mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
2116 lines
80 KiB
C
2116 lines
80 KiB
C
/*
|
||
* 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, ¶ms, &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, ¶ms, &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);
|
||
}
|