Files
codebase-memory-mcp/tests/test_store_arch.c
T
2026-10-30 22:29:41 +01:00

2116 lines
80 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* test_store_arch.c — Tests for architecture, ADR, Louvain, and helper functions.
*
* Ports from internal/store/architecture_test.go:
* TestGetArchitectureAll, TestArchEntryPointsExcludeTests,
* TestArchHotspotsExcludeTests, TestGetArchitectureSpecificAspects,
* TestGetArchitectureEmpty, TestArchLanguages, TestArchLayers,
* TestArchClusters, TestArchFileTree, TestArchRoutes, TestArchHotspots,
* TestArchBoundaries, TestSearchCaseInsensitiveDefault,
* TestSearchCaseSensitiveExplicit, TestEnsureCaseInsensitive,
* TestStripCaseFlag, TestStoreAndRetrieveADR, TestStoreADRUpsert,
* TestDeleteADR, TestDeleteADRNotFound, TestParseADRSections,
* TestRenderADR, TestParseRenderRoundTrip, TestUpdateADRSections,
* TestUpdateADRSectionsOverflow, TestUpdateADRSectionsNoExisting,
* TestValidateADRContentAllSections, TestValidateADRContentMissingSections,
* TestValidateADRContentEmpty, TestValidateADRSectionKeysValid,
* TestValidateADRSectionKeysInvalid, TestValidateADRSectionKeysEmpty,
* TestLouvainBasic, TestLouvainEmpty, TestLouvainSingleNode,
* TestLouvainConverges, TestQnToPackage, TestQnToTopPackage,
* TestFindArchitectureDocs, TestFindArchitectureDocsEmpty,
* TestIsTestFilePath
*/
#include "test_framework.h"
#include <store/store.h>
#include "foundation/mem_core.h"
#include <string.h>
#include <stdlib.h>
#include <stdint.h>
#include <time.h>
/* ── Helper: create architecture test store ──────────────────────── */
static cbm_store_t *setup_arch_test_store(void) {
cbm_store_t *s = cbm_store_open_memory();
if (!s)
return NULL;
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Files */
const char *files[] = {"main.go", "handler.go", "service.go", "model.py", "utils.js"};
for (int i = 0; i < 5; i++) {
char qn[64];
snprintf(qn, sizeof(qn), "test.%s", files[i]);
cbm_node_t n = {.project = "test",
.label = "File",
.name = files[i],
.qualified_name = qn,
.file_path = files[i]};
cbm_store_upsert_node(s, &n);
}
/* Packages */
cbm_node_t pkg1 = {
.project = "test", .label = "Package", .name = "cmd", .qualified_name = "test.cmd"};
cbm_node_t pkg2 = {
.project = "test", .label = "Package", .name = "handler", .qualified_name = "test.handler"};
cbm_node_t pkg3 = {
.project = "test", .label = "Package", .name = "service", .qualified_name = "test.service"};
cbm_store_upsert_node(s, &pkg1);
cbm_store_upsert_node(s, &pkg2);
cbm_store_upsert_node(s, &pkg3);
/* Functions with 4-segment QNs */
cbm_node_t fn_main = {.project = "test",
.label = "Function",
.name = "main",
.qualified_name = "test.cmd.server.main",
.file_path = "cmd/server/main.go",
.properties_json = "{\"is_entry_point\":true}"};
int64_t id_main = cbm_store_upsert_node(s, &fn_main);
cbm_node_t fn_handle = {.project = "test",
.label = "Function",
.name = "HandleRequest",
.qualified_name = "test.internal.handler.HandleRequest",
.file_path = "internal/handler/handler.go",
.properties_json = "{\"is_entry_point\":true}"};
int64_t id_handle = cbm_store_upsert_node(s, &fn_handle);
cbm_node_t fn_process = {.project = "test",
.label = "Function",
.name = "ProcessOrder",
.qualified_name = "test.internal.service.ProcessOrder",
.file_path = "internal/service/service.go"};
int64_t id_process = cbm_store_upsert_node(s, &fn_process);
cbm_node_t fn_validate = {.project = "test",
.label = "Function",
.name = "ValidateOrder",
.qualified_name = "test.internal.service.ValidateOrder",
.file_path = "internal/service/service.go"};
int64_t id_validate = cbm_store_upsert_node(s, &fn_validate);
cbm_node_t fn_helper = {.project = "test",
.label = "Function",
.name = "formatDate",
.qualified_name = "test.internal.service.formatDate",
.file_path = "internal/service/service.go"};
int64_t id_helper = cbm_store_upsert_node(s, &fn_helper);
/* Test function (should be excluded) */
cbm_node_t fn_test = {.project = "test",
.label = "Function",
.name = "TestHandleRequest",
.qualified_name = "test.internal.handler.handler_test.TestHandleRequest",
.file_path = "internal/handler/handler_test.go",
.properties_json = "{\"is_entry_point\":true}"};
int64_t id_test = cbm_store_upsert_node(s, &fn_test);
/* Route */
cbm_node_t route = {
.project = "test",
.label = "Route",
.name = "/api/orders",
.qualified_name = "test.internal.handler.route./api/orders",
.properties_json =
"{\"method\":\"POST\",\"path\":\"/api/orders\",\"handler\":\"HandleRequest\"}"};
cbm_store_upsert_node(s, &route);
/* Edges: main → HandleRequest → ProcessOrder → ValidateOrder
* ProcessOrder → formatDate
* TestHandleRequest → HandleRequest */
cbm_edge_t e1 = {
.project = "test", .source_id = id_main, .target_id = id_handle, .type = "CALLS"};
cbm_edge_t e2 = {
.project = "test", .source_id = id_handle, .target_id = id_process, .type = "CALLS"};
cbm_edge_t e3 = {
.project = "test", .source_id = id_process, .target_id = id_validate, .type = "CALLS"};
cbm_edge_t e4 = {
.project = "test", .source_id = id_process, .target_id = id_helper, .type = "CALLS"};
cbm_edge_t e5 = {
.project = "test", .source_id = id_test, .target_id = id_handle, .type = "CALLS"};
cbm_store_insert_edge(s, &e1);
cbm_store_insert_edge(s, &e2);
cbm_store_insert_edge(s, &e3);
cbm_store_insert_edge(s, &e4);
cbm_store_insert_edge(s, &e5);
return s;
}
/* ── Architecture tests ──────────────────────────────────────────── */
TEST(arch_get_all) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, NULL, 0, &info), CBM_STORE_OK);
ASSERT_TRUE(info.language_count > 0);
ASSERT_TRUE(info.package_count > 0);
ASSERT_TRUE(info.entry_point_count > 0);
ASSERT_TRUE(info.route_count > 0);
ASSERT_TRUE(info.hotspot_count > 0);
ASSERT_TRUE(info.boundary_count > 0);
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
TEST(arch_entry_points_exclude_tests) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"entry_points"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK);
for (int i = 0; i < info.entry_point_count; i++) {
ASSERT_TRUE(strstr(info.entry_points[i].file, "test") == NULL);
}
ASSERT_EQ(info.entry_point_count, 2); /* main, HandleRequest */
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
TEST(arch_hotspots_exclude_tests) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"hotspots"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK);
for (int i = 0; i < info.hotspot_count; i++) {
ASSERT_TRUE(strstr(info.hotspots[i].name, "Test") == NULL);
}
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
TEST(arch_specific_aspects) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
const char *aspects[] = {"languages", "hotspots"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 2, &info), CBM_STORE_OK);
ASSERT_TRUE(info.language_count > 0);
ASSERT_TRUE(info.hotspot_count > 0);
/* Not requested: should be zero */
ASSERT_EQ(info.package_count, 0);
ASSERT_EQ(info.entry_point_count, 0);
ASSERT_EQ(info.route_count, 0);
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
TEST(arch_path_scoping) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "pscope", "/tmp/pscope"), CBM_STORE_OK);
cbm_node_t f1 = {.project = "pscope",
.label = "File",
.name = "a.go",
.qualified_name = "pscope.apps.foo.a.go",
.file_path = "apps/foo/a.go"};
cbm_node_t f2 = {.project = "pscope",
.label = "File",
.name = "b.go",
.qualified_name = "pscope.other.b.go",
.file_path = "other/b.go"};
cbm_store_upsert_node(s, &f1);
cbm_store_upsert_node(s, &f2);
cbm_node_t fn_foo = {.project = "pscope",
.label = "Function",
.name = "Foo",
.qualified_name = "pscope.apps.foo.Foo",
.file_path = "apps/foo/a.go"};
cbm_node_t fn_other = {.project = "pscope",
.label = "Function",
.name = "Bar",
.qualified_name = "pscope.other.Bar",
.file_path = "other/b.go"};
cbm_store_upsert_node(s, &fn_foo);
cbm_store_upsert_node(s, &fn_other);
const char *aspects[] = {"languages", "packages"};
cbm_architecture_info_t whole;
memset(&whole, 0, sizeof(whole));
ASSERT_EQ(cbm_store_get_architecture(s, "pscope", NULL, aspects, 2, &whole), CBM_STORE_OK);
cbm_architecture_info_t scoped;
memset(&scoped, 0, sizeof(scoped));
ASSERT_EQ(cbm_store_get_architecture(s, "pscope", "apps/foo", aspects, 2, &scoped),
CBM_STORE_OK);
int whole_go = 0;
int scoped_go = 0;
for (int i = 0; i < whole.language_count; i++) {
if (strcmp(whole.languages[i].language, "Go") == 0) {
whole_go = whole.languages[i].file_count;
}
}
for (int i = 0; i < scoped.language_count; i++) {
if (strcmp(scoped.languages[i].language, "Go") == 0) {
scoped_go = scoped.languages[i].file_count;
}
}
ASSERT_TRUE(whole_go > scoped_go);
ASSERT_EQ(scoped_go, 1);
int whole_pkg_nodes = 0;
for (int i = 0; i < whole.package_count; i++) {
whole_pkg_nodes += whole.packages[i].node_count;
}
int scoped_pkg_nodes = 0;
for (int i = 0; i < scoped.package_count; i++) {
scoped_pkg_nodes += scoped.packages[i].node_count;
}
ASSERT_TRUE(whole_pkg_nodes > scoped_pkg_nodes);
ASSERT_EQ(scoped_pkg_nodes, 1);
ASSERT_TRUE(cbm_store_count_nodes(s, "pscope") >
cbm_store_count_nodes_scoped(s, "pscope", "apps/foo"));
cbm_architecture_info_t scoped_slash;
memset(&scoped_slash, 0, sizeof(scoped_slash));
ASSERT_EQ(cbm_store_get_architecture(s, "pscope", "apps/foo/", aspects, 2, &scoped_slash),
CBM_STORE_OK);
int slash_go = 0;
for (int i = 0; i < scoped_slash.language_count; i++) {
if (strcmp(scoped_slash.languages[i].language, "Go") == 0) {
slash_go = scoped_slash.languages[i].file_count;
}
}
ASSERT_EQ(slash_go, scoped_go);
cbm_store_architecture_free(&scoped_slash);
cbm_store_architecture_free(&whole);
cbm_store_architecture_free(&scoped);
cbm_store_close(s);
PASS();
}
/* packages: count DESC, tied counts in name (BINARY) order. The aggregation
* runs off the label index now; a sorted GROUP BY follows the ORDER BY
* direction, which reversed the ties until the name tie-break was explicit. */
TEST(arch_packages_tied_counts_in_name_order) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
static const char *const pkg_names[] = {"gamma", "alpha", "Zeta", "beta",
"Alpha", "gamma", "beta"};
enum { PKG_NODES = 7, PKG_GROUPS = 5 };
for (int i = 0; i < PKG_NODES; i++) {
char qn[64];
snprintf(qn, sizeof(qn), "test.pkg%d.%s", i, pkg_names[i]);
cbm_node_t n = {.project = "test",
.label = "Package",
.name = pkg_names[i],
.qualified_name = qn,
.file_path = "pkg/manifest.toml"};
ASSERT_TRUE(cbm_store_upsert_node(s, &n) > 0);
}
static const char *const want_names[] = {"beta", "gamma", "Alpha", "Zeta", "alpha"};
static const int want_counts[] = {2, 2, 1, 1, 1};
const char *aspects[] = {"packages"};
const char *paths[] = {NULL, "pkg"}; /* unscoped and path-scoped SQL */
for (int p = 0; p < 2; p++) {
cbm_architecture_info_t info;
ASSERT_EQ(cbm_store_get_architecture(s, "test", paths[p], aspects, 1, &info),
CBM_STORE_OK);
ASSERT_EQ(info.package_count, PKG_GROUPS);
for (int i = 0; i < PKG_GROUPS; i++) {
ASSERT_STR_EQ(info.packages[i].name, want_names[i]);
ASSERT_EQ(info.packages[i].node_count, want_counts[i]);
}
cbm_store_architecture_free(&info);
}
cbm_store_close(s);
PASS();
}
TEST(arch_empty_project) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "empty", "/tmp/empty"), CBM_STORE_OK);
cbm_architecture_info_t info;
const char *aspects[] = {"all"};
ASSERT_EQ(cbm_store_get_architecture(s, "empty", NULL, aspects, 1, &info), CBM_STORE_OK);
/* All should be empty but no errors */
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
TEST(arch_languages) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"languages"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK);
/* Check Go=3, Python=1, JavaScript=1 */
int go_count = 0, py_count = 0, js_count = 0;
for (int i = 0; i < info.language_count; i++) {
if (strcmp(info.languages[i].language, "Go") == 0)
go_count = info.languages[i].file_count;
if (strcmp(info.languages[i].language, "Python") == 0)
py_count = info.languages[i].file_count;
if (strcmp(info.languages[i].language, "JavaScript") == 0)
js_count = info.languages[i].file_count;
}
ASSERT_EQ(go_count, 3);
ASSERT_EQ(py_count, 1);
ASSERT_EQ(js_count, 1);
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
TEST(arch_routes) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"routes"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK);
ASSERT_EQ(info.route_count, 1);
ASSERT_STR_EQ(info.routes[0].method, "POST");
ASSERT_STR_EQ(info.routes[0].path, "/api/orders");
ASSERT_STR_EQ(info.routes[0].handler, "HandleRequest");
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
TEST(arch_hotspots) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"hotspots"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK);
ASSERT_TRUE(info.hotspot_count > 0);
/* ProcessOrder should be a hotspot (called by HandleRequest) */
bool found = false;
for (int i = 0; i < info.hotspot_count; i++) {
if (strcmp(info.hotspots[i].name, "ProcessOrder") == 0) {
found = true;
ASSERT_TRUE(info.hotspots[i].fan_in >= 1);
}
}
/* May not be found with few edges — just log */
(void)found;
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
TEST(arch_boundaries) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"boundaries"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK);
ASSERT_TRUE(info.boundary_count > 0);
/* server → handler and handler → service should be present */
bool found_sh = false, found_hs = false;
for (int i = 0; i < info.boundary_count; i++) {
if (strcmp(info.boundaries[i].from, "server") == 0 &&
strcmp(info.boundaries[i].to, "handler") == 0)
found_sh = true;
if (strcmp(info.boundaries[i].from, "handler") == 0 &&
strcmp(info.boundaries[i].to, "service") == 0)
found_hs = true;
}
ASSERT_TRUE(found_sh);
ASSERT_TRUE(found_hs);
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
/* Build a synthetic graph (nodes Functions across packages, random CALLS
* edges) and return the wall ms of the "boundaries" aspect. Returns -1 on
* setup/query failure. */
static double timed_boundaries_ms(int n_nodes, int n_edges, int n_pkgs) {
cbm_store_t *s = cbm_store_open_memory();
if (!s) {
return -1;
}
cbm_store_upsert_project(s, "perf", "/tmp/perf");
cbm_store_begin(s);
int64_t *ids = malloc((size_t)n_nodes * sizeof(int64_t));
if (!ids) {
cbm_store_close(s);
return -1;
}
for (int i = 0; i < n_nodes; i++) {
char name[32], qn[64];
snprintf(name, sizeof(name), "fn%d", i);
snprintf(qn, sizeof(qn), "perf.pkg%d.fn%d", i % n_pkgs, i);
cbm_node_t n = {.project = "perf",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "f.c"};
ids[i] = cbm_store_upsert_node(s, &n);
}
uint64_t rng = 42;
for (int i = 0; i < n_edges; i++) {
rng = rng * 6364136223846793005ULL + 1442695040888963407ULL;
int a = (int)((rng >> 33) % (uint64_t)n_nodes);
rng = rng * 6364136223846793005ULL + 1442695040888963407ULL;
int b = (int)((rng >> 33) % (uint64_t)n_nodes);
cbm_edge_t e = {
.project = "perf", .source_id = ids[a], .target_id = ids[b], .type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
cbm_store_commit(s);
free(ids);
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"boundaries"};
struct timespec t0, t1;
clock_gettime(CLOCK_MONOTONIC, &t0);
int rc = cbm_store_get_architecture(s, "perf", NULL, aspects, 1, &info);
clock_gettime(CLOCK_MONOTONIC, &t1);
double ms =
(double)(t1.tv_sec - t0.tv_sec) * 1000.0 + (double)(t1.tv_nsec - t0.tv_nsec) / 1000000.0;
int bcount = info.boundary_count;
cbm_store_architecture_free(&info);
cbm_store_close(s);
if (rc != CBM_STORE_OK || bcount <= 0) {
return -1;
}
return ms;
}
/* arch_boundaries used a LINEAR SCAN over all def nodes for EVERY CALLS edge
* (lookup_pkg over parallel arrays) — O(E×N). On the Linux kernel graph
* (~1.4M defs × ~1.4M CALLS) get_architecture spun >10 min at 100% CPU. The
* bug was latent while C call extraction was broken (few CALLS edges) and
* surfaced when extraction was fixed.
*
* Guard is SELF-RELATIVE because absolute wall bounds do not survive CI's
* machine spread (207 ms locally vs ~26 s on the shared ubuntu-arm UBSan
* runner for the same work): doubling nodes AND edges must scale the aspect
* by ~2x (linear; allow 3x for noise), while the quadratic scales by ~4x.
* A fast absolute result short-circuits (pre-fix the small size alone took
* >4 s on an M3 Pro, so 2 s means the scan is certainly gone). */
TEST(arch_boundaries_no_quadratic_scan) {
enum { BN_NODES = 40000, BN_EDGES = 80000, BN_PKGS = 200, BN_FAST_MS = 2000 };
double t_small = timed_boundaries_ms(BN_NODES, BN_EDGES, BN_PKGS);
ASSERT_TRUE(t_small >= 0);
if (t_small < (double)BN_FAST_MS) {
printf(" boundaries %dk/%dk: %.0f ms — fast path, linear\n", BN_NODES / 1000,
BN_EDGES / 1000, t_small);
PASS();
}
double t_big = timed_boundaries_ms(BN_NODES * 2, BN_EDGES * 2, BN_PKGS);
ASSERT_TRUE(t_big >= 0);
double ratio = t_big / t_small;
printf(" boundaries %.0f ms -> %.0f ms at 2x size (ratio %.2f, quadratic ~4)\n", t_small,
t_big, ratio);
ASSERT_TRUE(ratio < 3.0);
PASS();
}
TEST(arch_layers) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"layers"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK);
ASSERT_TRUE(info.layer_count > 0);
/* Handler package has routes, should be "api" */
for (int i = 0; i < info.layer_count; i++) {
if (strcmp(info.layers[i].name, "handler") == 0) {
ASSERT_STR_EQ(info.layers[i].layer, "api");
}
}
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
TEST(arch_file_tree) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"file_tree"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK);
ASSERT_TRUE(info.file_tree_count > 0);
/* Check that entries have valid types */
for (int i = 0; i < info.file_tree_count; i++) {
ASSERT_TRUE(strcmp(info.file_tree[i].type, "dir") == 0 ||
strcmp(info.file_tree[i].type, "file") == 0);
}
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
TEST(arch_clusters) {
cbm_store_t *s = setup_arch_test_store();
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"clusters"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK);
/* With 5 functions and 4 edges, Louvain should find at least 1 cluster */
if (info.cluster_count == 0) {
/* May need more nodes for meaningful clustering — just log */
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
for (int i = 0; i < info.cluster_count; i++) {
ASSERT_TRUE(info.clusters[i].members >= 2);
ASSERT_NOT_NULL(info.clusters[i].label);
ASSERT_TRUE(info.clusters[i].label[0] != '\0');
}
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
/* ── ADR tests ──────────────────────────────────────────────────── */
TEST(adr_store_and_retrieve) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
const char *content = "## PURPOSE\nTest project for unit tests.\n\n## STACK\n- Go: speed";
ASSERT_EQ(cbm_store_adr_store(s, "test", content), CBM_STORE_OK);
cbm_adr_t adr;
ASSERT_EQ(cbm_store_adr_get(s, "test", &adr), CBM_STORE_OK);
ASSERT_STR_EQ(adr.content, content);
ASSERT_STR_EQ(adr.project, "test");
ASSERT_TRUE(adr.created_at != NULL && adr.created_at[0] != '\0');
ASSERT_TRUE(adr.updated_at != NULL && adr.updated_at[0] != '\0');
cbm_store_adr_free(&adr);
cbm_store_close(s);
PASS();
}
TEST(adr_restore_from_graph_if_absent_memory_is_noop) {
/* A :memory: store keeps ADRs in the graph DB, so restore-if-absent is a
* well-formed no-op (returns OK) and leaves an existing ADR untouched. */
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_store(s, "test", "v1"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_restore_from_graph_if_absent(s), CBM_STORE_OK);
cbm_adr_t adr;
ASSERT_EQ(cbm_store_adr_get(s, "test", &adr), CBM_STORE_OK);
ASSERT_STR_EQ(adr.content, "v1");
cbm_store_adr_free(&adr);
cbm_store_close(s);
PASS();
}
TEST(adr_upsert) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_store(s, "test", "v1"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_store(s, "test", "v2"), CBM_STORE_OK);
cbm_adr_t adr;
ASSERT_EQ(cbm_store_adr_get(s, "test", &adr), CBM_STORE_OK);
ASSERT_STR_EQ(adr.content, "v2");
cbm_store_adr_free(&adr);
cbm_store_close(s);
PASS();
}
TEST(adr_delete) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_store(s, "test", "## PURPOSE\nTest"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_delete(s, "test"), CBM_STORE_OK);
cbm_adr_t adr;
ASSERT_TRUE(cbm_store_adr_get(s, "test", &adr) != CBM_STORE_OK);
cbm_store_close(s);
PASS();
}
TEST(adr_delete_not_found) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_TRUE(cbm_store_adr_delete(s, "nonexistent") != CBM_STORE_OK);
cbm_store_close(s);
PASS();
}
/* Doc -> code candidates: a database without the table reads as "none";
* a replace swaps exactly one project's rows; reads go highest p first (then
* score) in both directions. */
TEST(doc_candidates_replace_and_get) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_doc_candidate_t *got = NULL;
int n = -1;
ASSERT_EQ(cbm_store_doc_candidates_get(s, "p", "p.secA", NULL, 5, &got, &n),
CBM_STORE_NOT_FOUND);
const cbm_doc_candidate_t rows[] = {
{.section_qn = "p.secA",
.target_qn = "p.f2",
.rank = 2,
.score = 0.25,
.p = 0.36,
.evidence = "{\"shared_terms\":3,\"name_tokens\":0}"},
{.section_qn = "p.secA",
.target_qn = "p.f1",
.rank = 1,
.score = 0.45,
.p = 0.548,
.evidence = "{\"shared_terms\":6,\"name_tokens\":1}"},
{.section_qn = "p.secB",
.target_qn = "p.f1",
.rank = 1,
.score = 0.32,
.p = 0.42,
.evidence = ""},
};
ASSERT_EQ(cbm_store_doc_candidates_replace(s, "p", rows, 3), CBM_STORE_OK);
const cbm_doc_candidate_t other = {.section_qn = "q.secA",
.target_qn = "q.f9",
.rank = 1,
.score = 0.5,
.p = 0.548,
.evidence = ""};
ASSERT_EQ(cbm_store_doc_candidates_replace(s, "q", &other, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_doc_candidates_get(s, "p", "p.secA", NULL, 5, &got, &n), CBM_STORE_OK);
ASSERT_EQ(n, 2);
ASSERT_STR_EQ(got[0].target_qn, "p.f1");
ASSERT_EQ(got[0].rank, 1);
ASSERT_STR_EQ(got[0].evidence, "{\"shared_terms\":6,\"name_tokens\":1}");
ASSERT_STR_EQ(got[1].target_qn, "p.f2");
cbm_store_doc_candidates_free(got, n);
ASSERT_EQ(cbm_store_doc_candidates_get(s, "p", NULL, "p.f1", 5, &got, &n), CBM_STORE_OK);
ASSERT_EQ(n, 2);
ASSERT_STR_EQ(got[0].section_qn, "p.secA");
ASSERT_STR_EQ(got[1].section_qn, "p.secB");
cbm_store_doc_candidates_free(got, n);
ASSERT_EQ(cbm_store_doc_candidates_get(s, "p", NULL, "p.f1", 1, &got, &n), CBM_STORE_OK);
ASSERT_EQ(n, 1);
cbm_store_doc_candidates_free(got, n);
/* a replace drops every earlier row of the project, and only of it */
ASSERT_EQ(cbm_store_doc_candidates_replace(s, "p", &rows[2], 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_doc_candidates_get(s, "p", "p.secA", NULL, 5, &got, &n), CBM_STORE_OK);
ASSERT_EQ(n, 0);
cbm_store_doc_candidates_free(got, n);
ASSERT_EQ(cbm_store_doc_candidates_get(s, "p", "p.secB", NULL, 5, &got, &n), CBM_STORE_OK);
ASSERT_EQ(n, 1);
cbm_store_doc_candidates_free(got, n);
ASSERT_EQ(cbm_store_doc_candidates_get(s, "q", "q.secA", NULL, 5, &got, &n), CBM_STORE_OK);
ASSERT_EQ(n, 1);
cbm_store_doc_candidates_free(got, n);
ASSERT_EQ(cbm_store_doc_candidates_replace(s, "p", NULL, 0), CBM_STORE_OK);
ASSERT_EQ(cbm_store_doc_candidates_get(s, "p", NULL, "p.f1", 5, &got, &n), CBM_STORE_OK);
ASSERT_EQ(n, 0);
cbm_store_doc_candidates_free(got, n);
/* exactly one of section / target */
ASSERT_EQ(cbm_store_doc_candidates_get(s, "p", NULL, NULL, 5, &got, &n), CBM_STORE_ERR);
ASSERT_EQ(cbm_store_doc_candidates_get(s, "p", "p.secA", "p.f1", 5, &got, &n), CBM_STORE_ERR);
cbm_store_close(s);
PASS();
}
/* Whole-file candidates of a path: the rows whose target is the path's File
* node or a Folder holding it -- never a sibling folder sharing a name prefix
* (pkg/mailer, pk), a function in the file, or another project's rows. */
TEST(doc_candidates_for_path_file_and_folders) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_doc_candidate_t *got = NULL;
int n = -1;
ASSERT_EQ(cbm_store_doc_candidates_for_path(s, "p", "pkg/mail/parse.py", 5, &got, &n),
CBM_STORE_NOT_FOUND);
cbm_store_upsert_project(s, "p", "/tmp/p");
const struct {
const char *label, *qn, *path;
} nodes[] = {{"File", "p.pkg.mail.parse.__file__", "pkg/mail/parse.py"},
{"Function", "p.pkg.mail.parse.parse_mail", "pkg/mail/parse.py"},
{"Folder", "p.pkg.mail", "pkg/mail"},
{"Folder", "p.pkg", "pkg"},
{"Folder", "p.pkg.mailer", "pkg/mailer"},
{"Folder", "p.pk", "pk"}};
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++) {
cbm_node_t node = {.project = "p",
.label = nodes[i].label,
.name = nodes[i].qn,
.qualified_name = nodes[i].qn,
.file_path = nodes[i].path};
ASSERT_TRUE(cbm_store_upsert_node(s, &node) > 0);
}
const cbm_doc_candidate_t rows[] = {
{.section_qn = "p.docs.a", .target_qn = "p.pkg.mail.parse.__file__", .p = 0.5},
{.section_qn = "p.docs.b", .target_qn = "p.pkg.mail", .p = 0.7},
{.section_qn = "p.docs.c", .target_qn = "p.pkg", .p = 0.3},
{.section_qn = "p.docs.d", .target_qn = "p.pkg.mailer", .p = 0.9},
{.section_qn = "p.docs.e", .target_qn = "p.pk", .p = 0.9},
{.section_qn = "p.docs.f", .target_qn = "p.pkg.mail.parse.parse_mail", .p = 0.95},
};
ASSERT_EQ(cbm_store_doc_candidates_replace(s, "p", rows, 6), CBM_STORE_OK);
const cbm_doc_candidate_t other = {
.section_qn = "q.docs.a", .target_qn = "p.pkg.mail", .p = 0.99};
ASSERT_EQ(cbm_store_doc_candidates_replace(s, "q", &other, 1), CBM_STORE_OK);
ASSERT_EQ(cbm_store_doc_candidates_for_path(s, "p", "pkg/mail/parse.py", 5, &got, &n),
CBM_STORE_OK);
ASSERT_EQ(n, 3);
ASSERT_STR_EQ(got[0].section_qn, "p.docs.b"); /* highest p first */
ASSERT_STR_EQ(got[1].section_qn, "p.docs.a");
ASSERT_STR_EQ(got[2].section_qn, "p.docs.c");
cbm_store_doc_candidates_free(got, n);
ASSERT_EQ(cbm_store_doc_candidates_for_path(s, "p", "pkg/mail/parse.py", 1, &got, &n),
CBM_STORE_OK);
ASSERT_EQ(n, 1);
cbm_store_doc_candidates_free(got, n);
ASSERT_EQ(cbm_store_doc_candidates_for_path(s, "p", "", 5, &got, &n), CBM_STORE_ERR);
cbm_store_close(s);
PASS();
}
TEST(adr_parse_sections_basic) {
cbm_adr_sections_t sec = cbm_adr_parse_sections("## PURPOSE\nFoo\n\n## STACK\nBar");
ASSERT_EQ(sec.count, 2);
ASSERT_STR_EQ(sec.keys[0], "PURPOSE");
ASSERT_STR_EQ(sec.values[0], "Foo");
ASSERT_STR_EQ(sec.keys[1], "STACK");
ASSERT_STR_EQ(sec.values[1], "Bar");
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_parse_sections_all_six) {
cbm_adr_sections_t sec =
cbm_adr_parse_sections("## PURPOSE\nA\n\n## STACK\nB\n\n## ARCHITECTURE\nC\n\n## "
"PATTERNS\nD\n\n## TRADEOFFS\nE\n\n## PHILOSOPHY\nF");
ASSERT_EQ(sec.count, 6);
ASSERT_STR_EQ(sec.keys[0], "PURPOSE");
ASSERT_STR_EQ(sec.values[0], "A");
ASSERT_STR_EQ(sec.keys[5], "PHILOSOPHY");
ASSERT_STR_EQ(sec.values[5], "F");
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_parse_sections_non_canonical) {
cbm_adr_sections_t sec =
cbm_adr_parse_sections("## PURPOSE\nFoo\n## CUSTOM\nStill in PURPOSE\n\n## STACK\nBar");
ASSERT_EQ(sec.count, 2);
ASSERT_STR_EQ(sec.keys[0], "PURPOSE");
ASSERT_STR_EQ(sec.values[0], "Foo\n## CUSTOM\nStill in PURPOSE");
ASSERT_STR_EQ(sec.keys[1], "STACK");
ASSERT_STR_EQ(sec.values[1], "Bar");
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_parse_sections_empty) {
cbm_adr_sections_t sec = cbm_adr_parse_sections("");
ASSERT_EQ(sec.count, 0);
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_parse_sections_preamble) {
cbm_adr_sections_t sec = cbm_adr_parse_sections("preamble\n## PURPOSE\nFoo");
ASSERT_EQ(sec.count, 1);
ASSERT_STR_EQ(sec.keys[0], "PURPOSE");
ASSERT_STR_EQ(sec.values[0], "Foo");
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_parse_sections_multiline) {
cbm_adr_sections_t sec =
cbm_adr_parse_sections("## PURPOSE\nLine 1\nLine 2\nLine 3\n\n## STACK\n- Go\n- SQLite");
ASSERT_EQ(sec.count, 2);
ASSERT_STR_EQ(sec.values[0], "Line 1\nLine 2\nLine 3");
ASSERT_STR_EQ(sec.values[1], "- Go\n- SQLite");
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_render_canonical_order) {
cbm_adr_sections_t sec = {.count = 2};
sec.keys[0] = strdup("STACK");
sec.values[0] = strdup("Bar");
sec.keys[1] = strdup("PURPOSE");
sec.values[1] = strdup("Foo");
char *rendered = cbm_adr_render(&sec);
ASSERT_STR_EQ(rendered, "## PURPOSE\nFoo\n\n## STACK\nBar");
free(rendered);
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_render_all_sections) {
cbm_adr_sections_t sec = {.count = 6};
sec.keys[0] = strdup("PHILOSOPHY");
sec.values[0] = strdup("F");
sec.keys[1] = strdup("PURPOSE");
sec.values[1] = strdup("A");
sec.keys[2] = strdup("STACK");
sec.values[2] = strdup("B");
sec.keys[3] = strdup("ARCHITECTURE");
sec.values[3] = strdup("C");
sec.keys[4] = strdup("PATTERNS");
sec.values[4] = strdup("D");
sec.keys[5] = strdup("TRADEOFFS");
sec.values[5] = strdup("E");
char *rendered = cbm_adr_render(&sec);
ASSERT_STR_EQ(rendered, "## PURPOSE\nA\n\n## STACK\nB\n\n## ARCHITECTURE\nC\n\n## "
"PATTERNS\nD\n\n## TRADEOFFS\nE\n\n## PHILOSOPHY\nF");
free(rendered);
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_render_non_canonical) {
cbm_adr_sections_t sec = {.count = 3};
sec.keys[0] = strdup("PURPOSE");
sec.values[0] = strdup("Foo");
sec.keys[1] = strdup("ZEBRA");
sec.values[1] = strdup("Z");
sec.keys[2] = strdup("ALPHA");
sec.values[2] = strdup("A");
char *rendered = cbm_adr_render(&sec);
ASSERT_STR_EQ(rendered, "## PURPOSE\nFoo\n\n## ALPHA\nA\n\n## ZEBRA\nZ");
free(rendered);
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_render_empty) {
cbm_adr_sections_t sec = {.count = 0};
char *rendered = cbm_adr_render(&sec);
ASSERT_STR_EQ(rendered, "");
free(rendered);
PASS();
}
/* Helper: allocate a NUL-terminated buffer of `len` 'x' characters. */
static char *adr_test_fill(int len) {
char *s = malloc((size_t)len + 1);
if (s) {
memset(s, 'x', (size_t)len);
s[len] = '\0';
}
return s;
}
/* Regression: sections whose combined size exceeds the fixed render buffer used
* to overflow it. adr_render_section advanced pos by snprintf's return value
* (the length it WOULD have written); once pos passed buf_sz the next section
* computed a wrapped (huge) remaining size from (buf_sz - pos) and wrote past
* the stack buffer (ASan: stack-buffer-overflow WRITE).
*
* The render buffer is ST_MAX_DEGREE*ST_GROWTH = 16384. Sizes below drive the
* cursor to exactly buf_sz+8 after section "B" (a truncating write that stays
* in bounds), so section "C" performs the first out-of-bounds write right at
* the buffer's tail — into ASan's redzone, where it is reliably caught.
* Non-canonical keys render in alphabetical order (A, B, C). */
TEST(adr_render_oversized_sections_no_overflow) {
enum { ADR_RENDER_BUFSZ = 16384 };
cbm_adr_sections_t sec = {.count = 3};
/* A is first: header "## A\n" (5) + value -> pos = 16000. */
sec.keys[0] = strdup("A");
sec.values[0] = adr_test_fill(15995);
/* B: header "\n\n## B\n" (7) + value -> intended pos = 16000+7+385 = 16392
* (= buf_sz + 8); the write itself truncates safely in bounds. */
sec.keys[1] = strdup("B");
sec.values[1] = adr_test_fill(385);
/* C: rendered with pos = 16392 > buf_sz -> "\n\n" written at buf+16392. */
sec.keys[2] = strdup("C");
sec.values[2] = strdup("z");
for (int i = 0; i < 3; i++) {
ASSERT_NOT_NULL(sec.values[i]);
}
char *rendered = cbm_adr_render(&sec);
/* Must not crash; result must be NUL-terminated within the render buffer. */
ASSERT_NOT_NULL(rendered);
ASSERT_TRUE(strlen(rendered) < ADR_RENDER_BUFSZ);
free(rendered);
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_parse_render_roundtrip) {
const char *original =
"## PURPOSE\nTest project\n\n## STACK\n- Go: speed\n- SQLite: embedded\n\n"
"## ARCHITECTURE\nPipeline pattern\n\n## PATTERNS\n- Convention over config\n\n"
"## TRADEOFFS\n- Speed over features\n\n## PHILOSOPHY\n- Keep it simple";
cbm_adr_sections_t sec = cbm_adr_parse_sections(original);
char *rendered = cbm_adr_render(&sec);
ASSERT_STR_EQ(rendered, original);
free(rendered);
cbm_adr_sections_free(&sec);
PASS();
}
TEST(adr_update_sections) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_store(s, "test", "## PURPOSE\nOriginal purpose\n\n## STACK\n- Go"),
CBM_STORE_OK);
const char *keys[] = {"PATTERNS"};
const char *values[] = {"- Pipeline pattern"};
cbm_adr_t updated;
ASSERT_EQ(cbm_store_adr_update_sections(s, "test", keys, values, 1, &updated), CBM_STORE_OK);
/* Verify all sections preserved */
cbm_adr_sections_t sec = cbm_adr_parse_sections(updated.content);
bool found_purpose = false, found_stack = false, found_patterns = false;
for (int i = 0; i < sec.count; i++) {
if (strcmp(sec.keys[i], "PURPOSE") == 0) {
ASSERT_STR_EQ(sec.values[i], "Original purpose");
found_purpose = true;
}
if (strcmp(sec.keys[i], "STACK") == 0) {
ASSERT_STR_EQ(sec.values[i], "- Go");
found_stack = true;
}
if (strcmp(sec.keys[i], "PATTERNS") == 0) {
ASSERT_STR_EQ(sec.values[i], "- Pipeline pattern");
found_patterns = true;
}
}
ASSERT_TRUE(found_purpose);
ASSERT_TRUE(found_stack);
ASSERT_TRUE(found_patterns);
cbm_adr_sections_free(&sec);
cbm_store_adr_free(&updated);
cbm_store_close(s);
PASS();
}
TEST(adr_update_overflow) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_adr_store(s, "test", "## PURPOSE\nShort"), CBM_STORE_OK);
/* Create huge content */
char *huge = malloc(CBM_ADR_MAX_LENGTH + 2);
memset(huge, 'x', CBM_ADR_MAX_LENGTH + 1);
huge[CBM_ADR_MAX_LENGTH + 1] = '\0';
const char *keys[] = {"STACK"};
const char *values[] = {huge};
cbm_adr_t out;
ASSERT_TRUE(cbm_store_adr_update_sections(s, "test", keys, values, 1, &out) != CBM_STORE_OK);
free(huge);
cbm_store_close(s);
PASS();
}
TEST(adr_update_no_existing) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
const char *keys[] = {"PURPOSE"};
const char *values[] = {"New purpose"};
cbm_adr_t out;
ASSERT_TRUE(cbm_store_adr_update_sections(s, "test", keys, values, 1, &out) != CBM_STORE_OK);
cbm_store_close(s);
PASS();
}
TEST(adr_validate_all_sections) {
const char *content = "## PURPOSE\nA\n\n## STACK\nB\n\n## ARCHITECTURE\nC\n\n## "
"PATTERNS\nD\n\n## TRADEOFFS\nE\n\n## PHILOSOPHY\nF";
char errbuf[256];
ASSERT_EQ(cbm_adr_validate_content(content, errbuf, sizeof(errbuf)), CBM_STORE_OK);
PASS();
}
TEST(adr_validate_missing_sections) {
const char *content = "## PURPOSE\nA\n\n## STACK\nB";
char errbuf[512];
ASSERT_TRUE(cbm_adr_validate_content(content, errbuf, sizeof(errbuf)) != CBM_STORE_OK);
/* Error should mention missing sections */
ASSERT_TRUE(strstr(errbuf, "ARCHITECTURE") != NULL);
ASSERT_TRUE(strstr(errbuf, "PATTERNS") != NULL);
ASSERT_TRUE(strstr(errbuf, "TRADEOFFS") != NULL);
ASSERT_TRUE(strstr(errbuf, "PHILOSOPHY") != NULL);
PASS();
}
TEST(adr_validate_empty) {
char errbuf[256];
ASSERT_TRUE(cbm_adr_validate_content("", errbuf, sizeof(errbuf)) != CBM_STORE_OK);
PASS();
}
TEST(adr_validate_keys_valid) {
const char *keys[] = {"PURPOSE", "STACK"};
char errbuf[256];
ASSERT_EQ(cbm_adr_validate_section_keys(keys, 2, errbuf, sizeof(errbuf)), CBM_STORE_OK);
PASS();
}
/* Section names are no longer restricted to the canonical six — those are a
* convention now, so "STACKS" and "CUSTOM" are ordinary sections. What is
* still refused is a name that could not round-trip through a "## NAME" line:
* such a name scans back as a different heading or as none, so a second
* identical write would append a duplicate instead of being a no-op. */
TEST(adr_validate_keys_accepts_arbitrary_names) {
const char *keys[] = {"PURPOSE", "STACKS", "CUSTOM", "Decisions (2026)"};
char errbuf[256];
ASSERT_EQ(cbm_adr_validate_section_keys(keys, 4, errbuf, sizeof(errbuf)), CBM_STORE_OK);
PASS();
}
TEST(adr_validate_keys_rejects_unroundtrippable) {
char errbuf[256];
const char *empty[] = {""};
ASSERT_TRUE(cbm_adr_validate_section_keys(empty, 1, errbuf, sizeof(errbuf)) != CBM_STORE_OK);
const char *hashed[] = {"# PURPOSE"};
ASSERT_TRUE(cbm_adr_validate_section_keys(hashed, 1, errbuf, sizeof(errbuf)) != CBM_STORE_OK);
const char *newline[] = {"PUR\nPOSE"};
ASSERT_TRUE(cbm_adr_validate_section_keys(newline, 1, errbuf, sizeof(errbuf)) != CBM_STORE_OK);
const char *padded[] = {" PURPOSE "};
ASSERT_TRUE(cbm_adr_validate_section_keys(padded, 1, errbuf, sizeof(errbuf)) != CBM_STORE_OK);
char toolong[128];
memset(toolong, 'X', sizeof(toolong) - 1);
toolong[sizeof(toolong) - 1] = '\0';
const char *big[] = {toolong};
ASSERT_TRUE(cbm_adr_validate_section_keys(big, 1, errbuf, sizeof(errbuf)) != CBM_STORE_OK);
PASS();
}
/* ── Splice: bytes outside the target span never change ─────────── */
typedef struct {
char buf[512];
int n;
} adr_name_collect_t;
static void adr_collect_names(void *ctx, const cbm_adr_heading_t *h) {
adr_name_collect_t *c = (adr_name_collect_t *)ctx;
c->n += snprintf(c->buf + c->n, sizeof(c->buf) - (size_t)c->n, "[%.*s]", h->name_len, h->name);
}
static int adr_check_splice(const char *in, const char *name, const char *body,
const char *expect) {
char *out = cbm_adr_splice_section(in, name, body);
ASSERT_NOT_NULL(out);
if (strcmp(out, expect) != 0) {
printf(" %sFAIL%s %s: splice(\"%s\")\n in >>>%s<<<\n got >>>%s<<<\n"
" expect >>>%s<<<\n",
tf_red(), tf_reset(), __FILE__, name, in, out, expect);
free(out);
return 1;
}
free(out);
return 0;
}
#define CHECK_SPLICE(in, name, body, expect) \
do { \
if (adr_check_splice((in), (name), (body), (expect)) != 0) { \
return 1; \
} \
} while (0)
/* THE acceptance property. Every document below is a case where rebuilding
* from cbm_adr_parse_sections() would have rewritten or destroyed text: a
* preamble (dropped), a mis-cased heading (dropped with its whole block), an
* unrecognised heading in prose (absorbed), a fenced '##' (absorbed), and
* out-of-canonical-order sections (reordered). Splicing touches only the
* target span, so each survives byte-for-byte. */
TEST(adr_splice_preserves_untouched_bytes) {
/* Preamble before the first heading. */
CHECK_SPLICE("Some preamble.\n\n## PURPOSE\nFoo", "PURPOSE", "New foo",
"Some preamble.\n\n## PURPOSE\nNew foo");
/* Mis-cased heading: a real section now, and untouched when not targeted. */
CHECK_SPLICE("## Purpose\nFoo\n\n## STACK\nBar", "STACK", "New bar",
"## Purpose\nFoo\n\n## STACK\nNew bar");
/* A heading in prose that the old parser absorbed into the section above. */
CHECK_SPLICE("## PURPOSE\nFoo\n## CUSTOM\nStill here\n\n## STACK\nBar", "STACK", "New bar",
"## PURPOSE\nFoo\n## CUSTOM\nStill here\n\n## STACK\nNew bar");
/* Fenced code containing a '##' line. */
CHECK_SPLICE("## PURPOSE\nFoo\n\n```md\n## Example\n```\n\n## STACK\nBar", "STACK", "New bar",
"## PURPOSE\nFoo\n\n```md\n## Example\n```\n\n## STACK\nNew bar");
/* Sections stored out of canonical order keep the author's order. */
CHECK_SPLICE("## STACK\nBar\n\n## PURPOSE\nFoo", "PURPOSE", "New foo",
"## STACK\nBar\n\n## PURPOSE\nNew foo");
/* The separator run before the next heading is preserved exactly. */
CHECK_SPLICE("## A\nx\n\n\n## B\ny", "A", "z", "## A\nz\n\n\n## B\ny");
/* A trailing newline on the document is preserved. */
CHECK_SPLICE("## A\nx\n", "A", "z", "## A\nz\n");
PASS();
}
/* Line endings. Every other ADR fixture in this file uses \n, so nothing
* exercised a CRLF-stored document — and CI could not have caught that,
* because the fixtures are LF whichever platform runs them.
*
* The exposure is specific to splicing: the old rebuild normalised everything
* on the way out, so ending confusion was invisible. A byte-span replacement
* can cut mid-"\r\n" or miscount a separator run, and byte-identity is the
* property this design exists to provide.
*
* Contract: bytes that already exist are never rewritten, so each line keeps
* whatever ending it had. Text this code writes itself — the separator before
* an appended section and the "## NAME" line — follows the document's majority
* ending, LF on a tie. A caller's body is inserted verbatim; rewriting the
* bytes a caller supplied would be the same silent modification this whole
* change removes. */
TEST(adr_splice_preserves_line_endings) {
/* CRLF throughout: untouched bytes keep their \r\n. */
CHECK_SPLICE("## PURPOSE\r\nFoo\r\n\r\n## STACK\r\nBar", "STACK", "New bar",
"## PURPOSE\r\nFoo\r\n\r\n## STACK\r\nNew bar");
/* The separator run before the next heading survives as CRLF, not as the
* two bare newlines a naive walk-back would leave. */
CHECK_SPLICE("## A\r\nx\r\n\r\n## B\r\ny", "A", "z", "## A\r\nz\r\n\r\n## B\r\ny");
/* A trailing CRLF on the document is preserved as CRLF. */
CHECK_SPLICE("## A\r\nx\r\n", "A", "z", "## A\r\nz\r\n");
/* Appending to a CRLF document writes CRLF — a bare \n here would leave a
* mixed document that neither the author nor the tool asked for. */
CHECK_SPLICE("## PURPOSE\r\nFoo", "DECISIONS", "- New.",
"## PURPOSE\r\nFoo\r\n\r\n## DECISIONS\r\n- New.");
/* One existing trailing break means one more is added, counting \r\n as a
* single break rather than as two characters. */
CHECK_SPLICE("## PURPOSE\r\nFoo\r\n", "DECISIONS", "- New.",
"## PURPOSE\r\nFoo\r\n\r\n## DECISIONS\r\n- New.");
/* A document already ending in a CRLF blank line gains no extra break. */
CHECK_SPLICE("## PURPOSE\r\nFoo\r\n\r\n", "DECISIONS", "- New.",
"## PURPOSE\r\nFoo\r\n\r\n## DECISIONS\r\n- New.");
/* LF documents are unaffected by any of the above. */
CHECK_SPLICE("## PURPOSE\nFoo\n", "DECISIONS", "- New.",
"## PURPOSE\nFoo\n\n## DECISIONS\n- New.");
PASS();
}
/* Mixed endings are what real files become through editors and merges. Lines
* that already exist keep their own endings; the majority decides only what
* this code writes itself. */
TEST(adr_splice_mixed_line_endings) {
/* Majority CRLF (2 of 3) -> the appended block is CRLF, and the one bare
* LF line in the middle is left exactly as it was. */
CHECK_SPLICE("## PURPOSE\r\nFoo\nBar\r\n", "DECISIONS", "- New.",
"## PURPOSE\r\nFoo\nBar\r\n\r\n## DECISIONS\r\n- New.");
/* Majority LF (2 of 3) -> the appended block is LF, and the lone CRLF line
* survives untouched. */
CHECK_SPLICE("## PURPOSE\nFoo\r\nBar\n", "DECISIONS", "- New.",
"## PURPOSE\nFoo\r\nBar\n\n## DECISIONS\n- New.");
/* A tie falls to LF. */
CHECK_SPLICE("## PURPOSE\r\nFoo\n", "DECISIONS", "- New.",
"## PURPOSE\r\nFoo\n\n## DECISIONS\n- New.");
/* Replacing a section in a mixed document rewrites nothing around it. */
CHECK_SPLICE("## PURPOSE\r\nFoo\n\n## STACK\nBar\r\n", "STACK", "New",
"## PURPOSE\r\nFoo\n\n## STACK\nNew\r\n");
PASS();
}
/* "## PURPOSE\r\n" must locate exactly as "## PURPOSE\n" does. The scanner
* trims \r from heading names, but trimming and matching are different claims
* and only one of them was pinned. */
TEST(adr_splice_matches_headings_across_line_endings) {
adr_name_collect_t c;
memset(&c, 0, sizeof(c));
ASSERT_EQ(
cbm_adr_scan_headings("## PURPOSE\r\nFoo\r\n\r\n## STACK\r\nBar", adr_collect_names, &c),
CBM_STORE_OK);
ASSERT_STR_EQ(c.buf, "[PURPOSE][STACK]");
/* A fenced block with CRLF still hides its heading, and still closes. */
memset(&c, 0, sizeof(c));
ASSERT_EQ(cbm_adr_scan_headings("## PURPOSE\r\nFoo\r\n\r\n```md\r\n## Example\r\n```"
"\r\n\r\n## STACK\r\nBar",
adr_collect_names, &c),
CBM_STORE_OK);
ASSERT_STR_EQ(c.buf, "[PURPOSE][STACK]");
/* And an unterminated CRLF fence is still refused. */
char errbuf[256];
ASSERT_TRUE(cbm_adr_check_structure("## PURPOSE\r\nFoo\r\n\r\n```\r\nopen\r\n", errbuf,
sizeof(errbuf)) != CBM_STORE_OK);
PASS();
}
/* The original use case: add an entry under its own heading. */
TEST(adr_splice_appends_arbitrary_heading) {
CHECK_SPLICE("## PURPOSE\nFoo", "DECISIONS", "- Chose SQLite.",
"## PURPOSE\nFoo\n\n## DECISIONS\n- Chose SQLite.");
/* A document already ending in a blank line does not gain another. */
CHECK_SPLICE("## PURPOSE\nFoo\n\n", "DECISIONS", "- Chose SQLite.",
"## PURPOSE\nFoo\n\n## DECISIONS\n- Chose SQLite.");
/* An empty document becomes a plain create with no leading separator. */
CHECK_SPLICE("", "PURPOSE", "Only entry.", "## PURPOSE\nOnly entry.");
PASS();
}
/* Splicing twice must be byte-identical to splicing once — including for a
* non-canonical heading, the case that would have corrupted an ADR under the
* old rules. Trailing newlines on the body are normalised so the separator run
* cannot grow by a blank line on every repeat. */
TEST(adr_splice_is_idempotent) {
const char *doc = "## PURPOSE\nFoo\n\n## STACK\nBar";
char *once = cbm_adr_splice_section(doc, "DECISIONS", "- Chose SQLite.\n");
ASSERT_NOT_NULL(once);
char *twice = cbm_adr_splice_section(once, "DECISIONS", "- Chose SQLite.\n");
ASSERT_NOT_NULL(twice);
ASSERT_STR_EQ(twice, once);
char *thrice = cbm_adr_splice_section(twice, "DECISIONS", "- Chose SQLite.\n");
ASSERT_NOT_NULL(thrice);
ASSERT_STR_EQ(thrice, once);
free(once);
free(twice);
free(thrice);
PASS();
}
/* Names match exactly, including case: folding them would silently merge two
* blocks the author chose to keep apart. */
TEST(adr_splice_matches_case_exactly) {
CHECK_SPLICE("## Purpose\nLower\n\n## PURPOSE\nUpper", "PURPOSE", "New",
"## Purpose\nLower\n\n## PURPOSE\nNew");
CHECK_SPLICE("## Purpose\nLower\n\n## PURPOSE\nUpper", "Purpose", "New",
"## Purpose\nNew\n\n## PURPOSE\nUpper");
PASS();
}
/* A '##' line inside a fenced code block is a code sample, not a heading —
* and '#' / '###' are not section headings at any position. */
TEST(adr_splice_ignores_heading_inside_fence) {
adr_name_collect_t c;
memset(&c, 0, sizeof(c));
ASSERT_EQ(cbm_adr_scan_headings(
"## PURPOSE\nFoo\n\n```md\n## Example\n```\n\n### Sub\n# Title\n\n## STACK\nBar",
adr_collect_names, &c),
CBM_STORE_OK);
ASSERT_STR_EQ(c.buf, "[PURPOSE][STACK]");
PASS();
}
/* An unterminated fence hides every heading after it, so an update would
* append a duplicate rather than replace. Refuse instead of guessing. */
TEST(adr_splice_refuses_unterminated_fence) {
const char *doc = "## PURPOSE\nFoo\n\n```\nunclosed sample\n\n## STACK\nBar";
char errbuf[256];
ASSERT_TRUE(cbm_adr_check_structure(doc, errbuf, sizeof(errbuf)) != CBM_STORE_OK);
ASSERT_TRUE(strstr(errbuf, "code fence") != NULL);
adr_name_collect_t c;
memset(&c, 0, sizeof(c));
ASSERT_TRUE(cbm_adr_scan_headings(doc, adr_collect_names, &c) != CBM_STORE_OK);
ASSERT_EQ(c.n, 0);
ASSERT_NULL(cbm_adr_splice_section(doc, "STACK", "New bar"));
/* A closed fence is fine. */
ASSERT_EQ(cbm_adr_check_structure("## A\n```\nx\n```\n", errbuf, sizeof(errbuf)), CBM_STORE_OK);
PASS();
}
TEST(adr_validate_keys_empty) {
char errbuf[256];
ASSERT_EQ(cbm_adr_validate_section_keys(NULL, 0, errbuf, sizeof(errbuf)), CBM_STORE_OK);
PASS();
}
/* ── cbm_store_fetch_call_edges / cbm_store_free_call_edges tests ─ */
TEST(fetch_call_edges_basic) {
cbm_store_t *s = setup_arch_test_store();
ASSERT_NOT_NULL(s);
int64_t *src = NULL;
int64_t *tgt = NULL;
int count = 0;
bool truncated = true;
ASSERT_EQ(cbm_store_fetch_call_edges(s, "test", 100, &src, &tgt, &count, &truncated),
CBM_STORE_OK);
/* setup_arch_test_store() wires 5 CALLS edges among Function nodes. */
ASSERT_EQ(count, 5);
ASSERT_FALSE(truncated);
ASSERT_NOT_NULL(src);
ASSERT_NOT_NULL(tgt);
cbm_store_free_call_edges(src, tgt);
cbm_store_close(s);
PASS();
}
TEST(fetch_call_edges_truncated) {
cbm_store_t *s = setup_arch_test_store();
ASSERT_NOT_NULL(s);
int64_t *src = NULL;
int64_t *tgt = NULL;
int count = 0;
bool truncated = false;
/* Only 5 CALLS edges exist; capping at 2 must set truncated. */
ASSERT_EQ(cbm_store_fetch_call_edges(s, "test", 2, &src, &tgt, &count, &truncated),
CBM_STORE_OK);
ASSERT_EQ(count, 2);
ASSERT_TRUE(truncated);
cbm_store_free_call_edges(src, tgt);
cbm_store_close(s);
PASS();
}
TEST(fetch_call_edges_empty_project) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "empty", "/tmp/empty");
int64_t *src = NULL;
int64_t *tgt = NULL;
int count = 0;
bool truncated = true;
ASSERT_EQ(cbm_store_fetch_call_edges(s, "empty", 100, &src, &tgt, &count, &truncated),
CBM_STORE_OK);
ASSERT_EQ(count, 0);
ASSERT_FALSE(truncated);
/* cbm_store_free_call_edges(NULL, NULL) must not crash. */
cbm_store_free_call_edges(src, tgt);
cbm_store_close(s);
PASS();
}
/* The two arrays cbm_store_fetch_call_edges hands out are memory-core blocks of
* the STORE class, and cbm_store_free_call_edges is what takes them back. Both
* halves are measured: the fetch adds exactly the two arrays and the paired free
* removes exactly those, so a raw allocation on either side fails. Thirty-six
* edges outgrow the initial capacity of 16, so the growth path is covered too. */
TEST(fetch_call_edges_memory_core_ownership) {
enum { FN = 37 };
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "grow", "/tmp/grow");
int64_t ids[FN];
for (int i = 0; i < FN; i++) {
char name[32];
char qn[64];
snprintf(name, sizeof(name), "f%d", i);
snprintf(qn, sizeof(qn), "grow.pkg.f%d", i);
cbm_node_t n = {.project = "grow",
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "pkg/f.go"};
ids[i] = cbm_store_upsert_node(s, &n);
ASSERT_TRUE(ids[i] > 0);
}
for (int i = 0; i + 1 < FN; i++) {
cbm_edge_t e = {
.project = "grow", .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"};
ASSERT_TRUE(cbm_store_insert_edge(s, &e) > 0);
}
size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE);
size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE);
int64_t *src = NULL;
int64_t *tgt = NULL;
int count = 0;
bool truncated = true;
ASSERT_EQ(cbm_store_fetch_call_edges(s, "grow", 100, &src, &tgt, &count, &truncated),
CBM_STORE_OK);
ASSERT_EQ(count, FN - 1);
ASSERT_FALSE(truncated);
ASSERT_NOT_NULL(src);
ASSERT_NOT_NULL(tgt);
/* Ordered by (source_id, target_id): growth kept every pair in place. */
ASSERT_EQ(src[0], ids[0]);
ASSERT_EQ(tgt[0], ids[1]);
ASSERT_EQ(src[FN - 2], ids[FN - 2]);
ASSERT_EQ(tgt[FN - 2], ids[FN - 1]);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE), blocks + 2);
ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE) >=
bytes + 2 * (size_t)count * sizeof(int64_t));
cbm_store_free_call_edges(src, tgt);
ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_STORE), bytes);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_STORE), blocks);
cbm_store_close(s);
PASS();
}
/* ── Louvain tests ──────────────────────────────────────────────── */
TEST(louvain_basic) {
/* Triangle: 1-2, 2-3, 1-3. Pair: 4-5. */
int64_t nodes[] = {1, 2, 3, 4, 5};
cbm_louvain_edge_t edges[] = {{1, 2}, {2, 3}, {1, 3}, {4, 5}};
cbm_louvain_result_t *result = NULL;
int count = 0;
ASSERT_EQ(cbm_louvain(nodes, 5, edges, 4, &result, &count), CBM_STORE_OK);
ASSERT_EQ(count, 5);
/* Build node→community map */
int comm[6] = {0};
for (int i = 0; i < count; i++) {
comm[result[i].node_id] = result[i].community;
}
/* Triangle should be same community */
ASSERT_EQ(comm[1], comm[2]);
ASSERT_EQ(comm[2], comm[3]);
/* Pair should be same community */
ASSERT_EQ(comm[4], comm[5]);
/* Triangle and pair different */
ASSERT_TRUE(comm[1] != comm[4]);
cbm_leiden_free(result);
PASS();
}
TEST(louvain_empty) {
cbm_louvain_result_t *result = NULL;
int count = 0;
ASSERT_EQ(cbm_louvain(NULL, 0, NULL, 0, &result, &count), CBM_STORE_OK);
ASSERT_EQ(count, 0);
cbm_leiden_free(result);
PASS();
}
TEST(louvain_single_node) {
int64_t nodes[] = {42};
cbm_louvain_result_t *result = NULL;
int count = 0;
ASSERT_EQ(cbm_louvain(nodes, 1, NULL, 0, &result, &count), CBM_STORE_OK);
ASSERT_EQ(count, 1);
ASSERT_EQ(result[0].node_id, 42);
cbm_leiden_free(result);
PASS();
}
TEST(louvain_converges) {
/* Two fully connected clusters of 10 nodes each, bridged by one edge */
int64_t nodes[20];
cbm_louvain_edge_t edges[200];
int nedges = 0;
for (int i = 0; i < 20; i++)
nodes[i] = i + 1;
/* Cluster 1: nodes 1-10, fully connected */
for (int i = 1; i <= 10; i++) {
for (int j = i + 1; j <= 10; j++) {
edges[nedges++] = (cbm_louvain_edge_t){i, j};
}
}
/* Cluster 2: nodes 11-20, fully connected */
for (int i = 11; i <= 20; i++) {
for (int j = i + 1; j <= 20; j++) {
edges[nedges++] = (cbm_louvain_edge_t){i, j};
}
}
/* Bridge */
edges[nedges++] = (cbm_louvain_edge_t){5, 15};
cbm_louvain_result_t *result = NULL;
int count = 0;
ASSERT_EQ(cbm_louvain(nodes, 20, edges, nedges, &result, &count), CBM_STORE_OK);
ASSERT_EQ(count, 20);
/* Count communities */
int comm_map[21] = {0};
for (int i = 0; i < count; i++) {
comm_map[result[i].node_id] = result[i].community;
}
/* Count distinct communities */
int distinct[20];
int ndistinct = 0;
for (int i = 0; i < count; i++) {
bool found = false;
for (int j = 0; j < ndistinct; j++) {
if (distinct[j] == result[i].community) {
found = true;
break;
}
}
if (!found)
distinct[ndistinct++] = result[i].community;
}
ASSERT_TRUE(ndistinct >= 2);
/* Nodes 1-10 mostly in same community */
int same_count = 0;
for (int i = 1; i <= 10; i++) {
if (comm_map[i] == comm_map[1])
same_count++;
}
ASSERT_TRUE(same_count >= 8);
cbm_leiden_free(result);
PASS();
}
/* Both the populated graph and isolated-node path return exactly one owned
* output block. The Louvain compatibility entry point shares this contract. */
TEST(leiden_result_memory_core_ownership) {
int64_t nodes[] = {1, 2, 3};
cbm_louvain_edge_t edges[] = {{1, 2}, {2, 3}};
for (int wrapper = 0; wrapper < 2; wrapper++) {
for (int connected = 0; connected < 2; connected++) {
size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER);
size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER);
cbm_louvain_result_t *result = NULL;
int count = 0;
const cbm_louvain_edge_t *input = connected ? edges : NULL;
int edge_count = connected ? 2 : 0;
int rc = wrapper ? cbm_louvain(nodes, 3, input, edge_count, &result, &count)
: cbm_leiden(nodes, 3, input, edge_count, 1.0, &result, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 3);
ASSERT_NOT_NULL(result);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks + 1);
ASSERT_TRUE(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER) >=
bytes + 3 * sizeof(*result));
/* Released through the exported pair, the way an FFI caller must. */
cbm_leiden_free(result);
ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks);
}
}
size_t bytes = cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER);
size_t blocks = cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER);
cbm_louvain_result_t *result = NULL;
int count = -1;
ASSERT_EQ(cbm_leiden(NULL, 0, NULL, 0, 1.0, &result, &count), CBM_STORE_OK);
ASSERT_NULL(result);
ASSERT_EQ(count, 0);
ASSERT_EQ(cbm_mem_class_live_bytes(CBM_MEM_CLASS_OTHER), bytes);
ASSERT_EQ(cbm_mem_class_live_blocks(CBM_MEM_CLASS_OTHER), blocks);
PASS();
}
/* ── Leiden multi-level / refinement tests ──────────────────────── */
/* Count distinct community labels in a result. */
static int leiden_count_communities(const cbm_louvain_result_t *r, int n) {
int *seen = malloc((size_t)n * sizeof(int));
int nd = 0;
for (int i = 0; i < n; i++) {
bool found = false;
for (int j = 0; j < nd; j++) {
if (seen[j] == r[i].community) {
found = true;
break;
}
}
if (!found) {
seen[nd++] = r[i].community;
}
}
free(seen);
return nd;
}
/* Verify every community induces a connected subgraph under `edges` — the
* property Leiden's refinement guarantees and single-level Louvain does not. */
static bool leiden_all_communities_connected(const cbm_louvain_result_t *r, int n,
const cbm_louvain_edge_t *edges, int ne) {
bool *vis = calloc((size_t)n, sizeof(bool));
int *stack = malloc((size_t)n * sizeof(int));
bool ok = true;
for (int s = 0; s < n && ok; s++) {
int comm = r[s].community;
bool seeded = false; /* community already verified from an earlier seed? */
for (int t = 0; t < s; t++) {
if (r[t].community == comm) {
seeded = true;
break;
}
}
if (seeded) {
continue;
}
for (int i = 0; i < n; i++) {
vis[i] = false;
}
int sp = 0;
stack[sp++] = s;
vis[s] = true;
while (sp > 0) {
int cur = stack[--sp];
int64_t cid = r[cur].node_id;
for (int e = 0; e < ne; e++) {
int64_t o = 0;
bool inc = false;
if (edges[e].src == cid) {
o = edges[e].dst;
inc = true;
} else if (edges[e].dst == cid) {
o = edges[e].src;
inc = true;
}
if (!inc) {
continue;
}
for (int j = 0; j < n; j++) {
if (r[j].node_id == o) {
if (!vis[j] && r[j].community == comm) {
vis[j] = true;
stack[sp++] = j;
}
break;
}
}
}
}
for (int j = 0; j < n; j++) {
if (r[j].community == comm && !vis[j]) {
ok = false; /* a member was unreachable → disconnected community */
break;
}
}
}
free(vis);
free(stack);
return ok;
}
TEST(leiden_multilevel_collapses_noise) {
/* Four 8-node cliques chained by single bridge edges. Single-level Louvain
* tends to leave many small clusters; multi-level Leiden collapses this to
* roughly four well-separated, internally-connected communities. */
enum { CL = 4, SZ = 8, N = CL * SZ };
int64_t nodes[N];
for (int i = 0; i < N; i++) {
nodes[i] = i + 1;
}
cbm_louvain_edge_t edges[CL * (SZ * (SZ - 1) / 2) + CL];
int ne = 0;
for (int c = 0; c < CL; c++) {
int base = c * SZ + 1;
for (int i = 0; i < SZ; i++) {
for (int j = i + 1; j < SZ; j++) {
edges[ne++] = (cbm_louvain_edge_t){base + i, base + j};
}
}
}
for (int c = 0; c + 1 < CL; c++) {
edges[ne++] = (cbm_louvain_edge_t){c * SZ + 1, (c + 1) * SZ + 1};
}
cbm_louvain_result_t *result = NULL;
int count = 0;
ASSERT_EQ(cbm_louvain(nodes, N, edges, ne, &result, &count), CBM_STORE_OK);
ASSERT_EQ(count, N);
int nc = leiden_count_communities(result, N);
ASSERT_TRUE(nc >= 2);
ASSERT_TRUE(nc <= CL + 1); /* far below N=32: the noise collapsed */
ASSERT_TRUE(leiden_all_communities_connected(result, N, edges, ne));
/* Each clique should be (almost) entirely in one community. */
for (int c = 0; c < CL; c++) {
int base_idx = c * SZ;
int same = 0;
for (int i = 0; i < SZ; i++) {
if (result[base_idx + i].community == result[base_idx].community) {
same++;
}
}
ASSERT_TRUE(same >= SZ - 1);
}
cbm_leiden_free(result);
PASS();
}
TEST(leiden_resolution_controls_granularity) {
/* Path graph of 30 nodes. Low resolution favours one large community; high
* resolution fragments it into more, smaller communities. */
enum { N = 30 };
int64_t nodes[N];
for (int i = 0; i < N; i++) {
nodes[i] = i + 1;
}
cbm_louvain_edge_t edges[N - 1];
int ne = 0;
for (int i = 0; i + 1 < N; i++) {
edges[ne++] = (cbm_louvain_edge_t){i + 1, i + 2};
}
cbm_louvain_result_t *lo = NULL;
cbm_louvain_result_t *hi = NULL;
int lc = 0;
int hc = 0;
ASSERT_EQ(cbm_leiden(nodes, N, edges, ne, 0.1, &lo, &lc), CBM_STORE_OK);
ASSERT_EQ(cbm_leiden(nodes, N, edges, ne, 5.0, &hi, &hc), CBM_STORE_OK);
ASSERT_EQ(lc, N);
ASSERT_EQ(hc, N);
int n_lo = leiden_count_communities(lo, N);
int n_hi = leiden_count_communities(hi, N);
ASSERT_TRUE(n_hi > n_lo);
ASSERT_TRUE(leiden_all_communities_connected(lo, N, edges, ne));
ASSERT_TRUE(leiden_all_communities_connected(hi, N, edges, ne));
cbm_leiden_free(lo);
cbm_leiden_free(hi);
PASS();
}
/* get_architecture "clusters" aspect: Leiden communities surfaced compactly. */
TEST(arch_clusters_basic) {
cbm_store_t *s = cbm_store_open_memory();
cbm_store_upsert_project(s, "test", "/tmp/test");
/* Two 4-function cliques in two packages, one bridge between them. */
int64_t id[8];
for (int i = 0; i < 8; i++) {
char nm[32];
char qn[64];
int grp = i / 4;
snprintf(nm, sizeof(nm), "fn%d", i);
snprintf(qn, sizeof(qn), "test.pkg%d.mod.fn%d", grp, i);
cbm_node_t node = {.project = "test",
.label = "Function",
.name = nm,
.qualified_name = qn,
.file_path = "f.go"};
id[i] = cbm_store_upsert_node(s, &node);
}
for (int g = 0; g < 2; g++) {
for (int a = 0; a < 4; a++) {
for (int b = a + 1; b < 4; b++) {
cbm_edge_t e = {.project = "test",
.source_id = id[(g * 4) + a],
.target_id = id[(g * 4) + b],
.type = "CALLS"};
cbm_store_insert_edge(s, &e);
}
}
}
cbm_edge_t bridge = {
.project = "test", .source_id = id[0], .target_id = id[4], .type = "CALLS"};
cbm_store_insert_edge(s, &bridge);
cbm_architecture_info_t info;
memset(&info, 0, sizeof(info));
const char *aspects[] = {"clusters"};
ASSERT_EQ(cbm_store_get_architecture(s, "test", NULL, aspects, 1, &info), CBM_STORE_OK);
ASSERT_TRUE(info.cluster_count >= 2); /* two dense communities */
for (int i = 0; i < info.cluster_count; i++) {
ASSERT_TRUE(info.clusters[i].members >= 2);
ASSERT_NOT_NULL(info.clusters[i].label);
ASSERT_TRUE(info.clusters[i].cohesion >= 0.0 && info.clusters[i].cohesion <= 1.0);
ASSERT_EQ(info.clusters[i].edge_type_count, 1);
ASSERT_TRUE(info.clusters[i].top_node_count > 0);
}
cbm_store_architecture_free(&info);
cbm_store_close(s);
PASS();
}
/* ── Helper function tests ──────────────────────────────────────── */
TEST(qn_to_package) {
/* 4+ segments: returns segment[2] */
ASSERT_STR_EQ(cbm_qn_to_package("project.internal.store.search.Search"), "store");
ASSERT_STR_EQ(cbm_qn_to_package("project.src.utils.helper.foo"), "utils");
ASSERT_STR_EQ(cbm_qn_to_package("project.src.components.Button.render"), "components");
ASSERT_STR_EQ(cbm_qn_to_package("project.cmd.server.main"), "server");
/* 3 segments: falls back to segment[1] */
ASSERT_STR_EQ(cbm_qn_to_package("project.main.foo"), "main");
ASSERT_STR_EQ(cbm_qn_to_package("project.cmd"), "cmd");
/* Edge cases */
ASSERT_STR_EQ(cbm_qn_to_package("standalone"), "");
ASSERT_STR_EQ(cbm_qn_to_package(""), "");
PASS();
}
TEST(qn_to_top_package) {
ASSERT_STR_EQ(cbm_qn_to_top_package("project.internal.store.search.Search"), "internal");
ASSERT_STR_EQ(cbm_qn_to_top_package("project.src.components.Button"), "src");
ASSERT_STR_EQ(cbm_qn_to_top_package("project.cmd"), "cmd");
ASSERT_STR_EQ(cbm_qn_to_top_package("standalone"), "");
PASS();
}
TEST(is_test_file_path) {
ASSERT_TRUE(!cbm_is_test_file_path("internal/handler/handler.go"));
ASSERT_TRUE(cbm_is_test_file_path("src/__tests__/handler.test.ts"));
ASSERT_TRUE(cbm_is_test_file_path("src/test/java/com/example/Test.java"));
ASSERT_TRUE(cbm_is_test_file_path("tests/test_handler.py"));
ASSERT_TRUE(cbm_is_test_file_path("testdata/fixture.json"));
ASSERT_TRUE(!cbm_is_test_file_path(""));
PASS();
}
TEST(find_architecture_docs) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
const char *fps[] = {"main.go", "ARCHITECTURE.md", "docs/adr/001-use-sqlite.md", "README.md"};
for (int i = 0; i < 4; i++) {
char qn[64];
snprintf(qn, sizeof(qn), "test.%s", fps[i]);
cbm_node_t n = {.project = "test",
.label = "File",
.name = fps[i],
.qualified_name = qn,
.file_path = fps[i]};
cbm_store_upsert_node(s, &n);
}
char **docs = NULL;
int count = 0;
ASSERT_EQ(cbm_store_find_architecture_docs(s, "test", &docs, &count), CBM_STORE_OK);
ASSERT_EQ(count, 2);
bool found_arch = false, found_adr = false;
for (int i = 0; i < count; i++) {
if (strcmp(docs[i], "ARCHITECTURE.md") == 0)
found_arch = true;
if (strcmp(docs[i], "docs/adr/001-use-sqlite.md") == 0)
found_adr = true;
free(docs[i]);
}
free(docs);
ASSERT_TRUE(found_arch);
ASSERT_TRUE(found_adr);
cbm_store_close(s);
PASS();
}
TEST(find_architecture_docs_empty) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK);
char **docs = NULL;
int count = 0;
ASSERT_EQ(cbm_store_find_architecture_docs(s, "test", &docs, &count), CBM_STORE_OK);
ASSERT_EQ(count, 0);
free(docs);
cbm_store_close(s);
PASS();
}
/* ── Case-insensitive search tests ───────────────────────────────── */
TEST(search_case_insensitive_default) {
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 = "FooBar", .qualified_name = "test.FooBar"};
cbm_node_t n2 = {
.project = "test", .label = "Function", .name = "foobar", .qualified_name = "test.foobar"};
cbm_node_t n3 = {
.project = "test", .label = "Function", .name = "FOOBAR", .qualified_name = "test.FOOBAR"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_store_upsert_node(s, &n3);
/* Default (case_sensitive=false) should match all 3 */
cbm_search_params_t params = {.project = "test",
.name_pattern = "foobar",
.min_degree = -1,
.max_degree = -1,
.case_sensitive = false};
cbm_search_output_t out = {0};
ASSERT_EQ(cbm_store_search(s, &params, &out), CBM_STORE_OK);
ASSERT_EQ(out.count, 3);
cbm_store_search_free(&out);
cbm_store_close(s);
PASS();
}
TEST(search_case_sensitive_explicit) {
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 = "FooBar", .qualified_name = "test.FooBar"};
cbm_node_t n2 = {
.project = "test", .label = "Function", .name = "foobar", .qualified_name = "test.foobar"};
cbm_node_t n3 = {
.project = "test", .label = "Function", .name = "FOOBAR", .qualified_name = "test.FOOBAR"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
cbm_store_upsert_node(s, &n3);
/* Explicit case-sensitive should match only "foobar" */
cbm_search_params_t params = {.project = "test",
.name_pattern = "foobar",
.min_degree = -1,
.max_degree = -1,
.case_sensitive = true};
cbm_search_output_t out = {0};
ASSERT_EQ(cbm_store_search(s, &params, &out), CBM_STORE_OK);
ASSERT_EQ(out.count, 1);
if (out.count > 0) {
ASSERT_STR_EQ(out.results[0].node.name, "foobar");
}
cbm_store_search_free(&out);
cbm_store_close(s);
PASS();
}
/* ── Suite ─────────────────────────────────────────────────────── */
SUITE(store_arch) {
/* Architecture */
RUN_TEST(arch_get_all);
RUN_TEST(arch_entry_points_exclude_tests);
RUN_TEST(arch_hotspots_exclude_tests);
RUN_TEST(arch_specific_aspects);
RUN_TEST(arch_path_scoping);
RUN_TEST(arch_packages_tied_counts_in_name_order);
RUN_TEST(arch_empty_project);
RUN_TEST(arch_languages);
RUN_TEST(arch_routes);
RUN_TEST(arch_hotspots);
RUN_TEST(arch_boundaries);
RUN_TEST(arch_boundaries_no_quadratic_scan);
RUN_TEST(arch_layers);
RUN_TEST(arch_file_tree);
RUN_TEST(arch_clusters);
/* ADR */
RUN_TEST(adr_store_and_retrieve);
RUN_TEST(adr_upsert);
RUN_TEST(adr_restore_from_graph_if_absent_memory_is_noop);
RUN_TEST(adr_delete);
RUN_TEST(adr_delete_not_found);
RUN_TEST(doc_candidates_replace_and_get);
RUN_TEST(doc_candidates_for_path_file_and_folders);
RUN_TEST(adr_parse_sections_basic);
RUN_TEST(adr_parse_sections_all_six);
RUN_TEST(adr_parse_sections_non_canonical);
RUN_TEST(adr_parse_sections_empty);
RUN_TEST(adr_parse_sections_preamble);
RUN_TEST(adr_parse_sections_multiline);
RUN_TEST(adr_render_canonical_order);
RUN_TEST(adr_render_all_sections);
RUN_TEST(adr_render_non_canonical);
RUN_TEST(adr_render_empty);
RUN_TEST(adr_render_oversized_sections_no_overflow);
RUN_TEST(adr_parse_render_roundtrip);
RUN_TEST(adr_update_sections);
RUN_TEST(adr_update_overflow);
RUN_TEST(adr_update_no_existing);
RUN_TEST(adr_validate_all_sections);
RUN_TEST(adr_validate_missing_sections);
RUN_TEST(adr_validate_empty);
RUN_TEST(adr_validate_keys_valid);
RUN_TEST(adr_validate_keys_accepts_arbitrary_names);
RUN_TEST(adr_validate_keys_rejects_unroundtrippable);
RUN_TEST(adr_splice_preserves_untouched_bytes);
RUN_TEST(adr_splice_preserves_line_endings);
RUN_TEST(adr_splice_mixed_line_endings);
RUN_TEST(adr_splice_matches_headings_across_line_endings);
RUN_TEST(adr_splice_appends_arbitrary_heading);
RUN_TEST(adr_splice_is_idempotent);
RUN_TEST(adr_splice_matches_case_exactly);
RUN_TEST(adr_splice_ignores_heading_inside_fence);
RUN_TEST(adr_splice_refuses_unterminated_fence);
RUN_TEST(adr_validate_keys_empty);
/* Call-edge fetch / free */
RUN_TEST(fetch_call_edges_basic);
RUN_TEST(fetch_call_edges_truncated);
RUN_TEST(fetch_call_edges_empty_project);
RUN_TEST(fetch_call_edges_memory_core_ownership);
/* Louvain */
RUN_TEST(louvain_basic);
RUN_TEST(louvain_empty);
RUN_TEST(louvain_single_node);
RUN_TEST(louvain_converges);
RUN_TEST(leiden_result_memory_core_ownership);
RUN_TEST(leiden_multilevel_collapses_noise);
RUN_TEST(leiden_resolution_controls_granularity);
RUN_TEST(arch_clusters_basic);
/* Helpers */
RUN_TEST(qn_to_package);
RUN_TEST(qn_to_top_package);
RUN_TEST(is_test_file_path);
RUN_TEST(find_architecture_docs);
RUN_TEST(find_architecture_docs_empty);
/* Case-insensitive search */
RUN_TEST(search_case_insensitive_default);
RUN_TEST(search_case_sensitive_explicit);
}