5d8a9421fb
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com> # Conflicts: # src/mcp/mcp.c # tests/test_cli.c
857 lines
30 KiB
C
857 lines
30 KiB
C
/*
|
|
* test_integration.c — End-to-end integration tests for the pure C pipeline.
|
|
*
|
|
* Creates a temporary project with real source files, indexes it through
|
|
* the full pipeline, then queries the result through MCP tool handlers.
|
|
*
|
|
* This exercises the complete flow: discover → extract → registry → graph
|
|
* buffer → SQLite dump → query. No mocking — real files, real parsing.
|
|
*/
|
|
#include "../src/foundation/compat.h"
|
|
#include "test_framework.h"
|
|
#include "test_helpers.h"
|
|
#include <mcp/mcp.h>
|
|
#include <store/store.h>
|
|
#include <pipeline/pipeline.h>
|
|
#include <foundation/log.h>
|
|
#include <foundation/platform.h>
|
|
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdio.h>
|
|
#include <unistd.h>
|
|
#include <sys/stat.h>
|
|
|
|
/* ── Test fixture: temp project with Python + Go files ─────────── */
|
|
|
|
static char g_tmpdir[256];
|
|
static char g_dbpath[512];
|
|
static cbm_mcp_server_t *g_srv = NULL;
|
|
static char *g_project = NULL;
|
|
|
|
/* Create source files in temp directory */
|
|
static int create_test_project(void) {
|
|
snprintf(g_tmpdir, sizeof(g_tmpdir), "/tmp/cbm_integ_XXXXXX");
|
|
if (!cbm_mkdtemp(g_tmpdir))
|
|
return -1;
|
|
|
|
char path[512];
|
|
FILE *f;
|
|
|
|
/* Python file with function calls */
|
|
snprintf(path, sizeof(path), "%s/main.py", g_tmpdir);
|
|
f = fopen(path, "w");
|
|
if (!f)
|
|
return -1;
|
|
fprintf(f, "def greet(name):\n"
|
|
" return 'Hello ' + name\n"
|
|
"\n"
|
|
"def farewell(name):\n"
|
|
" return 'Goodbye ' + name\n"
|
|
"\n"
|
|
"def main():\n"
|
|
" msg = greet('World')\n"
|
|
" msg2 = farewell('World')\n"
|
|
" print(msg, msg2)\n");
|
|
fclose(f);
|
|
|
|
/* Go file with function calls */
|
|
snprintf(path, sizeof(path), "%s/utils.go", g_tmpdir);
|
|
f = fopen(path, "w");
|
|
if (!f)
|
|
return -1;
|
|
fprintf(f, "package utils\n"
|
|
"\n"
|
|
"func Add(a, b int) int {\n"
|
|
" return a + b\n"
|
|
"}\n"
|
|
"\n"
|
|
"func Multiply(a, b int) int {\n"
|
|
" sum := Add(a, b)\n"
|
|
" return sum * 2\n"
|
|
"}\n"
|
|
"\n"
|
|
"func Compute(x int) int {\n"
|
|
" return Multiply(x, Add(x, 1))\n"
|
|
"}\n");
|
|
fclose(f);
|
|
|
|
/* JavaScript file */
|
|
snprintf(path, sizeof(path), "%s/app.js", g_tmpdir);
|
|
f = fopen(path, "w");
|
|
if (!f)
|
|
return -1;
|
|
fprintf(f, "function validate(input) {\n"
|
|
" return input != null;\n"
|
|
"}\n"
|
|
"\n"
|
|
"function process(data) {\n"
|
|
" if (validate(data)) {\n"
|
|
" return data.toUpperCase();\n"
|
|
" }\n"
|
|
" return null;\n"
|
|
"}\n");
|
|
fclose(f);
|
|
|
|
return 0;
|
|
}
|
|
|
|
/* Set up: create project, index it through MCP (production flow) */
|
|
static int integration_setup(void) {
|
|
if (create_test_project() != 0)
|
|
return -1;
|
|
|
|
/* Derive project name (same logic the pipeline uses) */
|
|
g_project = cbm_project_name_from_path(g_tmpdir);
|
|
if (!g_project)
|
|
return -1;
|
|
|
|
/* Build db path for direct store queries (pipeline writes here) */
|
|
const char *cache_dir = cbm_resolve_cache_dir();
|
|
int dbpath_length =
|
|
cache_dir ? snprintf(g_dbpath, sizeof(g_dbpath), "%s/%s.db", cache_dir, g_project) : -1;
|
|
if (dbpath_length <= 0 || (size_t)dbpath_length >= sizeof(g_dbpath) ||
|
|
!cbm_mkdir_p(cache_dir, 0700)) {
|
|
return -1;
|
|
}
|
|
|
|
/* Remove stale db from previous test runs */
|
|
unlink(g_dbpath);
|
|
|
|
/* Create MCP server, then index through it (production flow):
|
|
* 1. Server starts with in-memory store
|
|
* 2. index_repository closes in-memory store
|
|
* 3. Pipeline runs → dumps to ~/.cache/.../<project>.db
|
|
* 4. Server reopens from that db
|
|
* This exercises the exact same path as real usage. */
|
|
g_srv = cbm_mcp_server_new(NULL);
|
|
if (!g_srv)
|
|
return -1;
|
|
|
|
/* Index our temp project via MCP tool handler */
|
|
char args[512];
|
|
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", g_tmpdir);
|
|
char *resp = cbm_mcp_handle_tool(g_srv, "index_repository", args);
|
|
if (!resp)
|
|
return -1;
|
|
|
|
/* Verify indexing succeeded */
|
|
bool ok = strstr(resp, "indexed") != NULL;
|
|
free(resp);
|
|
return ok ? 0 : -1;
|
|
}
|
|
|
|
static void integration_teardown(void) {
|
|
if (g_srv) {
|
|
cbm_mcp_server_free(g_srv);
|
|
g_srv = NULL;
|
|
}
|
|
free(g_project);
|
|
g_project = NULL;
|
|
|
|
/* Clean up temp project */
|
|
th_rmtree(g_tmpdir);
|
|
|
|
/* Clean up cache db */
|
|
unlink(g_dbpath);
|
|
char wal[520], shm[520];
|
|
snprintf(wal, sizeof(wal), "%s-wal", g_dbpath);
|
|
snprintf(shm, sizeof(shm), "%s-shm", g_dbpath);
|
|
unlink(wal);
|
|
unlink(shm);
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* PIPELINE INTEGRATION TESTS
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
/* Helper: call a tool and return response JSON. Caller must free(). */
|
|
static char *call_tool(const char *tool, const char *args) {
|
|
if (!g_srv)
|
|
return NULL;
|
|
return cbm_mcp_handle_tool(g_srv, tool, args);
|
|
}
|
|
|
|
TEST(integ_index_has_nodes) {
|
|
/* Open the indexed db directly and check node counts */
|
|
cbm_store_t *store = cbm_store_open_path_existing(g_dbpath);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
int nodes = cbm_store_count_nodes(store, g_project);
|
|
/* We expect: 3 File nodes + 3+ Function/Method nodes per file +
|
|
* Folder/Package/Module nodes. Should be at least 8. */
|
|
ASSERT_TRUE(nodes >= 8);
|
|
|
|
cbm_store_close(store);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_index_has_edges) {
|
|
cbm_store_t *store = cbm_store_open_path_existing(g_dbpath);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
int edges = cbm_store_count_edges(store, g_project);
|
|
/* We expect CONTAINS_FILE edges + CALLS edges + others */
|
|
ASSERT_TRUE(edges >= 3);
|
|
|
|
cbm_store_close(store);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_index_has_functions) {
|
|
cbm_store_t *store = cbm_store_open_path_existing(g_dbpath);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
cbm_node_t *funcs = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_label(store, g_project, "Function", &funcs, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
/* Python: greet, farewell, main. Go: Add, Multiply, Compute. JS: validate, process */
|
|
ASSERT_TRUE(count >= 6);
|
|
|
|
/* Verify some function names exist */
|
|
bool found_greet = false, found_add = false, found_validate = false;
|
|
for (int i = 0; i < count; i++) {
|
|
if (funcs[i].name && strcmp(funcs[i].name, "greet") == 0)
|
|
found_greet = true;
|
|
if (funcs[i].name && strcmp(funcs[i].name, "Add") == 0)
|
|
found_add = true;
|
|
if (funcs[i].name && strcmp(funcs[i].name, "validate") == 0)
|
|
found_validate = true;
|
|
}
|
|
ASSERT_TRUE(found_greet);
|
|
ASSERT_TRUE(found_add);
|
|
ASSERT_TRUE(found_validate);
|
|
|
|
cbm_store_free_nodes(funcs, count);
|
|
cbm_store_close(store);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_index_has_files) {
|
|
cbm_store_t *store = cbm_store_open_path_existing(g_dbpath);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
cbm_node_t *files = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_label(store, g_project, "File", &files, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
ASSERT_EQ(count, 3); /* main.py, utils.go, app.js */
|
|
|
|
bool found_py = false, found_go = false, found_js = false;
|
|
for (int i = 0; i < count; i++) {
|
|
if (files[i].file_path && strstr(files[i].file_path, "main.py"))
|
|
found_py = true;
|
|
if (files[i].file_path && strstr(files[i].file_path, "utils.go"))
|
|
found_go = true;
|
|
if (files[i].file_path && strstr(files[i].file_path, "app.js"))
|
|
found_js = true;
|
|
}
|
|
ASSERT_TRUE(found_py);
|
|
ASSERT_TRUE(found_go);
|
|
ASSERT_TRUE(found_js);
|
|
|
|
cbm_store_free_nodes(files, count);
|
|
cbm_store_close(store);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_index_has_calls) {
|
|
cbm_store_t *store = cbm_store_open_path_existing(g_dbpath);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
int call_count = cbm_store_count_edges_by_type(store, g_project, "CALLS");
|
|
/* Python: main→greet, main→farewell, main→print
|
|
* Go: Multiply→Add, Compute→Multiply, Compute→Add
|
|
* JS: process→validate */
|
|
ASSERT_TRUE(call_count >= 4);
|
|
|
|
cbm_store_close(store);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* MCP TOOL HANDLER INTEGRATION
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(integ_mcp_list_projects) {
|
|
char *resp = call_tool("list_projects", "{}");
|
|
ASSERT_NOT_NULL(resp);
|
|
/* Should contain the project name derived from temp path */
|
|
ASSERT_NOT_NULL(strstr(resp, "project"));
|
|
free(resp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_mcp_search_graph_by_label) {
|
|
char args[256];
|
|
snprintf(args, sizeof(args), "{\"label\":\"Function\",\"project\":\"%s\",\"limit\":20}",
|
|
g_project);
|
|
|
|
char *resp = call_tool("search_graph", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
/* Should return function nodes */
|
|
ASSERT_NOT_NULL(strstr(resp, "Function"));
|
|
/* Should contain our known functions */
|
|
ASSERT_NOT_NULL(strstr(resp, "greet"));
|
|
free(resp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_mcp_search_graph_by_name) {
|
|
char args[256];
|
|
snprintf(args, sizeof(args), "{\"name_pattern\":\".*Add.*\",\"project\":\"%s\"}", g_project);
|
|
|
|
char *resp = call_tool("search_graph", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
ASSERT_NOT_NULL(strstr(resp, "Add"));
|
|
free(resp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_mcp_query_graph_functions) {
|
|
char args[512];
|
|
snprintf(args, sizeof(args),
|
|
"{\"project\":\"%s\",\"query\":\"MATCH (f:Function) WHERE f.project = '%s' "
|
|
"RETURN f.name LIMIT 20\"}",
|
|
g_project, g_project);
|
|
|
|
char *resp = call_tool("query_graph", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
/* Should return results (may be in various formats depending on Cypher output).
|
|
* At minimum, should not be an error. */
|
|
ASSERT_TRUE(strstr(resp, "row") || strstr(resp, "greet") || strstr(resp, "Add") ||
|
|
strstr(resp, "result") || strstr(resp, "f.name"));
|
|
free(resp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_mcp_query_graph_calls) {
|
|
char args[512];
|
|
snprintf(args, sizeof(args),
|
|
"{\"project\":\"%s\",\"query\":\"MATCH (a)-[r:CALLS]->(b) WHERE a.project = '%s' "
|
|
"RETURN a.name, b.name LIMIT 20\"}",
|
|
g_project, g_project);
|
|
|
|
char *resp = call_tool("query_graph", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
/* Should have some call relationships */
|
|
ASSERT_NOT_NULL(strstr(resp, "name"));
|
|
free(resp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_mcp_get_graph_schema) {
|
|
char args[128];
|
|
snprintf(args, sizeof(args), "{\"project\":\"%s\"}", g_project);
|
|
|
|
char *resp = call_tool("get_graph_schema", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
/* Schema should include node labels and edge types */
|
|
ASSERT_NOT_NULL(strstr(resp, "Function"));
|
|
ASSERT_NOT_NULL(strstr(resp, "File"));
|
|
free(resp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_mcp_get_architecture) {
|
|
char args[128];
|
|
snprintf(args, sizeof(args), "{\"project\":\"%s\"}", g_project);
|
|
|
|
char *resp = call_tool("get_architecture", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
ASSERT_NOT_NULL(strstr(resp, "total_nodes"));
|
|
free(resp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_mcp_trace_path) {
|
|
/* Trace outbound calls from Compute → should reach Add and Multiply */
|
|
char args[256];
|
|
snprintf(args, sizeof(args),
|
|
"{\"function_name\":\"Compute\",\"project\":\"%s\","
|
|
"\"direction\":\"outbound\",\"max_depth\":3}",
|
|
g_project);
|
|
|
|
char *resp = call_tool("trace_path", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
/* Should find the function and show some path */
|
|
/* Either finds the function, or returns not found if name doesn't match exactly */
|
|
ASSERT_TRUE(strstr(resp, "Compute") || strstr(resp, "Multiply") || strstr(resp, "not found"));
|
|
free(resp);
|
|
PASS();
|
|
}
|
|
|
|
/* #522: trace_path mode=cross_service must follow CROSS_* cross-repo edges.
|
|
* Seed a CROSS_HTTP_CALLS edge between two indexed functions that have no CALLS
|
|
* relationship, then confirm cross_service surfaces the hop while the default
|
|
* calls mode does not (proving the cross edge specifically is what's followed).
|
|
*
|
|
* The trace goes through a fresh server so it opens the db after the edge is
|
|
* committed — exactly what a new MCP session sees after a cross-repo pass writes
|
|
* CROSS_* edges (g_srv's cached connection predates this write). */
|
|
TEST(integ_mcp_trace_path_cross_service) {
|
|
cbm_store_t *store = cbm_store_open_path_existing(g_dbpath);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
cbm_node_t *src = NULL;
|
|
cbm_node_t *dst = NULL;
|
|
int src_count = 0;
|
|
int dst_count = 0;
|
|
cbm_store_find_nodes_by_name(store, g_project, "greet", &src, &src_count);
|
|
cbm_store_find_nodes_by_name(store, g_project, "farewell", &dst, &dst_count);
|
|
ASSERT_TRUE(src_count > 0 && dst_count > 0);
|
|
|
|
cbm_edge_t edge = {.project = g_project,
|
|
.source_id = src[0].id,
|
|
.target_id = dst[0].id,
|
|
.type = "CROSS_HTTP_CALLS"};
|
|
ASSERT_TRUE(cbm_store_insert_edge(store, &edge) > 0);
|
|
|
|
cbm_store_free_nodes(src, src_count);
|
|
cbm_store_free_nodes(dst, dst_count);
|
|
cbm_store_close(store);
|
|
|
|
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char args[256];
|
|
snprintf(args, sizeof(args),
|
|
"{\"function_name\":\"greet\",\"project\":\"%s\","
|
|
"\"direction\":\"outbound\",\"mode\":\"cross_service\"}",
|
|
g_project);
|
|
char *resp = cbm_mcp_handle_tool(srv, "trace_path", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
ASSERT_NOT_NULL(strstr(resp, "farewell"));
|
|
free(resp);
|
|
|
|
snprintf(args, sizeof(args),
|
|
"{\"function_name\":\"greet\",\"project\":\"%s\","
|
|
"\"direction\":\"outbound\",\"mode\":\"calls\"}",
|
|
g_project);
|
|
resp = cbm_mcp_handle_tool(srv, "trace_path", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
ASSERT_TRUE(strstr(resp, "farewell") == NULL);
|
|
free(resp);
|
|
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_mcp_index_status) {
|
|
char args[128];
|
|
snprintf(args, sizeof(args), "{\"project\":\"%s\"}", g_project);
|
|
|
|
char *resp = call_tool("index_status", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
/* Should show indexed status with node/edge counts */
|
|
ASSERT_NOT_NULL(strstr(resp, g_project));
|
|
free(resp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_mcp_delete_project) {
|
|
/* Delete the project and verify it's gone */
|
|
char args[256];
|
|
snprintf(args, sizeof(args), "{\"project\":\"%s\"}", g_project);
|
|
|
|
char *resp = call_tool("delete_project", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
ASSERT_NOT_NULL(strstr(resp, "deleted"));
|
|
free(resp);
|
|
|
|
/* Note: querying after delete on Linux re-opens the unlinked .db inode
|
|
* (unlink defers removal until all fds close). SQLite's WAL mode connection
|
|
* on an unlinked file leaks internal allocations that sqlite3_close cannot
|
|
* reclaim. Guard behavior for deleted/missing projects is tested separately
|
|
* in tests/smoke_guard.sh using non-existent project names. */
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* PIPELINE DIRECT API TESTS
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(integ_pipeline_fqn_compute) {
|
|
char *fqn = cbm_pipeline_fqn_compute("myproject", "src/utils.go", "Add");
|
|
ASSERT_NOT_NULL(fqn);
|
|
ASSERT_STR_EQ(fqn, "myproject.src.utils.Add");
|
|
free(fqn);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_pipeline_fqn_module) {
|
|
char *fqn = cbm_pipeline_fqn_module("myproject", "src/utils.go");
|
|
ASSERT_NOT_NULL(fqn);
|
|
ASSERT_STR_EQ(fqn, "myproject.src.utils");
|
|
free(fqn);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_pipeline_project_name) {
|
|
char *name = cbm_project_name_from_path("/home/user/my-project");
|
|
ASSERT_NOT_NULL(name);
|
|
/* Should contain "my-project" or a sanitized version */
|
|
ASSERT_NOT_NULL(strstr(name, "my-project"));
|
|
free(name);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_pipeline_cancel) {
|
|
/* Create and immediately cancel a pipeline */
|
|
cbm_pipeline_t *p = cbm_pipeline_new(g_tmpdir, NULL, CBM_MODE_FULL);
|
|
ASSERT_NOT_NULL(p);
|
|
|
|
cbm_pipeline_cancel(p);
|
|
int rc = cbm_pipeline_run(p);
|
|
/* Should return -1 (cancelled) or complete with partial results */
|
|
/* Either way, it shouldn't crash */
|
|
(void)rc;
|
|
|
|
cbm_pipeline_free(p);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* STORE QUERY INTEGRATION
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(integ_store_search_by_degree) {
|
|
cbm_store_t *store = cbm_store_open_path_existing(g_dbpath);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
/* Find functions with at least 1 outbound call */
|
|
cbm_search_params_t params = {0};
|
|
params.project = g_project;
|
|
params.label = "Function";
|
|
params.min_degree = 1;
|
|
params.max_degree = -1;
|
|
params.limit = 10;
|
|
|
|
cbm_search_output_t out = {0};
|
|
int rc = cbm_store_search(store, ¶ms, &out);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
/* main, Multiply, Compute, process should all have outbound calls */
|
|
ASSERT_TRUE(out.count >= 1);
|
|
|
|
cbm_store_search_free(&out);
|
|
cbm_store_close(store);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_store_find_by_file) {
|
|
cbm_store_t *store = cbm_store_open_path_existing(g_dbpath);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
cbm_node_t *nodes = NULL;
|
|
int count = 0;
|
|
int rc = cbm_store_find_nodes_by_file(store, g_project, "main.py", &nodes, &count);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
/* main.py should have: greet, farewell, main functions + Module node */
|
|
ASSERT_TRUE(count >= 3);
|
|
|
|
cbm_store_free_nodes(nodes, count);
|
|
cbm_store_close(store);
|
|
PASS();
|
|
}
|
|
|
|
TEST(integ_store_bfs_traversal) {
|
|
cbm_store_t *store = cbm_store_open_path_existing(g_dbpath);
|
|
ASSERT_NOT_NULL(store);
|
|
|
|
/* Find a function node to start BFS from */
|
|
cbm_node_t *results = NULL;
|
|
int count = 0;
|
|
cbm_store_find_nodes_by_name(store, g_project, "Multiply", &results, &count);
|
|
|
|
if (count > 0) {
|
|
/* BFS outbound from Multiply */
|
|
cbm_traverse_result_t trav = {0};
|
|
int rc = cbm_store_bfs(store, results[0].id, "outbound", NULL, 0, 3, 20, &trav);
|
|
ASSERT_EQ(rc, CBM_STORE_OK);
|
|
/* Should visit at least Add */
|
|
ASSERT_TRUE(trav.visited_count >= 0); /* might be 0 if no edges */
|
|
cbm_store_traverse_free(&trav);
|
|
}
|
|
|
|
cbm_store_free_nodes(results, count);
|
|
cbm_store_close(store);
|
|
PASS();
|
|
}
|
|
|
|
/* bfs_collect_edges built its visited-ID set into a fixed 4KB string: past
|
|
* ~340-1100 visited nodes (id-width dependent) the id list was SILENTLY cut,
|
|
* so trace edges (and data_flow args) vanished — and a partially-written id
|
|
* could even match an UNRELATED node, admitting wrong edges. GUARD: a star of
|
|
* 1200 callers (id string ≈ 4.6KB) must surface every edge. RED on the fixed
|
|
* buffer, GREEN with the temp-table join. */
|
|
TEST(store_bfs_edges_survive_large_visited_set) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
ASSERT_EQ(cbm_store_upsert_project(s, "star", "/tmp/star"), CBM_STORE_OK);
|
|
|
|
cbm_node_t hub = {0};
|
|
hub.project = "star";
|
|
hub.label = "Function";
|
|
hub.name = "hub";
|
|
hub.qualified_name = "star.hub";
|
|
hub.file_path = "hub.c";
|
|
int64_t hub_id = cbm_store_upsert_node(s, &hub);
|
|
ASSERT_GT(hub_id, 0);
|
|
|
|
enum { SPOKES = 1200 };
|
|
for (int i = 0; i < SPOKES; i++) {
|
|
char nm[32];
|
|
char qn[64];
|
|
snprintf(nm, sizeof(nm), "caller_%04d", i);
|
|
snprintf(qn, sizeof(qn), "star.caller_%04d", i);
|
|
cbm_node_t sp = {0};
|
|
sp.project = "star";
|
|
sp.label = "Function";
|
|
sp.name = nm;
|
|
sp.qualified_name = qn;
|
|
sp.file_path = "spokes.c";
|
|
int64_t sid = cbm_store_upsert_node(s, &sp);
|
|
ASSERT_GT(sid, 0);
|
|
cbm_edge_t e = {0};
|
|
e.project = "star";
|
|
e.source_id = sid;
|
|
e.target_id = hub_id;
|
|
e.type = "CALLS";
|
|
ASSERT_GT(cbm_store_insert_edge(s, &e), 0); /* returns the edge id */
|
|
}
|
|
|
|
cbm_traverse_result_t tr = {0};
|
|
ASSERT_EQ(cbm_store_bfs(s, hub_id, "inbound", NULL, 0, 1, SPOKES + 10, &tr), CBM_STORE_OK);
|
|
ASSERT_EQ(tr.visited_count, SPOKES);
|
|
/* Every caller->hub edge must be collected — none silently dropped. */
|
|
ASSERT_EQ(tr.edge_count, SPOKES);
|
|
|
|
cbm_store_traverse_free(&tr);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* Multi-source BFS is the substrate for detect_changes impact analysis. Its
|
|
* contract (challenger's flagged traps): (1) ONE traversal over ALL seeds,
|
|
* not seed_count separate walks; (2) seeds EXCLUDED from the result even when
|
|
* reachable from another seed (changed files call each other — that is not
|
|
* "downstream impact"); (3) MIN(hop) across the whole seed set; (4) uncapped
|
|
* counting up to the memory ceiling, which sets *truncated when hit. Fixture:
|
|
* two seeds A, B; A->mid->leaf, B->leaf (leaf is hop 1 from B, hop 2 from A),
|
|
* and A->B directly (B reachable from A). Impact set must be {mid, leaf} with
|
|
* leaf at hop 1, and must NOT contain A or B. */
|
|
TEST(store_bfs_multi_excludes_seeds_and_takes_min_hop) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
ASSERT_EQ(cbm_store_upsert_project(s, "impact", "/tmp/impact"), CBM_STORE_OK);
|
|
|
|
int64_t ids[4];
|
|
const char *names[4] = {"A", "B", "mid", "leaf"};
|
|
for (int i = 0; i < 4; i++) {
|
|
char qn[32];
|
|
snprintf(qn, sizeof(qn), "impact.%s", names[i]);
|
|
cbm_node_t n = {.project = "impact",
|
|
.label = "Function",
|
|
.name = names[i],
|
|
.qualified_name = qn,
|
|
.file_path = "m.c",
|
|
.start_line = 1,
|
|
.end_line = 5};
|
|
ids[i] = cbm_store_upsert_node(s, &n);
|
|
ASSERT_GT(ids[i], 0);
|
|
}
|
|
int64_t A = ids[0];
|
|
int64_t B = ids[1];
|
|
int64_t mid = ids[2];
|
|
int64_t leaf = ids[3];
|
|
struct {
|
|
int64_t from;
|
|
int64_t to;
|
|
} edges[] = {{A, mid}, {mid, leaf}, {B, leaf}, {A, B}};
|
|
for (size_t i = 0; i < sizeof(edges) / sizeof(edges[0]); i++) {
|
|
cbm_edge_t e = {.project = "impact",
|
|
.source_id = edges[i].from,
|
|
.target_id = edges[i].to,
|
|
.type = "CALLS"};
|
|
ASSERT_GT(cbm_store_insert_edge(s, &e), 0);
|
|
}
|
|
|
|
int64_t seeds[2] = {A, B};
|
|
cbm_traverse_result_t tr = {0};
|
|
bool truncated = true;
|
|
ASSERT_EQ(cbm_store_bfs_multi(s, seeds, 2, "outbound", NULL, 0, 5, 100, &tr, &truncated),
|
|
CBM_STORE_OK);
|
|
ASSERT_FALSE(truncated);
|
|
|
|
/* Impact set = {mid, leaf}; A and B (seeds) excluded even though B is
|
|
* reachable from A. */
|
|
ASSERT_EQ(tr.visited_count, 2);
|
|
int seen_mid = 0;
|
|
int seen_leaf = 0;
|
|
int leaf_hop = -1;
|
|
for (int i = 0; i < tr.visited_count; i++) {
|
|
int64_t id = tr.visited[i].node.id;
|
|
ASSERT_TRUE(id != A && id != B); /* seeds never in the result */
|
|
if (id == mid) {
|
|
seen_mid = 1;
|
|
}
|
|
if (id == leaf) {
|
|
seen_leaf = 1;
|
|
leaf_hop = tr.visited[i].hop;
|
|
}
|
|
}
|
|
ASSERT_TRUE(seen_mid && seen_leaf);
|
|
ASSERT_EQ(leaf_hop, 1); /* MIN(hop): 1 from B, not 2 from A */
|
|
cbm_store_traverse_free(&tr);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* The memory-safety ceiling reports truncation instead of silently capping.
|
|
* A star of N callees from one seed, ceiling = N/2, must return exactly N/2
|
|
* rows with *truncated = true. */
|
|
TEST(store_bfs_multi_reports_truncation_at_ceiling) {
|
|
cbm_store_t *s = cbm_store_open_memory();
|
|
ASSERT_NOT_NULL(s);
|
|
ASSERT_EQ(cbm_store_upsert_project(s, "cap", "/tmp/cap"), CBM_STORE_OK);
|
|
cbm_node_t hub = {.project = "cap",
|
|
.label = "Function",
|
|
.name = "hub",
|
|
.qualified_name = "cap.hub",
|
|
.file_path = "h.c",
|
|
.start_line = 1,
|
|
.end_line = 2};
|
|
int64_t hub_id = cbm_store_upsert_node(s, &hub);
|
|
ASSERT_GT(hub_id, 0);
|
|
enum { N = 40, CEIL = 20 };
|
|
for (int i = 0; i < N; i++) {
|
|
char qn[32];
|
|
snprintf(qn, sizeof(qn), "cap.c%02d", i);
|
|
cbm_node_t n = {.project = "cap",
|
|
.label = "Function",
|
|
.name = qn + 4,
|
|
.qualified_name = qn,
|
|
.file_path = "c.c",
|
|
.start_line = 1,
|
|
.end_line = 2};
|
|
int64_t nid = cbm_store_upsert_node(s, &n);
|
|
ASSERT_GT(nid, 0);
|
|
cbm_edge_t e = {.project = "cap", .source_id = hub_id, .target_id = nid, .type = "CALLS"};
|
|
ASSERT_GT(cbm_store_insert_edge(s, &e), 0);
|
|
}
|
|
cbm_traverse_result_t tr = {0};
|
|
bool truncated = false;
|
|
ASSERT_EQ(cbm_store_bfs_multi(s, &hub_id, 1, "outbound", NULL, 0, 5, CEIL, &tr, &truncated),
|
|
CBM_STORE_OK);
|
|
ASSERT_EQ(tr.visited_count, CEIL);
|
|
ASSERT_TRUE(truncated);
|
|
cbm_store_traverse_free(&tr);
|
|
cbm_store_close(s);
|
|
PASS();
|
|
}
|
|
|
|
/* #411: index_repository silently drops entire subtrees with no record.
|
|
* Moderate/fast mode applies FAST_SKIP_DIRS (tools/scripts/bin/docs/...) and ALL
|
|
* modes apply ALWAYS_SKIP_DIRS (node_modules/...), so files are excluded from the
|
|
* graph — but the response only reports nodes/edges/status, giving the user no
|
|
* way to know which subtrees were dropped (the reporter lost a 47-file tools/).
|
|
* Desired (maintainer): a COMPACT per-directory summary of excluded files (dir +
|
|
* count, not a verbose per-file list) surfaced in the index result for any mode.
|
|
* RED until the index response reports excluded subtrees. Self-contained. */
|
|
TEST(index_reports_excluded_subtrees_issue411) {
|
|
char tmp[256];
|
|
snprintf(tmp, sizeof(tmp), "/tmp/cbm_excl_XXXXXX");
|
|
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
|
|
|
char path[512];
|
|
/* one real source file ... */
|
|
snprintf(path, sizeof(path), "%s/app.py", tmp);
|
|
FILE *f = fopen(path, "wb");
|
|
ASSERT_NOT_NULL(f);
|
|
fputs("def app():\n return 1\n", f);
|
|
fclose(f);
|
|
/* ... and a node_modules subtree that is excluded in EVERY mode. */
|
|
snprintf(path, sizeof(path), "%s/node_modules", tmp);
|
|
cbm_mkdir_p(path, 0755);
|
|
snprintf(path, sizeof(path), "%s/node_modules/dep.js", tmp);
|
|
f = fopen(path, "wb");
|
|
ASSERT_NOT_NULL(f);
|
|
fputs("export function dep() { return 2; }\n", f);
|
|
fclose(f);
|
|
|
|
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
|
ASSERT_NOT_NULL(srv);
|
|
char args[600];
|
|
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", tmp);
|
|
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* The dropped node_modules/dep.js must be reported somewhere compact in the
|
|
* response so the user knows it wasn't indexed. Today the response carries no
|
|
* excluded/skipped summary at all → this fails (reproduces the silent drop). */
|
|
bool reports_excluded = strstr(resp, "excluded") != NULL || strstr(resp, "skipped") != NULL;
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
th_rmtree(tmp);
|
|
ASSERT_TRUE(reports_excluded);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* SUITE
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
SUITE(integration) {
|
|
RUN_TEST(index_reports_excluded_subtrees_issue411);
|
|
/* Set up: create temp project and index it */
|
|
if (integration_setup() != 0) {
|
|
/* A suite that cannot establish its preconditions has FAILED, not
|
|
* "skipped" — surface it as a single red failure (no-skips policy). */
|
|
printf(" %sFAIL%s %s:%d: %s\n", tf_red(), tf_reset(), __FILE__, __LINE__,
|
|
"integration_setup failed");
|
|
tf_fail_count++;
|
|
integration_teardown();
|
|
return;
|
|
}
|
|
|
|
/* Pipeline result validation */
|
|
RUN_TEST(integ_index_has_nodes);
|
|
RUN_TEST(integ_index_has_edges);
|
|
RUN_TEST(integ_index_has_functions);
|
|
RUN_TEST(integ_index_has_files);
|
|
RUN_TEST(integ_index_has_calls);
|
|
|
|
/* MCP tool handler validation */
|
|
RUN_TEST(integ_mcp_list_projects);
|
|
RUN_TEST(integ_mcp_search_graph_by_label);
|
|
RUN_TEST(integ_mcp_search_graph_by_name);
|
|
RUN_TEST(integ_mcp_query_graph_functions);
|
|
RUN_TEST(integ_mcp_query_graph_calls);
|
|
RUN_TEST(integ_mcp_get_graph_schema);
|
|
RUN_TEST(integ_mcp_get_architecture);
|
|
RUN_TEST(integ_mcp_trace_path);
|
|
RUN_TEST(integ_mcp_trace_path_cross_service);
|
|
RUN_TEST(integ_mcp_index_status);
|
|
|
|
/* Store query validation */
|
|
RUN_TEST(integ_store_search_by_degree);
|
|
RUN_TEST(integ_store_find_by_file);
|
|
RUN_TEST(integ_store_bfs_traversal);
|
|
RUN_TEST(store_bfs_edges_survive_large_visited_set);
|
|
RUN_TEST(store_bfs_multi_excludes_seeds_and_takes_min_hop);
|
|
RUN_TEST(store_bfs_multi_reports_truncation_at_ceiling);
|
|
|
|
/* Pipeline API tests (no db needed) */
|
|
RUN_TEST(integ_pipeline_fqn_compute);
|
|
RUN_TEST(integ_pipeline_fqn_module);
|
|
RUN_TEST(integ_pipeline_project_name);
|
|
RUN_TEST(integ_pipeline_cancel);
|
|
|
|
/* Destructive tests (run last!) */
|
|
RUN_TEST(integ_mcp_delete_project);
|
|
|
|
/* Teardown */
|
|
integration_teardown();
|
|
}
|