Files
wehub-resource-sync 41cb1c0170
OpenSSF Scorecard / scorecard (push) Failing after 0s
DCO / dco (push) Failing after 0s
CodeQL SAST / analyze (push) Failing after 1s
Deploy Pages / deploy (push) Failing after 1s
chore: import upstream snapshot with attribution
2026-07-13 12:28:05 +08:00

5872 lines
225 KiB
C
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* test_mcp.c — Tests for the MCP server module.
*
* Covers: JSON-RPC parsing, MCP protocol, tool dispatch, tool handlers.
*/
#include "../src/foundation/compat.h"
#include <sqlite3.h>
#include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */
#include "../src/foundation/constants.h"
#include "../src/foundation/log.h"
#include "test_framework.h"
#include "test_helpers.h"
#include <cli/cli.h>
#include <mcp/index_supervisor.h> /* spawn-count hook — #845 in-process guard */
#include <mcp/mcp.h>
#include <pipeline/pipeline.h>
#include <store/store.h>
#include <watcher/watcher.h>
#include <yyjson/yyjson.h>
#include <string.h>
#include <stdlib.h>
#include <stdbool.h>
#include <stdio.h>
#include <sys/stat.h> /* chmod / stat for read-only query reproductions */
#ifdef _WIN32
#include <direct.h>
#define cbm_chdir _chdir
#define cbm_getcwd _getcwd
#else
#include <sys/wait.h> /* waitpid — #845 fork+alarm harness */
#include <unistd.h>
#define cbm_chdir chdir
#define cbm_getcwd getcwd
#endif
static char mcp_log_buf[4096];
static void mcp_capture_log(const char *line) {
snprintf(mcp_log_buf, sizeof(mcp_log_buf), "%s", line ? line : "");
}
static bool response_contains_json_fragment(const char *response, const char *fragment) {
if (!response || !fragment) {
return false;
}
if (strstr(response, fragment)) {
return true;
}
char escaped[512];
size_t out = 0;
for (size_t i = 0; fragment[i] && out + 2 < sizeof(escaped); i++) {
if (fragment[i] == '"') {
escaped[out++] = '\\';
}
escaped[out++] = fragment[i];
}
escaped[out] = '\0';
return strstr(response, escaped) != NULL;
}
static void restore_cache_dir(const char *saved_copy) {
if (saved_copy) {
cbm_setenv("CBM_CACHE_DIR", saved_copy, 1);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
}
static void cleanup_project_db(const char *cache, const char *project) {
if (!cache || !project) {
return;
}
char path[CBM_SZ_4K];
snprintf(path, sizeof(path), "%s/%s.db", cache, project);
cbm_unlink(path);
snprintf(path, sizeof(path), "%s/%s.db-wal", cache, project);
cbm_unlink(path);
snprintf(path, sizeof(path), "%s/%s.db-shm", cache, project);
cbm_unlink(path);
}
/* ══════════════════════════════════════════════════════════════════
* JSON-RPC PARSING
* ══════════════════════════════════════════════════════════════════ */
TEST(jsonrpc_parse_request) {
const char *line = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
"\"params\":{\"capabilities\":{}}}";
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse(line, &req);
ASSERT_EQ(rc, 0);
ASSERT_STR_EQ(req.jsonrpc, "2.0");
ASSERT_STR_EQ(req.method, "initialize");
ASSERT_EQ(req.id, 1);
ASSERT_TRUE(req.has_id);
ASSERT_NOT_NULL(req.params_raw);
cbm_jsonrpc_request_free(&req);
PASS();
}
TEST(jsonrpc_parse_notification) {
const char *line = "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}";
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse(line, &req);
ASSERT_EQ(rc, 0);
ASSERT_STR_EQ(req.method, "notifications/initialized");
ASSERT_FALSE(req.has_id);
cbm_jsonrpc_request_free(&req);
PASS();
}
TEST(jsonrpc_parse_invalid) {
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse("not json", &req);
ASSERT_EQ(rc, -1);
cbm_jsonrpc_request_free(&req);
PASS();
}
TEST(jsonrpc_parse_tools_call) {
const char *line = "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"label\":\"Function\",\"limit\":5}}}";
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse(line, &req);
ASSERT_EQ(rc, 0);
ASSERT_STR_EQ(req.method, "tools/call");
ASSERT_EQ(req.id, 42);
ASSERT_NOT_NULL(req.params_raw);
cbm_jsonrpc_request_free(&req);
PASS();
}
/* issue #253: JSON-RPC 2.0 §4 permits string ids (Claude Desktop sends them
* for "initialize"). Previously strtol-coerced to 0; must be preserved. */
TEST(jsonrpc_parse_string_id_issue253) {
const char *line = "{\"jsonrpc\":\"2.0\",\"id\":\"init-abc\",\"method\":\"initialize\"}";
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse(line, &req);
ASSERT_EQ(rc, 0);
ASSERT_TRUE(req.has_id);
ASSERT_NOT_NULL(req.id_str);
ASSERT_STR_EQ(req.id_str, "init-abc");
cbm_jsonrpc_request_free(&req);
/* A purely non-numeric string would have become 0 under strtol. */
const char *line2 = "{\"jsonrpc\":\"2.0\",\"id\":\"xyz\",\"method\":\"ping\"}";
cbm_jsonrpc_request_t req2 = {0};
ASSERT_EQ(cbm_jsonrpc_parse(line2, &req2), 0);
ASSERT_NOT_NULL(req2.id_str);
ASSERT_STR_EQ(req2.id_str, "xyz");
cbm_jsonrpc_request_free(&req2);
PASS();
}
/* issue #253: the response must echo the string id verbatim, not as a number. */
TEST(jsonrpc_format_response_string_id_issue253) {
cbm_jsonrpc_response_t resp = {
.id_str = "init-abc",
.result_json = "{\"ok\":true}",
};
char *json = cbm_jsonrpc_format_response(&resp);
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"id\":\"init-abc\""));
/* Must NOT have coerced to a numeric id. */
ASSERT_NULL(strstr(json, "\"id\":0"));
free(json);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* JSON-RPC FORMATTING
* ══════════════════════════════════════════════════════════════════ */
TEST(jsonrpc_format_response) {
cbm_jsonrpc_response_t resp = {
.id = 1,
.result_json = "{\"name\":\"codebase-memory-mcp\"}",
};
char *json = cbm_jsonrpc_format_response(&resp);
ASSERT_NOT_NULL(json);
/* Should contain jsonrpc, id, and result */
ASSERT_NOT_NULL(strstr(json, "\"jsonrpc\":\"2.0\""));
ASSERT_NOT_NULL(strstr(json, "\"id\":1"));
ASSERT_NOT_NULL(strstr(json, "\"result\""));
free(json);
PASS();
}
TEST(jsonrpc_format_error) {
char *json = cbm_jsonrpc_format_error(5, -32600, "Invalid Request");
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"id\":5"));
ASSERT_NOT_NULL(strstr(json, "\"error\""));
ASSERT_NOT_NULL(strstr(json, "-32600"));
ASSERT_NOT_NULL(strstr(json, "Invalid Request"));
free(json);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* MCP PROTOCOL HELPERS
* ══════════════════════════════════════════════════════════════════ */
TEST(mcp_initialize_response) {
cbm_cli_set_version("9.8.7-test");
/* Default (no params): returns latest supported version */
char *json = cbm_mcp_initialize_response(NULL);
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "codebase-memory-mcp"));
ASSERT_NOT_NULL(strstr(json, "\"version\":\"9.8.7-test\""));
ASSERT_NOT_NULL(strstr(json, "capabilities"));
ASSERT_NOT_NULL(strstr(json, "tools"));
ASSERT_NOT_NULL(strstr(json, "\"listChanged\":false"));
ASSERT_NOT_NULL(strstr(json, "2025-11-25"));
free(json);
/* Client requests a supported version: server echoes it */
json = cbm_mcp_initialize_response("{\"protocolVersion\":\"2024-11-05\"}");
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "2024-11-05"));
free(json);
json = cbm_mcp_initialize_response("{\"protocolVersion\":\"2025-06-18\"}");
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "2025-06-18"));
free(json);
/* Client requests unknown version: server returns its latest */
json = cbm_mcp_initialize_response("{\"protocolVersion\":\"9999-01-01\"}");
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "2025-11-25"));
free(json);
cbm_cli_set_version("dev");
PASS();
}
TEST(mcp_tools_list) {
char *json = cbm_mcp_tools_list();
ASSERT_NOT_NULL(json);
/* Should contain all 14 tools */
ASSERT_NOT_NULL(strstr(json, "index_repository"));
ASSERT_NOT_NULL(strstr(json, "search_graph"));
ASSERT_NOT_NULL(strstr(json, "query_graph"));
ASSERT_NOT_NULL(strstr(json, "trace_path"));
ASSERT_NOT_NULL(strstr(json, "get_code_snippet"));
ASSERT_NOT_NULL(strstr(json, "get_graph_schema"));
ASSERT_NOT_NULL(strstr(json, "get_architecture"));
ASSERT_NOT_NULL(strstr(json, "search_code"));
ASSERT_NOT_NULL(strstr(json, "list_projects"));
ASSERT_NOT_NULL(strstr(json, "delete_project"));
ASSERT_NOT_NULL(strstr(json, "index_status"));
ASSERT_NOT_NULL(strstr(json, "detect_changes"));
ASSERT_NOT_NULL(strstr(json, "manage_adr"));
ASSERT_NOT_NULL(strstr(json, "ingest_traces"));
free(json);
PASS();
}
TEST(mcp_tools_list_latest_metadata) {
char *json = cbm_mcp_tools_list();
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"title\":\"Search graph\""));
ASSERT_NOT_NULL(strstr(json, "\"title\":\"Index repository\""));
ASSERT_NOT_NULL(strstr(json, "\"outputSchema\":{\"type\":\"object\""));
ASSERT_NOT_NULL(strstr(json, "\"additionalProperties\":true"));
free(json);
PASS();
}
TEST(mcp_index_repository_declares_name_override_issue571) {
char *json = cbm_mcp_tools_list();
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"index_repository\""));
ASSERT_NOT_NULL(strstr(json, "\"name\":{\"type\":\"string\""));
ASSERT_NOT_NULL(strstr(json, "Non-ASCII bytes are encoded"));
free(json);
PASS();
}
TEST(mcp_tools_array_schemas_have_items) {
/* VS Code 1.112+ rejects array schemas without "items" (see
* https://github.com/microsoft/vscode/issues/248810).
* Walk every tool's inputSchema and verify that every "type":"array"
* property also contains "items". */
char *json = cbm_mcp_tools_list();
ASSERT_NOT_NULL(json);
/* Scan for all occurrences of "type":"array" — each must be followed
* by "items" before the next closing brace of that property. */
const char *p = json;
while ((p = strstr(p, "\"type\":\"array\"")) != NULL) {
/* Find the enclosing '}' for this property object */
const char *end = strchr(p, '}');
ASSERT_NOT_NULL(end);
/* "items" must appear between p and end */
size_t span = (size_t)(end - p);
char *segment = malloc(span + 1);
memcpy(segment, p, span);
segment[span] = '\0';
ASSERT_NOT_NULL(strstr(segment, "\"items\"")); /* array missing items */
free(segment);
p = end;
}
free(json);
PASS();
}
TEST(mcp_ingest_traces_items_disallow_additional_properties_issue731) {
char *json = cbm_mcp_tools_list();
ASSERT_NOT_NULL(json);
yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
ASSERT_NOT_NULL(root);
yyjson_val *tools = yyjson_obj_get(root, "tools");
ASSERT_NOT_NULL(tools);
ASSERT_TRUE(yyjson_is_arr(tools));
yyjson_val *tool;
yyjson_arr_iter iter;
yyjson_arr_iter_init(tools, &iter);
yyjson_val *ingest_traces = NULL;
while ((tool = yyjson_arr_iter_next(&iter)) != NULL) {
yyjson_val *name = yyjson_obj_get(tool, "name");
if (name && yyjson_is_str(name) && strcmp(yyjson_get_str(name), "ingest_traces") == 0) {
ingest_traces = tool;
break;
}
}
ASSERT_NOT_NULL(ingest_traces);
yyjson_val *input_schema = yyjson_obj_get(ingest_traces, "inputSchema");
ASSERT_NOT_NULL(input_schema);
yyjson_val *properties = yyjson_obj_get(input_schema, "properties");
ASSERT_NOT_NULL(properties);
yyjson_val *traces = yyjson_obj_get(properties, "traces");
ASSERT_NOT_NULL(traces);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(traces, "type")), "array");
yyjson_val *items = yyjson_obj_get(traces, "items");
ASSERT_NOT_NULL(items);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(items, "type")), "object");
yyjson_val *item_properties = yyjson_obj_get(items, "properties");
ASSERT_NOT_NULL(item_properties);
yyjson_val *caller = yyjson_obj_get(item_properties, "caller");
ASSERT_NOT_NULL(caller);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(caller, "type")), "string");
yyjson_val *callee = yyjson_obj_get(item_properties, "callee");
ASSERT_NOT_NULL(callee);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(callee, "type")), "string");
yyjson_val *count = yyjson_obj_get(item_properties, "count");
ASSERT_NOT_NULL(count);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(count, "type")), "integer");
yyjson_val *additional_properties = yyjson_obj_get(items, "additionalProperties");
ASSERT_NOT_NULL(additional_properties);
ASSERT_TRUE(yyjson_is_bool(additional_properties));
ASSERT_FALSE(yyjson_get_bool(additional_properties));
yyjson_doc_free(doc);
free(json);
PASS();
}
/* Guard for PR #560 (schema enum): the get_architecture aspects items schema
* must carry an enum of the valid tokens — including the new "overview" —
* mirroring VALID_ASPECTS in mcp.c. Parsed structurally like
* mcp_ingest_traces_items_disallow_additional_properties_issue731. */
TEST(mcp_get_architecture_aspects_schema_enum_pr560) {
char *json = cbm_mcp_tools_list();
ASSERT_NOT_NULL(json);
yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
ASSERT_NOT_NULL(root);
yyjson_val *tools = yyjson_obj_get(root, "tools");
ASSERT_NOT_NULL(tools);
ASSERT_TRUE(yyjson_is_arr(tools));
yyjson_val *tool;
yyjson_arr_iter iter;
yyjson_arr_iter_init(tools, &iter);
yyjson_val *get_arch = NULL;
while ((tool = yyjson_arr_iter_next(&iter)) != NULL) {
yyjson_val *name = yyjson_obj_get(tool, "name");
if (name && yyjson_is_str(name) && strcmp(yyjson_get_str(name), "get_architecture") == 0) {
get_arch = tool;
break;
}
}
ASSERT_NOT_NULL(get_arch);
yyjson_val *input_schema = yyjson_obj_get(get_arch, "inputSchema");
ASSERT_NOT_NULL(input_schema);
yyjson_val *properties = yyjson_obj_get(input_schema, "properties");
ASSERT_NOT_NULL(properties);
yyjson_val *aspects = yyjson_obj_get(properties, "aspects");
ASSERT_NOT_NULL(aspects);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(aspects, "type")), "array");
yyjson_val *items = yyjson_obj_get(aspects, "items");
ASSERT_NOT_NULL(items);
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(items, "type")), "string");
yyjson_val *enum_arr = yyjson_obj_get(items, "enum");
ASSERT_NOT_NULL(enum_arr);
ASSERT_TRUE(yyjson_is_arr(enum_arr));
/* The enum must be exactly the valid-token set — no more, no less. */
static const char *expected[] = {"all", "overview", "structure", "dependencies",
"routes", "languages", "packages", "entry_points",
"hotspots", "boundaries", "layers", "file_tree",
"clusters"};
size_t expected_count = sizeof(expected) / sizeof(expected[0]);
ASSERT_EQ(yyjson_arr_size(enum_arr), expected_count);
for (size_t i = 0; i < expected_count; i++) {
bool found = false;
yyjson_val *ev;
yyjson_arr_iter eiter;
yyjson_arr_iter_init(enum_arr, &eiter);
while ((ev = yyjson_arr_iter_next(&eiter)) != NULL) {
if (yyjson_is_str(ev) && strcmp(yyjson_get_str(ev), expected[i]) == 0) {
found = true;
break;
}
}
ASSERT_TRUE(found);
}
yyjson_doc_free(doc);
free(json);
PASS();
}
TEST(mcp_text_result) {
char *json = cbm_mcp_text_result("{\"total\":5}", false);
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"type\":\"text\""));
/* The text value is JSON-escaped inside the "text" field */
ASSERT_NOT_NULL(strstr(json, "total"));
ASSERT_NOT_NULL(strstr(json, "\"structuredContent\":{\"total\":5}"));
ASSERT_NOT_NULL(strstr(json, "\"isError\":false"));
ASSERT_NULL(strstr(json, "\"isError\":true"));
free(json);
PASS();
}
TEST(mcp_text_result_skips_structured_content_for_plain_text) {
char *json = cbm_mcp_text_result("plain text", false);
ASSERT_NOT_NULL(json);
ASSERT_NULL(strstr(json, "\"structuredContent\""));
ASSERT_NOT_NULL(strstr(json, "\"isError\":false"));
free(json);
PASS();
}
TEST(mcp_cancel_matches_request_id) {
ASSERT_TRUE(cbm_mcp_cancel_request_matches("{\"requestId\":7}", 7, NULL));
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":8}", 7, NULL));
ASSERT_TRUE(cbm_mcp_cancel_request_matches("{\"requestId\":\"call-1\"}", -1, "call-1"));
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":\"call-2\"}", -1, "call-1"));
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{\"requestId\":7}", -1, "7"));
ASSERT_FALSE(cbm_mcp_cancel_request_matches("{}", 7, NULL));
PASS();
}
TEST(mcp_text_result_error) {
char *json = cbm_mcp_text_result("something failed", true);
ASSERT_NOT_NULL(json);
ASSERT_NOT_NULL(strstr(json, "\"isError\":true"));
ASSERT_NOT_NULL(strstr(json, "something failed"));
free(json);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* ARGUMENT EXTRACTION
* ══════════════════════════════════════════════════════════════════ */
TEST(mcp_get_tool_name) {
const char *params = "{\"name\":\"search_graph\",\"arguments\":{\"label\":\"Function\"}}";
char *name = cbm_mcp_get_tool_name(params);
ASSERT_NOT_NULL(name);
ASSERT_STR_EQ(name, "search_graph");
free(name);
PASS();
}
TEST(mcp_get_arguments) {
const char *params =
"{\"name\":\"search_graph\",\"arguments\":{\"label\":\"Function\",\"limit\":5}}";
char *args = cbm_mcp_get_arguments(params);
ASSERT_NOT_NULL(args);
ASSERT_NOT_NULL(strstr(args, "\"label\":\"Function\""));
ASSERT_NOT_NULL(strstr(args, "\"limit\":5"));
free(args);
PASS();
}
TEST(mcp_get_string_arg) {
const char *args = "{\"label\":\"Function\",\"name_pattern\":\".*Order.*\"}";
char *val = cbm_mcp_get_string_arg(args, "label");
ASSERT_NOT_NULL(val);
ASSERT_STR_EQ(val, "Function");
free(val);
val = cbm_mcp_get_string_arg(args, "name_pattern");
ASSERT_NOT_NULL(val);
ASSERT_STR_EQ(val, ".*Order.*");
free(val);
val = cbm_mcp_get_string_arg(args, "nonexistent");
ASSERT_NULL(val);
PASS();
}
TEST(mcp_get_int_arg) {
const char *args = "{\"limit\":10,\"offset\":5}";
int val = cbm_mcp_get_int_arg(args, "limit", 0);
ASSERT_EQ(val, 10);
val = cbm_mcp_get_int_arg(args, "offset", 0);
ASSERT_EQ(val, 5);
val = cbm_mcp_get_int_arg(args, "missing", 42);
ASSERT_EQ(val, 42);
PASS();
}
TEST(mcp_get_bool_arg) {
const char *args = "{\"include_connected\":true,\"regex\":false}";
bool val = cbm_mcp_get_bool_arg(args, "include_connected");
ASSERT_TRUE(val);
val = cbm_mcp_get_bool_arg(args, "regex");
ASSERT_FALSE(val);
val = cbm_mcp_get_bool_arg(args, "missing");
ASSERT_FALSE(val);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SERVER HANDLE — PROTOCOL FLOW
* ══════════════════════════════════════════════════════════════════ */
TEST(server_handle_initialize) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
"\"params\":{\"capabilities\":{}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"id\":1"));
ASSERT_NOT_NULL(strstr(resp, "codebase-memory-mcp"));
ASSERT_NOT_NULL(strstr(resp, "capabilities"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(server_handle_initialized_notification) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
/* Notification has no id → no response */
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}");
ASSERT_NULL(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(server_handle_tools_list) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"id\":2"));
ASSERT_NOT_NULL(strstr(resp, "search_graph"));
ASSERT_NOT_NULL(strstr(resp, "query_graph"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(server_handle_tools_list_defaults_to_all_tools_and_accepts_cursor) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":200,\"method\":\"tools/list\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"id\":200"));
ASSERT_NULL(strstr(resp, "\"nextCursor\""));
ASSERT_NOT_NULL(strstr(resp, "index_repository"));
ASSERT_NOT_NULL(strstr(resp, "manage_adr"));
ASSERT_NOT_NULL(strstr(resp, "ingest_traces"));
free(resp);
resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":202,\"method\":\"tools/list\",\"params\":{}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"id\":202"));
ASSERT_NULL(strstr(resp, "\"nextCursor\""));
ASSERT_NOT_NULL(strstr(resp, "manage_adr"));
ASSERT_NOT_NULL(strstr(resp, "ingest_traces"));
free(resp);
resp = cbm_mcp_server_handle(
srv,
"{\"jsonrpc\":\"2.0\",\"id\":201,\"method\":\"tools/list\",\"params\":{\"cursor\":\"8\"}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"id\":201"));
ASSERT_NULL(strstr(resp, "\"nextCursor\""));
ASSERT_NOT_NULL(strstr(resp, "manage_adr"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(server_handle_logs_request_without_params) {
mcp_log_buf[0] = '\0';
CBMLogLevel prev_level = cbm_log_get_level();
cbm_log_set_level(CBM_LOG_DEBUG);
cbm_log_set_format(CBM_LOG_FORMAT_TEXT);
cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":210,\"method\":\"tools/list\","
"\"params\":{\"token\":\"secret\"}}");
ASSERT_NOT_NULL(resp);
free(resp);
cbm_mcp_server_free(srv);
cbm_log_set_sink(NULL);
cbm_log_set_level(prev_level);
ASSERT_NOT_NULL(strstr(mcp_log_buf, "msg=mcp.request"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "protocol=jsonrpc"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "method=tools/list"));
ASSERT_NOT_NULL(strstr(mcp_log_buf, "status=ok"));
ASSERT_NULL(strstr(mcp_log_buf, "token"));
ASSERT_NULL(strstr(mcp_log_buf, "secret"));
PASS();
}
TEST(server_handle_unknown_method) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"unknown/method\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"error\""));
ASSERT_NOT_NULL(strstr(resp, "-32601")); /* Method not found */
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* TOOL HANDLERS (via server_handle)
* ══════════════════════════════════════════════════════════════════ */
/* Helper: create a server with an in-memory store populated with test data */
static cbm_mcp_server_t *setup_mcp_with_data(void) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); /* NULL = in-memory */
return srv;
}
TEST(tool_list_projects_empty) {
cbm_mcp_server_t *srv = setup_mcp_with_data();
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tools/call\","
"\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"id\":10"));
/* Should return a result (possibly empty list) */
ASSERT_NOT_NULL(strstr(resp, "\"result\""));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_get_graph_schema_empty) {
cbm_mcp_server_t *srv = setup_mcp_with_data();
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":11,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_graph_schema\",\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"result\""));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_unknown_tool) {
cbm_mcp_server_t *srv = setup_mcp_with_data();
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":12,\"method\":\"tools/call\","
"\"params\":{\"name\":\"nonexistent_tool\",\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
/* Should return result with isError */
ASSERT_NOT_NULL(strstr(resp, "isError"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_search_graph_basic) {
cbm_mcp_server_t *srv = setup_mcp_with_data();
/* search_graph with no project → should work on empty store */
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":13,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"label\":\"Function\",\"limit\":10}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"result\""));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Forward declarations for helpers defined later in this file */
static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz);
static void cleanup_snippet_dir(const char *tmp_dir);
static char *extract_text_content(const char *mcp_result);
TEST(tool_search_graph_includes_node_properties) {
/* Node properties are OPT-IN columns in the default TOON output: the
* default row is qn/label/file/lines/degrees only, `fields` adds the
* requested property columns, and format:"json" restores the legacy
* verbose objects with the full property blob. The setup_snippet_server
* inserts HandleRequest with a signature/return_type/is_exported blob. */
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Default TOON: compact table, no property spill. */
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":42,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\","
"\"name_pattern\":\"HandleRequest\",\"limit\":5}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "results[")); /* TOON table header */
ASSERT_NOT_NULL(strstr(inner, "{qn,label,file,lines,in,out}"));
ASSERT_NOT_NULL(strstr(inner, "HandleRequest"));
ASSERT_NULL(strstr(inner, "func HandleRequest")); /* signature not spilled */
ASSERT_NULL(strstr(inner, "is_exported"));
free(inner);
free(resp);
/* fields:["signature"] adds the column + values. */
resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":43,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\","
"\"name_pattern\":\"HandleRequest\",\"fields\":[\"signature\"],\"limit\":5}}}");
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "{qn,label,file,lines,in,out,signature}"));
ASSERT_NOT_NULL(strstr(inner, "func HandleRequest"));
free(inner);
free(resp);
/* format:"json" keeps the legacy verbose objects intact. */
resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\","
"\"name_pattern\":\"HandleRequest\",\"format\":\"json\",\"limit\":5}}}");
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"signature\""));
ASSERT_NOT_NULL(strstr(inner, "func HandleRequest"));
ASSERT_NOT_NULL(strstr(inner, "is_exported"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
TEST(tool_output_byte_budgets) {
/* GUARD: absolute byte ceilings on default tool outputs. Re-bloat (e.g.
* a property blob sneaking back into row emission — the fp field alone
* is ~450B/hit) blows these ceilings immediately. The numbers are
* generous vs the measured compact outputs (search hit rows ≈ 90B) but
* far below the legacy verbose sizes (≈1.5KB/hit). */
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* search_graph: 1-hit search must stay under 600B. */
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":46,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\","
"\"name_pattern\":\"HandleRequest\",\"limit\":5}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "HandleRequest")); /* non-vacuous: hit present */
ASSERT_LT((int)strlen(inner), 600);
free(inner);
free(resp);
/* trace_path: single-hop trace on the fixture must stay under 800B. */
resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":47,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_call_path\","
"\"arguments\":{\"project\":\"test-project\",\"function_name\":\"HandleRequest\","
"\"direction\":\"both\",\"depth\":2}}}");
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "callees["));
ASSERT_LT((int)strlen(inner), 800);
free(inner);
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
TEST(tool_search_graph_toon_never_leaks_internal_fields) {
/* The similarity/semantic pipeline intermediates (fp minhash hex, sp
* structural profile, bt body-token bag) dominated the legacy payload
* (~45%) and carry zero agent value. GUARD: they never appear in TOON
* output — not by default and not even when explicitly requested via
* fields (blocklist). */
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
/* A node whose properties carry the internal fields with sentinels. */
cbm_node_t n = {0};
n.project = "test-project";
n.label = "Function";
n.name = "fpCarrier";
n.qualified_name = "test-project.src.fpCarrier";
n.file_path = "src/fp.go";
n.start_line = 1;
n.end_line = 2;
n.properties_json = "{\"fp\":\"FPSENTINEL00\",\"sp\":\"SPSENTINEL00\","
"\"bt\":\"BTSENTINEL00\",\"complexity\":7}";
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":45,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project\":\"test-project\",\"name_pattern\":\"fpCarrier\","
"\"fields\":[\"fp\",\"sp\",\"bt\",\"complexity\"],\"limit\":5}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "fpCarrier"));
ASSERT_NULL(strstr(inner, "FPSENTINEL00"));
ASSERT_NULL(strstr(inner, "SPSENTINEL00"));
ASSERT_NULL(strstr(inner, "BTSENTINEL00"));
/* Non-blocked requested field still comes through. */
ASSERT_NOT_NULL(strstr(inner, "complexity"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
TEST(tool_search_graph_query_honors_file_pattern_issue552) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "issue-552";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/issue-552");
cbm_node_t lib_status = {0};
lib_status.project = proj;
lib_status.label = "Function";
lib_status.name = "status";
lib_status.qualified_name = "issue-552.src.lib.status";
lib_status.file_path = "src/lib/status.c";
lib_status.start_line = 1;
lib_status.end_line = 3;
ASSERT_GT(cbm_store_upsert_node(st, &lib_status), 0);
cbm_node_t component_status = {0};
component_status.project = proj;
component_status.label = "Function";
component_status.name = "status";
component_status.qualified_name = "issue-552.src.components.status";
component_status.file_path = "src/components/status.c";
component_status.start_line = 1;
component_status.end_line = 3;
ASSERT_GT(cbm_store_upsert_node(st, &component_status), 0);
cbm_store_exec(st, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');");
ASSERT_EQ(cbm_store_exec(st,
"INSERT INTO nodes_fts(rowid, name, qualified_name, label, "
"file_path) "
"SELECT id, cbm_camel_split(name), qualified_name, label, file_path "
"FROM nodes;"),
CBM_STORE_OK);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":552,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project\":\"issue-552\",\"query\":\"status\","
"\"file_pattern\":\"src/lib/*\",\"limit\":10}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "search_mode: bm25"));
ASSERT_NOT_NULL(strstr(inner, "src/lib/status.c"));
ASSERT_NULL(strstr(inner, "src/components/status.c"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* MCP discovery methods this server doesn't populate must return EMPTY
* lists, not -32601 Method-not-found: clients like Cline probe
* resources/list + prompts/list + resources/templates/list on connect and
* surface the errors as a failed connection (#958). */
TEST(mcp_discovery_methods_return_empty_lists) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
struct {
const char *method;
const char *want;
} cases[] = {
{"resources/list", "\"resources\":[]"},
{"prompts/list", "\"prompts\":[]"},
{"resources/templates/list", "\"resourceTemplates\":[]"},
};
for (int i = 0; i < 3; i++) {
char reqbuf[256];
snprintf(reqbuf, sizeof(reqbuf),
"{\"jsonrpc\":\"2.0\",\"id\":%d,\"method\":\"%s\"}", 100 + i,
cases[i].method);
char *resp = cbm_mcp_server_handle(srv, reqbuf);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "Method not found"));
ASSERT_NOT_NULL(strstr(resp, cases[i].want));
free(resp);
}
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_query_graph_basic) {
cbm_mcp_server_t *srv = setup_mcp_with_data();
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":14,\"method\":\"tools/call\","
"\"params\":{\"name\":\"query_graph\","
"\"arguments\":{\"query\":\"MATCH (f:Function) RETURN f.name\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"result\""));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_index_status_no_project) {
cbm_mcp_server_t *srv = setup_mcp_with_data();
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":15,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_status\",\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
/* Should return error or empty status */
ASSERT_NOT_NULL(strstr(resp, "\"result\""));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_index_status_includes_git_metadata) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":16,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_status\","
"\"arguments\":{\"project\":\"test-project\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"root_path\""));
ASSERT_NOT_NULL(strstr(inner, "\"git\""));
ASSERT_NOT_NULL(strstr(inner, "\"is_git\":false"));
ASSERT_NOT_NULL(strstr(inner, "\"root_exists\":true"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* TOOL HANDLERS WITH DATA
* ══════════════════════════════════════════════════════════════════ */
TEST(tool_trace_call_path_not_found) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":20,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_call_path\","
"\"arguments\":{\"function_name\":\"NonExistent\","
"\"project\":\"nonexistent\"}}}");
ASSERT_NOT_NULL(resp);
/* Should return error about project not found */
ASSERT_NOT_NULL(strstr(resp, "not found"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_trace_missing_function_name) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":21,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_call_path\","
"\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "required"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Regression: two same-named definitions with equal rank must be reported
* ambiguous, not silently traced (trace_path previously took nodes[0]). */
TEST(tool_trace_call_path_ambiguous) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "amb-proj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/amb");
cbm_node_t a = {.project = proj,
.label = "Function",
.name = "amb",
.qualified_name = "amb-proj.a.amb",
.file_path = "a.c",
.start_line = 10,
.end_line = 20};
cbm_node_t b = {.project = proj,
.label = "Function",
.name = "amb",
.qualified_name = "amb-proj.b.amb",
.file_path = "b.c",
.start_line = 10,
.end_line = 20}; /* equal span -> genuine tie */
ASSERT_GT(cbm_store_upsert_node(st, &a), 0);
ASSERT_GT(cbm_store_upsert_node(st, &b), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":61,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_call_path\","
"\"arguments\":{\"function_name\":\"amb\",\"project\":\"amb-proj\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "ambiguous"));
ASSERT_NOT_NULL(strstr(inner, "suggestions"));
ASSERT_NULL(strstr(inner, "\"callees\""));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Regression: when same-named nodes differ in rank, trace must pick the real
* definition (callable, larger body) — NOT nodes[0]. The Module is inserted
* first; if trace took nodes[0] the outbound trace would be empty. */
TEST(tool_trace_call_path_prefers_definition) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "pref-proj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/pref");
/* nodes[0]: the WRONG match (a Module, tiny span), inserted first. */
cbm_node_t wrong = {.project = proj,
.label = "Module",
.name = "dup",
.qualified_name = "pref-proj.dup",
.file_path = "dup.x",
.start_line = 1,
.end_line = 1};
/* the real definition: a Function with a body. */
cbm_node_t def = {.project = proj,
.label = "Function",
.name = "dup",
.qualified_name = "pref-proj.src.dup",
.file_path = "src/dup.c",
.start_line = 10,
.end_line = 50};
cbm_node_t callee = {.project = proj,
.label = "Function",
.name = "callee",
.qualified_name = "pref-proj.src.callee",
.file_path = "src/dup.c",
.start_line = 60,
.end_line = 70};
ASSERT_GT(cbm_store_upsert_node(st, &wrong), 0);
int64_t id_def = cbm_store_upsert_node(st, &def);
int64_t id_callee = cbm_store_upsert_node(st, &callee);
ASSERT_GT(id_def, 0);
ASSERT_GT(id_callee, 0);
cbm_edge_t e = {.project = proj, .source_id = id_def, .target_id = id_callee, .type = "CALLS"};
cbm_store_insert_edge(st, &e);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"dup\","
"\"project\":\"pref-proj\",\"direction\":\"outbound\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NULL(strstr(inner, "ambiguous"));
/* picked the Function definition -> its outbound CALLS edge to "callee" shows */
ASSERT_NOT_NULL(strstr(inner, "callee"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Reproduce-first (#887): the client-supplied `depth` on trace_call_path must be
* clamped to the MCP ceiling (cbm_mcp_max_depth(), default 15). On origin/main
* an MCP_MAX_DEPTH=15 constant was defined but never applied — `depth` flowed
* straight into bfs_union_same_name, so an unbounded value drives the shared
* cbm_store_bfs to arbitrary depth. Over an 18-node call chain, depth=1000
* reaches n16/n17 (RED); with the clamp the walk stops at hop 15, so n15 is
* reached but n16 is not (GREEN). Quoted tokens ("n15"/"n16") match only the
* node-name field, never the qualified_name (preceded by '.'), so the boundary
* check is exact. */
TEST(tool_trace_call_path_depth_clamped) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "depth-proj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/depth");
/* Linear call chain n00 -CALLS-> n01 -> ... -> n17 (18 nodes). */
int64_t ids[18];
for (int i = 0; i < 18; i++) {
char name[8];
char qn[32];
snprintf(name, sizeof(name), "n%02d", i);
snprintf(qn, sizeof(qn), "depth-proj.n%02d", i);
cbm_node_t n = {.project = proj,
.label = "Function",
.name = name,
.qualified_name = qn,
.file_path = "chain.c",
.start_line = 1,
.end_line = 2};
ids[i] = cbm_store_upsert_node(st, &n);
}
for (int i = 0; i < 17; i++) {
cbm_edge_t e = {
.project = proj, .source_id = ids[i], .target_id = ids[i + 1], .type = "CALLS"};
cbm_store_insert_edge(st, &e);
}
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"n00\","
"\"project\":\"depth-proj\",\"direction\":\"outbound\",\"depth\":1000}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
/* Reached within the ceiling (proves the traversal ran) but clamped at 15.
* TOON rows carry bare QNs, so match the names unquoted. */
ASSERT_NOT_NULL(strstr(inner, "n15"));
ASSERT_NULL(strstr(inner, "n16"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Reproduce-first (#650, distilled): two GENUINELY-DIFFERENT same-named functions
* whose bodies differ in length score differently, so the old exact-tie check did
* not flag them ambiguous — and bfs_union_same_name (#546) then merged the caller
* sets of both into one confidently-conflated answer (the mirror of #546's under-
* report). The fix: 2+ real callable defs => ambiguous (disambiguate), never union
* distinct symbols. RED before the pick_resolved_node real_def_count rule (response
* merged callerA+callerB), GREEN after (response is ambiguous, no "callers"). */
TEST(tool_trace_call_path_distinct_defs_not_over_unioned) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "ou-proj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/ou");
/* two unrelated real definitions of "dupreal", DIFFERENT body spans */
cbm_node_t da = {.project = proj,
.label = "Function",
.name = "dupreal",
.qualified_name = "ou-proj.a.dupreal",
.file_path = "a.c",
.start_line = 10,
.end_line = 20}; /* span 10 */
cbm_node_t db = {.project = proj,
.label = "Function",
.name = "dupreal",
.qualified_name = "ou-proj.b.dupreal",
.file_path = "b.c",
.start_line = 10,
.end_line = 40}; /* span 30 (no tie) */
cbm_node_t ca = {.project = proj,
.label = "Function",
.name = "callerA",
.qualified_name = "ou-proj.a.callerA",
.file_path = "a.c",
.start_line = 30,
.end_line = 40};
cbm_node_t cb = {.project = proj,
.label = "Function",
.name = "callerB",
.qualified_name = "ou-proj.b.callerB",
.file_path = "b.c",
.start_line = 50,
.end_line = 60};
int64_t id_da = cbm_store_upsert_node(st, &da);
int64_t id_db = cbm_store_upsert_node(st, &db);
int64_t id_ca = cbm_store_upsert_node(st, &ca);
int64_t id_cb = cbm_store_upsert_node(st, &cb);
ASSERT_GT(id_da, 0);
ASSERT_GT(id_db, 0);
ASSERT_GT(id_ca, 0);
ASSERT_GT(id_cb, 0);
cbm_edge_t ea = {.project = proj, .source_id = id_ca, .target_id = id_da, .type = "CALLS"};
cbm_edge_t eb = {.project = proj, .source_id = id_cb, .target_id = id_db, .type = "CALLS"};
cbm_store_insert_edge(st, &ea);
cbm_store_insert_edge(st, &eb);
char *resp = cbm_mcp_server_handle(
srv,
"{\"jsonrpc\":\"2.0\",\"id\":63,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"dupreal\","
"\"project\":\"ou-proj\",\"direction\":\"inbound\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
/* distinct symbols must be disambiguated, not merged into one caller set */
ASSERT_NOT_NULL(strstr(inner, "ambiguous"));
ASSERT_NOT_NULL(strstr(inner, "suggestions"));
ASSERT_NULL(strstr(inner, "\"callers\""));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Guard that the ambiguity gate does NOT regress the #546 fix: a real .ts
* implementation plus a body-less ambient .d.ts stub is ONE logical symbol
* (one real callable def + a fragment), so it must stay non-ambiguous and the
* caller sets from both nodes must be unioned. */
TEST(tool_trace_call_path_dts_stub_unions_with_impl) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "dts-proj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/dts");
cbm_node_t impl = {.project = proj,
.label = "Function",
.name = "sym546",
.qualified_name = "dts-proj.impl.sym546",
.file_path = "src/sym.ts",
.start_line = 10,
.end_line = 30}; /* real body */
cbm_node_t stub = {.project = proj,
.label = "Function",
.name = "sym546",
.qualified_name = "dts-proj.stub.sym546",
.file_path = "types/sym.d.ts",
.start_line = 5,
.end_line = 5}; /* body-less ambient decl */
cbm_node_t crel = {.project = proj,
.label = "Function",
.name = "callerRel",
.qualified_name = "dts-proj.callerRel",
.file_path = "src/rel.ts",
.start_line = 1,
.end_line = 8};
cbm_node_t cali = {.project = proj,
.label = "Function",
.name = "callerAlias",
.qualified_name = "dts-proj.callerAlias",
.file_path = "src/ali.ts",
.start_line = 1,
.end_line = 8};
int64_t id_impl = cbm_store_upsert_node(st, &impl);
int64_t id_stub = cbm_store_upsert_node(st, &stub);
int64_t id_crel = cbm_store_upsert_node(st, &crel);
int64_t id_cali = cbm_store_upsert_node(st, &cali);
ASSERT_GT(id_impl, 0);
ASSERT_GT(id_stub, 0);
ASSERT_GT(id_crel, 0);
ASSERT_GT(id_cali, 0);
/* callers split by import style: relative -> impl, path-alias -> stub */
cbm_edge_t er = {.project = proj, .source_id = id_crel, .target_id = id_impl, .type = "CALLS"};
cbm_edge_t el = {.project = proj, .source_id = id_cali, .target_id = id_stub, .type = "CALLS"};
cbm_store_insert_edge(st, &er);
cbm_store_insert_edge(st, &el);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":64,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"sym546\","
"\"project\":\"dts-proj\",\"direction\":\"inbound\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NULL(strstr(inner, "ambiguous"));
/* union across impl + stub: BOTH callers appear (this is the #546 fix) */
ASSERT_NOT_NULL(strstr(inner, "callerRel"));
ASSERT_NOT_NULL(strstr(inner, "callerAlias"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_delete_project_not_found) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":22,\"method\":\"tools/call\","
"\"params\":{\"name\":\"delete_project\","
"\"arguments\":{\"project\":\"nonexistent\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "not_found"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_get_architecture_empty) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":24,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project\":\"nonexistent\"}}}");
ASSERT_NOT_NULL(resp);
/* No store for nonexistent project — should return project error */
ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Regression for #281: handle_get_architecture must actually call
* cbm_store_get_architecture and surface its sections. Before the fix
* only label/edge histograms were emitted regardless of which aspects
* were requested. The store-side arch_entry_points query reads
* properties.is_entry_point on Function nodes, so we tag one node and
* assert the resulting JSON surfaces an "entry_points" array containing
* the tagged function — which is impossible without the wiring. */
TEST(tool_get_architecture_emits_populated_sections) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "arch-test";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/arch-test");
cbm_node_t main_fn = {0};
main_fn.project = proj;
main_fn.label = "Function";
main_fn.name = "main";
main_fn.qualified_name = "arch-test.cmd.main";
main_fn.file_path = "cmd/main.go";
main_fn.start_line = 1;
main_fn.end_line = 3;
main_fn.properties_json = "{\"is_entry_point\":true}";
ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project\":\"arch-test\",\"aspects\":[\"all\"]}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
/* The handler always emits node/edge counts and schema histograms;
* those existed before #281. The "entry_points" array only appears
* when cbm_store_get_architecture is actually called and its result
* is serialized — which is exactly what #281 wires up. */
ASSERT_NOT_NULL(strstr(inner, "entry_points["));
ASSERT_NOT_NULL(strstr(inner, "main"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Distills PR #560 (overview subset): "overview" must expand to a compact
* subset — every aspect EXCEPT file_tree. Before the fix, "overview" was not
* registered in either aspect gate (want_aspect in store.c, aspect_wanted in
* mcp.c), so aspects=["overview"] silently degraded to just
* {total_nodes,total_edges}. RED on unfixed code: no "entry_points" key. */
TEST(tool_get_architecture_overview_compact_subset_pr560) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "arch560";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/arch560");
cbm_node_t main_fn = {0};
main_fn.project = proj;
main_fn.label = "Function";
main_fn.name = "main";
main_fn.qualified_name = "arch560.cmd.main";
main_fn.file_path = "cmd/main.go";
main_fn.start_line = 1;
main_fn.end_line = 3;
main_fn.properties_json = "{\"is_entry_point\":true}";
ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0);
/* A File node so the file_tree aspect has real content — makes the
* "overview drops file_tree" assertion below non-vacuous. */
cbm_node_t file_node = {.project = proj,
.label = "File",
.name = "main.go",
.qualified_name = "arch560.cmd.main.go",
.file_path = "cmd/main.go"};
ASSERT_GT(cbm_store_upsert_node(st, &file_node), 0);
/* Sanity: with "all", both entry_points and file_tree surface. */
char *resp_all = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":560,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project\":\"arch560\",\"aspects\":[\"all\"]}}}");
ASSERT_NOT_NULL(resp_all);
char *inner_all = extract_text_content(resp_all);
ASSERT_NOT_NULL(inner_all);
ASSERT_NOT_NULL(strstr(inner_all, "entry_points["));
ASSERT_NOT_NULL(strstr(inner_all, "file_tree["));
free(inner_all);
free(resp_all);
/* "overview": substantive content (entry_points, node_labels) but NO
* file_tree section. */
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":561,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project\":\"arch560\",\"aspects\":[\"overview\"]}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "entry_points["));
ASSERT_NOT_NULL(strstr(inner, "node_labels["));
ASSERT_NULL(strstr(inner, "file_tree["));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Distills PR #560 (server-side validation): unknown aspect tokens must be
* rejected with an isError result listing the valid values. Before the fix
* the JSON-Schema accepted any string and both aspect gates simply never
* matched, so a typo like "bogus_aspect" produced a silent near-empty payload
* with isError:false. RED on unfixed code: no isError, no "Unknown aspect". */
TEST(tool_get_architecture_rejects_unknown_aspect_pr560) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "arch560v";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/arch560v");
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":562,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project\":\"arch560v\",\"aspects\":[\"bogus_aspect\"]}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"isError\":true"));
ASSERT_NOT_NULL(strstr(resp, "Unknown aspect 'bogus_aspect'"));
/* The error must teach the valid vocabulary, including the new token. */
ASSERT_NOT_NULL(strstr(resp, "overview"));
ASSERT_NOT_NULL(strstr(resp, "file_tree"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Reproduce-first for #640: query handlers must accept the `project_name`
* alias, not only the canonical `project` key. list_projects surfaces the field
* as "name" and the error hint says "pass the project name", so a caller
* naturally passes `project_name`. With no alias, the handler reads key
* "project" -> NULL -> resolve_store bails before opening any .db -> "project
* not found or not indexed" even though the project is indexed. Mirrors
* tool_get_architecture_emits_populated_sections but with the alias key. */
TEST(tool_get_architecture_accepts_project_name_alias_issue640) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "alias640";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/alias640");
cbm_node_t main_fn = {0};
main_fn.project = proj;
main_fn.label = "Function";
main_fn.name = "main";
main_fn.qualified_name = "alias640.cmd.main";
main_fn.file_path = "cmd/main.go";
main_fn.start_line = 1;
main_fn.end_line = 3;
main_fn.properties_json = "{\"is_entry_point\":true}";
ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0);
/* Caller passes `project_name` (the natural guess) instead of `project`. */
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":640,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project_name\":\"alias640\",\"aspects\":[\"all\"]}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
/* RED before the alias: inner is the "project not found" error.
* GREEN after: the alias resolves and architecture sections surface. */
ASSERT_NULL(strstr(inner, "project not found"));
ASSERT_NOT_NULL(strstr(inner, "entry_points["));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Reproduce-first for #640: the alias must apply across query handlers, not
* just get_architecture. search_graph with `project_name` must resolve too. */
TEST(tool_search_graph_accepts_project_name_alias_issue640) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "alias640b";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/alias640b");
cbm_node_t fn = {0};
fn.project = proj;
fn.label = "Function";
fn.name = "WidgetHandler";
fn.qualified_name = "alias640b.svc.WidgetHandler";
fn.file_path = "svc/widget.go";
fn.start_line = 1;
fn.end_line = 2;
ASSERT_GT(cbm_store_upsert_node(st, &fn), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":641,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project_name\":\"alias640b\",\"name_pattern\":\"Widget.*\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NULL(strstr(inner, "project not found"));
ASSERT_NOT_NULL(strstr(inner, "WidgetHandler"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* #1025: agents pass the repo FOLDER name ("codebase-memory-mcp"), but
* indexed project names derive from the full path
* (E:\project\graph\x -> "E-project-graph-x"), so exact lookup fails with
* "project not found" while list_projects clearly shows the project. A
* passed name that matches exactly ONE indexed project as a segment-aligned
* tail ("-<name>" suffix) must resolve to it; zero or several matches keep
* the existing error. Runs against real cache-dir .db files (the resolution
* scans filenames), so this test indexes real fixtures under an overridden
* CBM_CACHE_DIR. */
static void i1025_write_repo(const char *dir, const char *fn_name) {
char path[CBM_SZ_512];
snprintf(path, sizeof(path), "%s/mod.py", dir);
FILE *f = fopen(path, "w");
if (!f)
return;
fprintf(f, "def %s(x):\n return x + 1\n", fn_name);
fclose(f);
}
TEST(tool_project_arg_resolves_unique_tail_issue1025) {
char repo_a[CBM_SZ_256];
char repo_b[CBM_SZ_256];
char repo_c[CBM_SZ_256];
char cache[CBM_SZ_256];
snprintf(repo_a, sizeof(repo_a), "/tmp/cbm-i1025a-XXXXXX");
snprintf(repo_b, sizeof(repo_b), "/tmp/cbm-i1025b-XXXXXX");
snprintf(repo_c, sizeof(repo_c), "/tmp/cbm-i1025c-XXXXXX");
snprintf(cache, sizeof(cache), "/tmp/cbm-i1025d-XXXXXX");
if (!cbm_mkdtemp(repo_a) || !cbm_mkdtemp(repo_b) || !cbm_mkdtemp(repo_c) ||
!cbm_mkdtemp(cache)) {
FAIL("mkdtemp failed");
}
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1);
i1025_write_repo(repo_a, "unique_tail_target");
i1025_write_repo(repo_b, "amb_one");
i1025_write_repo(repo_c, "amb_two");
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char args[CBM_SZ_1K];
snprintf(args, sizeof(args),
"{\"repo_path\":\"%s\",\"name\":\"E-project-graph-suffix1025\"}", repo_a);
char *r = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(r);
free(r);
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"F-alpha-amb1025\"}", repo_b);
r = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(r);
free(r);
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"G-beta-amb1025\"}", repo_c);
r = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(r);
free(r);
/* 1. Unique tail resolves (RED today: "project not found"). */
r = cbm_mcp_handle_tool(srv, "search_graph",
"{\"project\":\"suffix1025\",\"name_pattern\":\".*target.*\"}");
ASSERT_NOT_NULL(r);
if (strstr(r, "project not found")) {
fprintf(stderr, " [1025] FAIL unique tail did not resolve: %.200s\n", r);
}
ASSERT_NULL(strstr(r, "project not found"));
ASSERT_NOT_NULL(strstr(r, "unique_tail_target"));
free(r);
/* 2. Ambiguous tail stays an error (never guess between projects). */
r = cbm_mcp_handle_tool(srv, "search_graph",
"{\"project\":\"amb1025\",\"name_pattern\":\".*\"}");
ASSERT_NOT_NULL(r);
ASSERT_NOT_NULL(strstr(r, "project not found"));
free(r);
/* 3. Exact full name keeps working unchanged. */
r = cbm_mcp_handle_tool(srv, "search_graph",
"{\"project\":\"E-project-graph-suffix1025\","
"\"name_pattern\":\".*target.*\"}");
ASSERT_NOT_NULL(r);
ASSERT_NULL(strstr(r, "project not found"));
free(r);
cbm_mcp_server_free(srv);
if (saved_cache_copy) {
cbm_setenv("CBM_CACHE_DIR", saved_cache_copy, 1);
free(saved_cache_copy);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
th_rmtree(repo_a);
th_rmtree(repo_b);
th_rmtree(repo_c);
th_rmtree(cache);
PASS();
}
/* Regression for #604: path scopes architecture totals and content. */
TEST(tool_get_architecture_path_scoping) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "arch-path";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/arch-path");
cbm_node_t pkg_global = {.project = proj,
.label = "Package",
.name = "Django",
.qualified_name = "arch-path.Django",
.file_path = "vendor/django/__init__.py"};
cbm_store_upsert_node(st, &pkg_global);
cbm_node_t pkg_local = {.project = proj,
.label = "Package",
.name = "hoa",
.qualified_name = "arch-path.hoa",
.file_path = "apps/hoa/main.go"};
cbm_store_upsert_node(st, &pkg_local);
cbm_node_t f_hoa = {.project = proj,
.label = "File",
.name = "main.go",
.qualified_name = "arch-path.apps.hoa.main.go",
.file_path = "apps/hoa/main.go"};
cbm_store_upsert_node(st, &f_hoa);
cbm_node_t f_other = {.project = proj,
.label = "File",
.name = "other.go",
.qualified_name = "arch-path.other.go",
.file_path = "lib/other.go"};
cbm_store_upsert_node(st, &f_other);
char *resp_root = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project\":\"arch-path\",\"aspects\":[\"packages\"]}}}");
ASSERT_NOT_NULL(resp_root);
char *inner_root = extract_text_content(resp_root);
ASSERT_NOT_NULL(inner_root);
ASSERT_NOT_NULL(strstr(inner_root, "Django"));
char *resp_scoped =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_architecture\","
"\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\","
"\"aspects\":[\"packages\"]}}}");
ASSERT_NOT_NULL(resp_scoped);
char *inner_scoped = extract_text_content(resp_scoped);
ASSERT_NOT_NULL(inner_scoped);
ASSERT_NOT_NULL(strstr(inner_scoped, "root_total_nodes"));
ASSERT_NOT_NULL(strstr(inner_scoped, "scoped_total_nodes"));
ASSERT_NOT_NULL(strstr(inner_scoped, "path: "));
ASSERT_NOT_NULL(strstr(inner_scoped, "hoa"));
ASSERT_NULL(strstr(inner_scoped, "Django"));
int root_nodes = 0;
int scoped_nodes = 0;
/* TOON scalar form (`key: N`) with JSON fallback for format:"json". */
const char *rt = strstr(inner_scoped, "root_total_nodes: ");
const char *stn = strstr(inner_scoped, "scoped_total_nodes: ");
if (rt) {
sscanf(rt, "root_total_nodes: %d", &root_nodes);
} else if ((rt = strstr(inner_scoped, "\"root_total_nodes\":")) != NULL) {
sscanf(rt, "\"root_total_nodes\":%d", &root_nodes);
}
if (stn) {
sscanf(stn, "scoped_total_nodes: %d", &scoped_nodes);
} else if ((stn = strstr(inner_scoped, "\"scoped_total_nodes\":")) != NULL) {
sscanf(stn, "\"scoped_total_nodes\":%d", &scoped_nodes);
}
ASSERT_TRUE(root_nodes > scoped_nodes);
ASSERT_TRUE(scoped_nodes > 0);
free(inner_scoped);
free(resp_scoped);
free(inner_root);
free(resp_root);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_query_graph_missing_query) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":23,\"method\":\"tools/call\","
"\"params\":{\"name\":\"query_graph\","
"\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
/* Should return error about missing query */
ASSERT_NOT_NULL(strstr(resp, "required"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* PIPELINE-DEPENDENT TOOL HANDLERS
* ══════════════════════════════════════════════════════════════════ */
TEST(tool_index_repository_missing_path) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":30,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_repository\","
"\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "required"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_get_code_snippet_missing_qn) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":31,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_code_snippet\","
"\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "required"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_get_code_snippet_not_found) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":32,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_code_snippet\","
"\"arguments\":{\"qualified_name\":\"nonexistent.func\","
"\"project\":\"nonexistent\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "not found"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_search_code_missing_pattern) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":33,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "required"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_search_code_no_project) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":34,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"func main\","
"\"project\":\"nonexistent\"}}}");
ASSERT_NOT_NULL(resp);
/* No project indexed → error */
ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed") ||
strstr(resp, "required"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(search_code_multi_word) {
char tmp[512];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Multi-word query "HandleRequest error" — should find the line
* "func HandleRequest() error {" via regex conversion. */
char req[512];
snprintf(req, sizeof(req),
"{\"jsonrpc\":\"2.0\",\"id\":90,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"HandleRequest error\","
"\"project\":\"test-project\"}}}");
char *resp = cbm_mcp_server_handle(srv, req);
ASSERT_NOT_NULL(resp);
/* Should find at least one result (not zero) */
ASSERT_TRUE(strstr(resp, "HandleRequest") != NULL);
/* Should NOT contain an error about "not found" */
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
free(resp);
cleanup_snippet_dir(tmp);
cbm_mcp_server_free(srv);
PASS();
}
/* Reproduce-first (#687): scoped content search over a repo whose ROOT PATH
* contains a space. write_scoped_filelist emits "<root>/<file>" records that the
* Unix pipeline pipes to grep via xargs. With plain `xargs` (newline-split) the
* space splits one path into several bogus args -> grep finds nothing ->
* total_grep_matches == 0 (RED on the unfixed code). The fix writes NUL-separated
* records + uses `xargs -0`, so the path stays a single argument -> match found
* (GREEN). On Windows the scoped path uses PowerShell Get-Content -LiteralPath,
* which already handles spaces, so this asserts correct behavior there too. */
TEST(search_code_scoped_path_with_spaces_issue687) {
char tmp[512];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_srch_space_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("cbm_mkdtemp failed");
}
/* Project root deliberately contains a space. */
char proj_dir[640];
snprintf(proj_dir, sizeof(proj_dir), "%s/my project", tmp);
cbm_mkdir(proj_dir);
char src_path[768];
snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir);
FILE *fp = fopen(src_path, "w");
if (!fp) {
rmdir(proj_dir);
rmdir(tmp);
FAIL("cannot write source file under spaced path");
}
fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n");
fclose(fp);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "space-search";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, proj_dir);
/* A node so the file is "indexed" (cbm_store_list_files -> scoped grep path)
* and the grep hit classifies to a result. */
cbm_node_t n = {.project = proj,
.label = "Function",
.name = "HandleRequest",
.qualified_name = "space-search.main.HandleRequest",
.file_path = "main.go",
.start_line = 3,
.end_line = 5};
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":94,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"space-search\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
/* grep must have found the match despite the space in the root path. */
int grep_matches = -1;
const char *g = strstr(inner, "\"total_grep_matches\":");
if (g) {
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
} else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) {
/* TOON scalar form — the search_code compact default. */
sscanf(g, "total_grep_matches: %d", &grep_matches);
}
ASSERT_TRUE(grep_matches > 0);
free(inner);
free(resp);
cbm_mcp_server_free(srv);
unlink(src_path);
rmdir(proj_dir);
rmdir(tmp);
PASS();
}
#ifdef _WIN32
/* Issue #903 follow-up: scoped search_code on Windows writes a UTF-8 filelist
* containing absolute source paths, then reads it back through PowerShell.
* Windows PowerShell 5.1 treats UTF-8 without BOM as ANSI unless told
* otherwise, so a non-ASCII project root can be mojibaked before
* Select-String sees the LiteralPath. */
TEST(search_code_scoped_path_with_cjk_root_issue903) {
char tmp[512];
snprintf(tmp, sizeof(tmp), "%s/cbm_srch_cjk_XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(tmp)) {
FAIL("cbm_mkdtemp failed");
}
char proj_dir[640];
snprintf(proj_dir, sizeof(proj_dir), "%s/%s", tmp,
"\xE4\xB8\xAD\xE6\x96\x87\xE9\xA1\xB9\xE7\x9B\xAE");
if (!cbm_mkdir_p(proj_dir, 0755)) {
cbm_rmdir(tmp);
FAIL("cannot create CJK project dir");
}
char src_path[768];
snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir);
FILE *fp = cbm_fopen(src_path, "wb");
if (!fp) {
cbm_rmdir(proj_dir);
cbm_rmdir(tmp);
FAIL("cannot write source file under CJK path");
}
fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n");
fclose(fp);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
const char *proj = "cjk-search";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, proj_dir);
cbm_node_t n = {.project = proj,
.label = "Function",
.name = "HandleRequest",
.qualified_name = "cjk-search.main.HandleRequest",
.file_path = "main.go",
.start_line = 3,
.end_line = 5};
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":903,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"cjk-search\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
int grep_matches = -1;
const char *g = strstr(inner, "\"total_grep_matches\":");
if (g) {
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
} else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) {
/* TOON scalar form — the search_code compact default. */
sscanf(g, "total_grep_matches: %d", &grep_matches);
}
ASSERT_TRUE(grep_matches > 0);
free(inner);
free(resp);
cbm_mcp_server_free(srv);
cbm_unlink(src_path);
cbm_rmdir(proj_dir);
cbm_rmdir(tmp);
PASS();
}
#endif
/* Shared fixture for the path_filter prefilter tests (PR #756 distilled):
* a project with two indexed files that both contain the search pattern —
* src/handler.go (inside the filter) and vendor/other.go (outside it). */
static cbm_mcp_server_t *setup_prefilter_server(char *tmp, size_t tmp_sz, char *src_path,
size_t src_sz, char *vendor_path,
size_t vendor_sz) {
snprintf(tmp, tmp_sz, "/tmp/cbm_srch_pref_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
return NULL;
}
char dir[640];
snprintf(dir, sizeof(dir), "%s/src", tmp);
cbm_mkdir(dir);
snprintf(dir, sizeof(dir), "%s/vendor", tmp);
cbm_mkdir(dir);
snprintf(src_path, src_sz, "%s/src/handler.go", tmp);
snprintf(vendor_path, vendor_sz, "%s/vendor/other.go", tmp);
FILE *fp = fopen(src_path, "w");
if (!fp) {
return NULL;
}
fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n");
fclose(fp);
fp = fopen(vendor_path, "w");
if (!fp) {
return NULL;
}
fprintf(fp, "package vendored\n\nfunc HandleRequest() error {\n\treturn nil\n}\n");
fclose(fp);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (!srv) {
return NULL;
}
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "prefilter-search";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, tmp);
cbm_node_t n1 = {.project = proj,
.label = "Function",
.name = "HandleRequest",
.qualified_name = "prefilter-search.main.HandleRequest",
.file_path = "src/handler.go",
.start_line = 3,
.end_line = 5};
cbm_node_t n2 = {.project = proj,
.label = "Function",
.name = "HandleRequest",
.qualified_name = "prefilter-search.vendored.HandleRequest",
.file_path = "vendor/other.go",
.start_line = 3,
.end_line = 5};
if (cbm_store_upsert_node(st, &n1) <= 0 || cbm_store_upsert_node(st, &n2) <= 0) {
cbm_mcp_server_free(srv);
return NULL;
}
return srv;
}
static void cleanup_prefilter_dir(const char *tmp, const char *src_path, const char *vendor_path) {
char dir[640];
unlink(src_path);
unlink(vendor_path);
snprintf(dir, sizeof(dir), "%s/src", tmp);
rmdir(dir);
snprintf(dir, sizeof(dir), "%s/vendor", tmp);
rmdir(dir);
rmdir(tmp);
}
/* PR #756 (distilled): scoped search_code prefilters the indexed filelist by
* path_filter before grep runs. POSITIVE invariant guard: a path_filter that
* matches the file containing the hit must still return that hit (guards
* against over-filtering — the prefilter predicate must stay IDENTICAL to the
* post-grep filter in collect_grep_matches), and files outside the filter
* stay excluded. Green on pre-prefilter main too (the post-grep filter alone
* produced the same results): the change is results-preserving perf-only. */
TEST(search_code_path_filter_prefilter_keeps_matches) {
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(srv);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":95,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\","
"\"path_filter\":\"^src/\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
/* The in-filter hit is returned; the out-of-filter file is not. */
ASSERT_NOT_NULL(strstr(inner, "src/handler.go"));
ASSERT_TRUE(strstr(inner, "vendor/other.go") == NULL);
/* Exactly the one in-filter grep match survives (same count before and
* after the prefilter — predicate identity). */
int grep_matches = -1;
const char *g = strstr(inner, "\"total_grep_matches\":");
if (g) {
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
} else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) {
/* TOON scalar form — the search_code compact default. */
sscanf(g, "total_grep_matches: %d", &grep_matches);
}
ASSERT_EQ(grep_matches, 1);
free(inner);
free(resp);
cbm_mcp_server_free(srv);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
PASS();
}
/* PR #756 (distilled): path_filter matching ZERO indexed files. With the
* prefilter the scoped filelist has 0 records, and handle_search_code now
* skips the grep subprocess entirely (xargs on an empty filelist is
* platform-dependent: GNU execs grep once with no operands, BSD skips) and
* returns the empty result directly. Must be a clean zero-result response —
* no error. Green on pre-prefilter main too (there the full filelist is
* grepped and the post-grep filter drops every hit — an empty filelist is
* unreachable on main): guards the edge the prefilter introduces. */
TEST(search_code_path_filter_matches_nothing) {
char tmp[512], src_path[768], vendor_path[768];
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
vendor_path, sizeof(vendor_path));
ASSERT_NOT_NULL(srv);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":96,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\","
"\"path_filter\":\"^no_such_dir/\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
int grep_matches = -1;
const char *g = strstr(inner, "\"total_grep_matches\":");
if (g) {
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
} else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) {
/* TOON scalar form — the search_code compact default. */
sscanf(g, "total_grep_matches: %d", &grep_matches);
}
ASSERT_EQ(grep_matches, 0);
int results = -1;
const char *r = strstr(inner, "\"total_results\":");
if (r) {
sscanf(r, "\"total_results\":%d", &results);
} else if ((r = strstr(inner, "total_results: ")) != NULL) {
sscanf(r, "total_results: %d", &results);
}
ASSERT_EQ(results, 0);
ASSERT_TRUE(strstr(inner, "handler.go") == NULL);
ASSERT_TRUE(strstr(inner, "other.go") == NULL);
free(inner);
free(resp);
cbm_mcp_server_free(srv);
cleanup_prefilter_dir(tmp, src_path, vendor_path);
PASS();
}
/* issue #283: search_code with regex=true and a syntactically invalid pattern
* must return an explicit error, not an empty result indistinguishable from a
* legitimate no-match. */
TEST(search_code_invalid_regex_errors_issue283) {
char tmp[512];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Unclosed group under regex=true → must be flagged as an error. */
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"func(\",\"regex\":true,"
"\"project\":\"test-project\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"isError\":true"));
ASSERT_NOT_NULL(strstr(resp, "invalid regex"));
free(resp);
/* Same pattern as a literal (regex=false) must NOT error. */
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"func(\",\"regex\":false,"
"\"project\":\"test-project\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "invalid regex") == NULL);
free(resp);
cleanup_snippet_dir(tmp);
cbm_mcp_server_free(srv);
PASS();
}
/* issue #282: a literal '|' under regex=false is a silent 0-match trap. It must
* now be surfaced as a warning (and the result carries elapsed_ms). */
TEST(search_code_literal_pipe_warns_issue282) {
char tmp[512];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"HandleRequest|Nope\","
"\"regex\":false,\"project\":\"test-project\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "warning")); /* surfaced, not silent */
ASSERT_NOT_NULL(strstr(resp, "regex=true")); /* the hint names the fix */
ASSERT_NOT_NULL(strstr(resp, "elapsed_ms")); /* timing is reported */
free(resp);
cleanup_snippet_dir(tmp);
cbm_mcp_server_free(srv);
PASS();
}
/* issue #272: '&' in a path / file_pattern is neutralised by the command's
* quoting and must no longer be rejected as "invalid characters". */
TEST(search_code_ampersand_accepted_issue272) {
char tmp[512];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":94,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_code\","
"\"arguments\":{\"pattern\":\"HandleRequest\","
"\"file_pattern\":\"*R&D*.go\",\"project\":\"test-project\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "invalid characters") == NULL);
free(resp);
cleanup_snippet_dir(tmp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_detect_changes_no_project) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":35,\"method\":\"tools/call\","
"\"params\":{\"name\":\"detect_changes\","
"\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "missing required argument: project"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_manage_adr_no_project) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":36,\"method\":\"tools/call\","
"\"params\":{\"name\":\"manage_adr\","
"\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "missing required argument: project"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Regression test for use-after-free in handle_manage_adr (get path).
* MUST FAIL before fix: free(buf) is called before yy_doc_to_str serializes doc,
* so result field is missing or contains garbage. MUST PASS after fix. */
TEST(tool_manage_adr_get_with_existing_adr) {
/* Create a temp directory with .codebase-memory/adr.md */
char tmp_dir[256];
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-test-XXXXXX");
if (!cbm_mkdtemp(tmp_dir)) {
PASS(); /* skip if mkdtemp fails */
}
char adr_dir[512];
snprintf(adr_dir, sizeof(adr_dir), "%s/.codebase-memory", tmp_dir);
cbm_mkdir(adr_dir);
char adr_path[512];
snprintf(adr_path, sizeof(adr_path), "%s/adr.md", adr_dir);
FILE *fp = fopen(adr_path, "w");
ASSERT_NOT_NULL(fp);
fputs("## PURPOSE\nTest ADR content for regression test.\n\n"
"## STACK\nC, SQLite.\n\n"
"## ARCHITECTURE\nMCP server.\n",
fp);
fclose(fp);
/* Create server and register the project */
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
cbm_store_upsert_project(st, "test-adr-uaf", tmp_dir);
cbm_mcp_server_set_project(srv, "test-adr-uaf");
/* Call manage_adr via full JSON-RPC path to exercise cbm_jsonrpc_format_response.
* The bug: free(buf) before yy_doc_to_str causes garbage JSON; format_response
* then fails to parse the result and omits the "result" field entirely. */
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"tools/call\","
"\"params\":{\"name\":\"manage_adr\","
"\"arguments\":{\"project\":\"test-adr-uaf\",\"mode\":\"get\"}}}");
ASSERT_NOT_NULL(resp);
/* JSON-RPC response must include a "result" field (absent when use-after-free) */
ASSERT_NOT_NULL(strstr(resp, "\"result\""));
/* ADR content must appear in response */
ASSERT_NOT_NULL(strstr(resp, "PURPOSE"));
/* Must not be an error */
ASSERT_NULL(strstr(resp, "\"isError\":true"));
free(resp);
/* Clean up */
cbm_mcp_server_free(srv);
remove(adr_path);
rmdir(adr_dir);
rmdir(tmp_dir);
PASS();
}
/* issue #256: manage_adr (MCP) and the UI /api/adr endpoints must share ONE
* backend. A manage_adr(update) write must be readable via cbm_store_adr_get
* (the exact API the UI's /api/adr GET uses). */
TEST(tool_manage_adr_unified_backend_issue256) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
cbm_store_upsert_project(st, "adr-unify", "/tmp/adr-unify");
cbm_mcp_server_set_project(srv, "adr-unify");
/* Write via the MCP tool. */
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":120,\"method\":\"tools/call\","
"\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\","
"\"mode\":\"update\",\"content\":\"## PURPOSE\\nUnified ADR backend.\\n\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "updated"));
free(resp);
/* Read DIRECTLY via the store API the UI /api/adr uses — must see it. */
cbm_adr_t adr;
memset(&adr, 0, sizeof(adr));
ASSERT_EQ(cbm_store_adr_get(st, "adr-unify", &adr), CBM_STORE_OK);
ASSERT_NOT_NULL(adr.content);
ASSERT_NOT_NULL(strstr(adr.content, "Unified ADR backend."));
cbm_store_adr_free(&adr);
/* And manage_adr(get) round-trips the same content. */
resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":121,\"method\":\"tools/call\","
"\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\","
"\"mode\":\"get\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "Unified ADR backend."));
ASSERT_NULL(strstr(resp, "\"isError\":true"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_index_repository_reports_store_backed_adr) {
char tmp_dir[256];
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-adr-test-XXXXXX");
if (!cbm_mkdtemp(tmp_dir)) {
PASS();
}
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-index-adr-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
cbm_rmdir(tmp_dir);
PASS();
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
char src_path[512];
snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir);
FILE *fp = fopen(src_path, "w");
ASSERT_NOT_NULL(fp);
fputs("def main():\n return 'ok'\n", fp);
fclose(fp);
char *project = cbm_project_name_from_path(tmp_dir);
ASSERT_NOT_NULL(project);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char args[1024];
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir);
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(resp);
ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\""));
free(resp);
char update_args[2048];
snprintf(update_args, sizeof(update_args),
"{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n"
"Store-backed ADR metadata.\\n\"}",
project);
resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "updated"));
free(resp);
resp = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(resp);
ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\""));
ASSERT(response_contains_json_fragment(resp, "\"adr_present\":true"));
ASSERT_NULL(strstr(resp, "adr_hint"));
free(resp);
char get_args[512];
snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", project);
resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "Store-backed ADR metadata."));
ASSERT_NULL(strstr(resp, "no_adr"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_project_db(cache, project);
restore_cache_dir(saved_copy);
free(saved_copy);
free(project);
remove(src_path);
cbm_rmdir(cache);
cbm_rmdir(tmp_dir);
PASS();
}
TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr) {
char tmp_dir[256];
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-index-dot-adr-test-XXXXXX");
if (!cbm_mkdtemp(tmp_dir)) {
PASS();
}
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-index-dot-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
cbm_rmdir(tmp_dir);
PASS();
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
char src_path[512];
snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir);
FILE *fp = fopen(src_path, "w");
ASSERT_NOT_NULL(fp);
fputs("def main():\n return helper()\n\ndef helper():\n return 1\n", fp);
fclose(fp);
char old_cwd[CBM_SZ_4K];
ASSERT_NOT_NULL(cbm_getcwd(old_cwd, sizeof(old_cwd)));
char *project = cbm_project_name_from_path(tmp_dir);
ASSERT_NOT_NULL(project);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
ASSERT_EQ(cbm_chdir(tmp_dir), 0);
char *resp =
cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\".\",\"mode\":\"fast\"}");
ASSERT_EQ(cbm_chdir(old_cwd), 0);
ASSERT_NOT_NULL(resp);
if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) {
free(resp);
cbm_mcp_server_free(srv);
cleanup_project_db(cache, project);
restore_cache_dir(saved_copy);
free(saved_copy);
free(project);
remove(src_path);
cbm_rmdir(cache);
cbm_rmdir(tmp_dir);
PASS();
}
ASSERT_NOT_NULL(strstr(resp, project));
ASSERT(!response_contains_json_fragment(resp, "\"project\":\"root\""));
free(resp);
char update_args[2048];
snprintf(update_args, sizeof(update_args),
"{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n"
"Dot-path ADR marker.\\n\"}",
project);
resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "updated"));
free(resp);
ASSERT_EQ(cbm_chdir(tmp_dir), 0);
resp = cbm_mcp_handle_tool(srv, "index_repository", "{\"repo_path\":\".\",\"mode\":\"fast\"}");
ASSERT_EQ(cbm_chdir(old_cwd), 0);
ASSERT_NOT_NULL(resp);
ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\""));
ASSERT_NOT_NULL(strstr(resp, project));
ASSERT(response_contains_json_fragment(resp, "\"adr_present\":true"));
ASSERT(!response_contains_json_fragment(resp, "\"project\":\"root\""));
free(resp);
char get_args[512];
snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", project);
resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "Dot-path ADR marker."));
ASSERT_NULL(strstr(resp, "no_adr"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_project_db(cache, project);
restore_cache_dir(saved_copy);
free(saved_copy);
free(project);
remove(src_path);
cbm_rmdir(cache);
cbm_rmdir(tmp_dir);
PASS();
}
TEST(tool_manage_adr_not_found_rich_error) {
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-adr-missing-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
PASS();
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *resp = cbm_mcp_handle_tool(srv, "manage_adr",
"{\"project\":\"cbm-no-such-project-zzz\",\"mode\":\"get\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "or not indexed"));
ASSERT_NOT_NULL(strstr(resp, "hint"));
free(resp);
cbm_mcp_server_free(srv);
restore_cache_dir(saved_copy);
free(saved_copy);
cbm_rmdir(cache);
PASS();
}
TEST(tool_manage_adr_get_accepts_abs_path) {
char tmp_dir[256];
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-abspath-XXXXXX");
if (!cbm_mkdtemp(tmp_dir)) {
PASS();
}
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-adr-abspath-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
cbm_rmdir(tmp_dir);
PASS();
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
char src_path[512];
snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir);
FILE *fp = fopen(src_path, "w");
ASSERT_NOT_NULL(fp);
fputs("def main():\n return 'ok'\n", fp);
fclose(fp);
char *project = cbm_project_name_from_path(tmp_dir);
ASSERT_NOT_NULL(project);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char args[1024];
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", tmp_dir);
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(resp);
ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\""));
free(resp);
char update_args[2048];
snprintf(update_args, sizeof(update_args),
"{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n"
"Abs-path normalization test.\\n\"}",
project);
resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "updated"));
free(resp);
char get_args[512];
snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", tmp_dir);
resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "Abs-path normalization test."));
ASSERT_NULL(strstr(resp, "or not indexed"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_project_db(cache, project);
restore_cache_dir(saved_copy);
free(saved_copy);
free(project);
remove(src_path);
cbm_rmdir(cache);
cbm_rmdir(tmp_dir);
PASS();
}
TEST(tool_manage_adr_get_accepts_symlink_path) {
#ifdef _WIN32
PASS();
#else
char tmp_dir[256];
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-realpath-XXXXXX");
if (!cbm_mkdtemp(tmp_dir)) {
PASS();
}
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-adr-realpath-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
cbm_rmdir(tmp_dir);
PASS();
}
char link_path[320];
snprintf(link_path, sizeof(link_path), "%s-link", tmp_dir);
(void)unlink(link_path);
if (symlink(tmp_dir, link_path) != 0) {
cbm_rmdir(cache);
cbm_rmdir(tmp_dir);
PASS();
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
char src_path[512];
snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir);
FILE *fp = fopen(src_path, "w");
ASSERT_NOT_NULL(fp);
fputs("def main():\n return 'ok'\n", fp);
fclose(fp);
char *project = cbm_project_name_from_path(tmp_dir);
ASSERT_NOT_NULL(project);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char args[1024];
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", link_path);
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(resp);
ASSERT(response_contains_json_fragment(resp, "\"status\":\"indexed\""));
ASSERT_NOT_NULL(strstr(resp, project));
free(resp);
char update_args[2048];
snprintf(update_args, sizeof(update_args),
"{\"project\":\"%s\",\"mode\":\"update\",\"content\":\"## PURPOSE\\n"
"Symlink-path normalization test.\\n\"}",
project);
resp = cbm_mcp_handle_tool(srv, "manage_adr", update_args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "updated"));
free(resp);
char get_args[512];
snprintf(get_args, sizeof(get_args), "{\"project\":\"%s\",\"mode\":\"get\"}", link_path);
resp = cbm_mcp_handle_tool(srv, "manage_adr", get_args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "Symlink-path normalization test."));
ASSERT_NULL(strstr(resp, "or not indexed"));
ASSERT_NULL(strstr(resp, "no_adr"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_project_db(cache, project);
restore_cache_dir(saved_copy);
free(saved_copy);
free(project);
remove(src_path);
unlink(link_path);
cbm_rmdir(cache);
cbm_rmdir(tmp_dir);
PASS();
#endif
}
TEST(tool_detect_changes_not_found_rich_error) {
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-detect-missing-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
PASS();
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *resp =
cbm_mcp_handle_tool(srv, "detect_changes", "{\"project\":\"cbm-no-such-project-zzz\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "or not indexed"));
ASSERT_NOT_NULL(strstr(resp, "hint"));
free(resp);
cbm_mcp_server_free(srv);
restore_cache_dir(saved_copy);
free(saved_copy);
cbm_rmdir(cache);
PASS();
}
TEST(tool_ingest_traces_basic) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":37,\"method\":\"tools/call\","
"\"params\":{\"name\":\"ingest_traces\","
"\"arguments\":{\"traces\":[{\"caller\":\"a\",\"callee\":\"b\"}]}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "accepted"));
ASSERT_NOT_NULL(strstr(resp, "traces_received"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_ingest_traces_empty) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":38,\"method\":\"tools/call\","
"\"params\":{\"name\":\"ingest_traces\","
"\"arguments\":{\"traces\":[]}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "accepted"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* IDLE STORE EVICTION
* ══════════════════════════════════════════════════════════════════ */
TEST(store_idle_eviction) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_mcp_server_set_project(srv, "test-evict");
/* Trigger resolve_store via a tool call to set store_last_used */
char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}");
free(resp);
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
/* Evict with 0s timeout → should evict immediately */
cbm_mcp_server_evict_idle(srv, 0);
ASSERT_FALSE(cbm_mcp_server_has_cached_store(srv));
cbm_mcp_server_free(srv);
PASS();
}
TEST(store_idle_no_eviction_within_timeout) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_mcp_server_set_project(srv, "test-evict");
char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}");
free(resp);
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
/* Evict with large timeout → should NOT evict */
cbm_mcp_server_evict_idle(srv, 99999);
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
cbm_mcp_server_free(srv);
PASS();
}
TEST(store_idle_evict_protects_initial_store) {
/* Evicting with NULL server should not crash */
cbm_mcp_server_evict_idle(NULL, 0);
/* Evicting server whose store was never accessed via a named project
* should NOT evict the initial in-memory store (store_last_used == 0). */
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
cbm_mcp_server_evict_idle(srv, 0);
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
cbm_mcp_server_free(srv);
PASS();
}
TEST(store_idle_evict_access_resets_timer) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_mcp_server_set_project(srv, "test-evict");
/* First access */
char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}");
free(resp);
/* Second access (resets timer) */
resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}");
free(resp);
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
/* With large timeout, store should survive */
cbm_mcp_server_evict_idle(srv, 99999);
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
/* With 0 timeout, store should be evicted */
cbm_mcp_server_evict_idle(srv, 0);
ASSERT_FALSE(cbm_mcp_server_has_cached_store(srv));
cbm_mcp_server_free(srv);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* URI HELPERS
* ══════════════════════════════════════════════════════════════════ */
TEST(parse_file_uri_unix) {
char path[256];
ASSERT_TRUE(cbm_parse_file_uri("file:///home/user/project", path, sizeof(path)));
ASSERT_STR_EQ(path, "/home/user/project");
ASSERT_TRUE(cbm_parse_file_uri("file:///tmp/test", path, sizeof(path)));
ASSERT_STR_EQ(path, "/tmp/test");
ASSERT_TRUE(cbm_parse_file_uri("file:///", path, sizeof(path)));
ASSERT_STR_EQ(path, "/");
PASS();
}
TEST(parse_file_uri_windows) {
char path[256];
/* Windows drive letter — leading / stripped */
ASSERT_TRUE(cbm_parse_file_uri("file:///C:/Users/project", path, sizeof(path)));
ASSERT_STR_EQ(path, "C:/Users/project");
ASSERT_TRUE(cbm_parse_file_uri("file:///D:/Projects/myapp", path, sizeof(path)));
ASSERT_STR_EQ(path, "D:/Projects/myapp");
PASS();
}
TEST(parse_file_uri_invalid) {
char path[256];
/* Non-file URI */
ASSERT_FALSE(cbm_parse_file_uri("https://example.com", path, sizeof(path)));
ASSERT_STR_EQ(path, "");
/* Empty string */
ASSERT_FALSE(cbm_parse_file_uri("", path, sizeof(path)));
ASSERT_STR_EQ(path, "");
/* NULL */
ASSERT_FALSE(cbm_parse_file_uri(NULL, path, sizeof(path)));
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SNIPPET TESTS — Port of internal/tools/snippet_test.go
* ══════════════════════════════════════════════════════════════════ */
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
/* Create an MCP server pre-populated with nodes/edges matching Go testSnippetServer.
* Writes a source file to tmp_dir/project/main.go.
* Caller must free the server with cbm_mcp_server_free and
* unlink the source file + rmdir manually. */
static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz) {
/* Create temp dir */
snprintf(tmp_dir, tmp_sz, "/tmp/cbm_snippet_test_XXXXXX");
if (!cbm_mkdtemp(tmp_dir))
return NULL;
char proj_dir[512];
snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp_dir);
cbm_mkdir(proj_dir);
/* Write sample source file */
char src_path[512];
snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir);
FILE *fp = fopen(src_path, "w");
if (!fp)
return NULL;
fprintf(fp, "package main\n"
"\n"
"func HandleRequest() error {\n"
"\treturn nil\n"
"}\n"
"\n"
"func ProcessOrder(id int) {\n"
"\t// process\n"
"}\n"
"\n"
"func Run() {\n"
"\t// server\n"
"}\n");
fclose(fp);
/* Create server with in-memory store */
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (!srv)
return NULL;
cbm_store_t *st = cbm_mcp_server_store(srv);
if (!st) {
cbm_mcp_server_free(srv);
return NULL;
}
const char *proj_name = "test-project";
cbm_mcp_server_set_project(srv, proj_name);
cbm_store_upsert_project(st, proj_name, proj_dir);
/* Create nodes */
cbm_node_t n_hr = {0};
n_hr.project = proj_name;
n_hr.label = "Function";
n_hr.name = "HandleRequest";
n_hr.qualified_name = "test-project.cmd.server.main.HandleRequest";
n_hr.file_path = "main.go";
n_hr.start_line = 3;
n_hr.end_line = 5;
n_hr.properties_json = "{\"signature\":\"func HandleRequest() error\","
"\"return_type\":\"error\","
"\"is_exported\":true}";
int64_t id_hr = cbm_store_upsert_node(st, &n_hr);
cbm_node_t n_po = {0};
n_po.project = proj_name;
n_po.label = "Function";
n_po.name = "ProcessOrder";
n_po.qualified_name = "test-project.cmd.server.main.ProcessOrder";
n_po.file_path = "main.go";
n_po.start_line = 7;
n_po.end_line = 9;
n_po.properties_json = "{\"signature\":\"func ProcessOrder(id int)\"}";
int64_t id_po = cbm_store_upsert_node(st, &n_po);
cbm_node_t n_run1 = {0};
n_run1.project = proj_name;
n_run1.label = "Function";
n_run1.name = "Run";
n_run1.qualified_name = "test-project.cmd.server.Run";
n_run1.file_path = "main.go";
n_run1.start_line = 11;
n_run1.end_line = 13;
int64_t id_run1 = cbm_store_upsert_node(st, &n_run1);
cbm_node_t n_run2 = {0};
n_run2.project = proj_name;
n_run2.label = "Function";
n_run2.name = "Run";
n_run2.qualified_name = "test-project.cmd.worker.Run";
n_run2.file_path = "main.go";
n_run2.start_line = 11;
n_run2.end_line = 13;
cbm_store_upsert_node(st, &n_run2);
/* Create edges: HandleRequest -> ProcessOrder, HandleRequest -> Run1 */
cbm_edge_t e1 = {.project = proj_name, .source_id = id_hr, .target_id = id_po, .type = "CALLS"};
cbm_store_insert_edge(st, &e1);
cbm_edge_t e2 = {
.project = proj_name, .source_id = id_hr, .target_id = id_run1, .type = "CALLS"};
cbm_store_insert_edge(st, &e2);
(void)id_run1; /* run1 used for edge above */
return srv;
}
/* Cleanup temp files created by setup_snippet_server */
static void cleanup_snippet_dir(const char *tmp_dir) {
char path[512];
snprintf(path, sizeof(path), "%s/project/main.go", tmp_dir);
unlink(path);
snprintf(path, sizeof(path), "%s/project", tmp_dir);
rmdir(path);
rmdir(tmp_dir);
}
/* Extract the inner "text" value from an MCP tool result JSON.
* The MCP envelope is: {"content":[{"type":"text","text":"<inner json>"}]}
* This returns the unescaped inner JSON. Caller must free. */
static char *extract_text_content(const char *mcp_result) {
if (!mcp_result)
return NULL;
yyjson_doc *doc = yyjson_read(mcp_result, strlen(mcp_result), 0);
if (!doc)
return strdup(mcp_result); /* fallback */
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *content = yyjson_obj_get(root, "content");
if (!content) {
/* Handle JSON-RPC wrapper: {"jsonrpc":...,"result":{"content":[...]}} */
yyjson_val *rpc_result = yyjson_obj_get(root, "result");
if (rpc_result) {
content = yyjson_obj_get(rpc_result, "content");
}
}
if (!content || !yyjson_is_arr(content)) {
yyjson_doc_free(doc);
return strdup(mcp_result);
}
yyjson_val *item = yyjson_arr_get(content, 0);
if (!item) {
yyjson_doc_free(doc);
return strdup(mcp_result);
}
yyjson_val *text = yyjson_obj_get(item, "text");
const char *str = yyjson_get_str(text);
char *result = str ? strdup(str) : strdup(mcp_result);
yyjson_doc_free(doc);
return result;
}
/* Call get_code_snippet and extract inner text content.
* Caller must free returned string. */
static char *call_snippet(cbm_mcp_server_t *srv, const char *args_json) {
char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", args_json);
char *text = extract_text_content(raw);
free(raw);
return text;
}
static bool is_valid_json_response(const char *json) {
if (!json) {
return false;
}
yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
if (!doc) {
return false;
}
yyjson_doc_free(doc);
return true;
}
static bool snippet_source_has_replacement(const char *json) {
yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
if (!doc) {
return false;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *source = yyjson_obj_get(root, "source");
const char *source_str = yyjson_get_str(source);
bool found = source_str && strstr(source_str, "\xEF\xBF\xBD");
yyjson_doc_free(doc);
return found;
}
/* ── TestSnippet_ExactQN ──────────────────────────────────────── */
TEST(snippet_exact_qn) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *resp =
call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
/* Exact match should NOT have match_method */
ASSERT_NULL(strstr(resp, "\"match_method\""));
/* No property-blob spill: the source IS the payload (signature and
* docstring are literally in it); metrics live behind search_graph
* fields=[...]. */
ASSERT_NULL(strstr(resp, "\"signature\""));
ASSERT_NULL(strstr(resp, "\"return_type\""));
/* Caller/callee counts: 0 callers, 2 callees */
ASSERT_NOT_NULL(strstr(resp, "\"callers\":0"));
ASSERT_NOT_NULL(strstr(resp, "\"callees\":2"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_QNSuffix ─────────────────────────────────────── */
TEST(snippet_qn_suffix) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *resp = call_snippet(srv, "{\"qualified_name\":\"main.HandleRequest\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\""));
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_UniqueShortName ──────────────────────────────── */
TEST(snippet_unique_short_name) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "ProcessOrder" is unique — suffix tier matches (QN ends with .ProcessOrder) */
char *resp = call_snippet(srv, "{\"qualified_name\":\"ProcessOrder\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"ProcessOrder\""));
ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\""));
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_NameTier ─────────────────────────────────────── */
TEST(snippet_name_tier) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "HandleRequest" — suffix tier finds it (QN ends with .HandleRequest) */
char *resp = call_snippet(srv, "{\"qualified_name\":\"HandleRequest\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_AmbiguousShortName ───────────────────────────── */
TEST(snippet_ambiguous_short_name) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "Run" matches 2 nodes — should return suggestions */
char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"status\":\"ambiguous\""));
ASSERT_NOT_NULL(strstr(resp, "\"message\""));
ASSERT_NOT_NULL(strstr(resp, "\"suggestions\""));
/* Must NOT have "error" key */
ASSERT_NULL(strstr(resp, "\"error\""));
/* Must NOT have "source" */
ASSERT_NULL(strstr(resp, "\"source\""));
/* Should have at least 2 suggestions with qualified_name */
ASSERT_NOT_NULL(strstr(resp, "test-project.cmd.server.Run"));
ASSERT_NOT_NULL(strstr(resp, "test-project.cmd.worker.Run"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_NotFound ─────────────────────────────────────── */
TEST(snippet_not_found) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *resp = call_snippet(srv, "{\"qualified_name\":\"CompletelyNonexistentFunctionXYZ123\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
/* Should return error or suggestions */
ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "suggestions"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_FuzzySuggestions ─────────────────────────────── */
TEST(snippet_fuzzy_suggestions) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "Handle" is not an exact QN or suffix — should get not-found guidance */
char *resp = call_snippet(srv, "{\"qualified_name\":\"Handle\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
/* Should guide user to search_graph */
ASSERT_NOT_NULL(strstr(resp, "search_graph"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_EnrichedProperties ───────────────────────────── */
TEST(snippet_enriched_properties) {
/* GUARD (inverted since the compact-output change): the snippet response
* carries the verbatim source plus location/degree/coverage metadata and
* NOTHING from the node's property blob — no signature/return_type/
* is_exported duplication, and never the fp/sp/bt similarity internals
* (41% of the legacy response). */
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *resp =
call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
ASSERT_NULL(strstr(resp, "\"signature\""));
ASSERT_NULL(strstr(resp, "\"return_type\""));
ASSERT_NULL(strstr(resp, "\"is_exported\""));
ASSERT_NULL(strstr(resp, "\"fp\""));
ASSERT_NULL(strstr(resp, "\"bt\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_FuzzyLastSegment ─────────────────────────────── */
TEST(snippet_fuzzy_last_segment) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "auth.handlers.HandleRequest" — suffix match should find HandleRequest */
char *resp = call_snippet(srv, "{\"qualified_name\":\"auth.handlers.HandleRequest\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
/* Should either find it via suffix or guide to search_graph */
ASSERT_TRUE(strstr(resp, "HandleRequest") != NULL || strstr(resp, "search_graph") != NULL);
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_AutoResolve_Default ──────────────────────────── */
TEST(snippet_auto_resolve_default) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "Run" is ambiguous (2 candidates). Without auto_resolve → suggestions */
char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"status\":\"ambiguous\""));
ASSERT_NULL(strstr(resp, "\"source\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_AutoResolve_Enabled ──────────────────────────── */
TEST(snippet_auto_resolve_enabled) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "Run" — suffix match should find candidates or guide to search */
char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
/* "Run" matches multiple nodes via suffix → should get suggestions or source */
ASSERT_TRUE(strstr(resp, "Run") != NULL);
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_IncludeNeighbors_Default ─────────────────────── */
TEST(snippet_include_neighbors_default) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *resp =
call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
"\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
/* Without include_neighbors → NO caller_names/callee_names */
ASSERT_NULL(strstr(resp, "\"caller_names\""));
ASSERT_NULL(strstr(resp, "\"callee_names\""));
/* But should still have counts */
ASSERT_NOT_NULL(strstr(resp, "\"callers\""));
ASSERT_NOT_NULL(strstr(resp, "\"callees\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_IncludeNeighbors_Enabled ─────────────────────── */
TEST(snippet_include_neighbors_enabled) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *resp =
call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
"\"include_neighbors\":true,\"project\":\"test-project\"}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
/* HandleRequest has 0 callers → no caller_names array */
ASSERT_NULL(strstr(resp, "\"caller_names\""));
/* HandleRequest has 2 callees: ProcessOrder and Run */
ASSERT_NOT_NULL(strstr(resp, "\"callee_names\""));
ASSERT_NOT_NULL(strstr(resp, "ProcessOrder"));
ASSERT_NOT_NULL(strstr(resp, "Run"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── TestSnippet_SourceInvalidUtf8 ────────────────────────────── */
TEST(snippet_source_invalid_utf8) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char src_path[512];
snprintf(src_path, sizeof(src_path), "%s/project/main.go", tmp);
FILE *fp = fopen(src_path, "wb");
ASSERT_NOT_NULL(fp);
const unsigned char source[] = {
'p', 'a', 'c', 'k', 'a', 'g', 'e', ' ', 'm', 'a', 'i', 'n', '\n', '\n',
'f', 'u', 'n', 'c', ' ', 'H', 'a', 'n', 'd', 'l', 'e', 'R', 'e', 'q',
'u', 'e', 's', 't', '(', ')', ' ', 'e', 'r', 'r', 'o', 'r', ' ', '{',
'\n', '\t', '/', '/', ' ', 0xC0, 0xD4, 0xB7, 0xC2, '\n', '\t', 'r', 'e', 't',
'u', 'r', 'n', ' ', 'n', 'i', 'l', '\n', '}', '\n'};
ASSERT_EQ(fwrite(source, 1, sizeof(source), fp), sizeof(source));
ASSERT_EQ(fclose(fp), 0);
char *raw =
cbm_mcp_handle_tool(srv, "get_code_snippet",
"{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
"\"project\":\"test-project\"}");
ASSERT_TRUE(is_valid_json_response(raw));
char *resp = extract_text_content(raw);
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(is_valid_json_response(resp));
ASSERT_NULL(strstr(resp, "\xC0\xD4"));
ASSERT_NOT_NULL(strstr(resp, "HandleRequest"));
ASSERT_NOT_NULL(strstr(resp, "return nil"));
ASSERT_TRUE(snippet_source_has_replacement(resp));
free(resp);
free(raw);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* JSON-RPC PARSING — EDGE CASES
* ══════════════════════════════════════════════════════════════════ */
TEST(jsonrpc_parse_empty_string) {
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse("", &req);
ASSERT_EQ(rc, -1);
cbm_jsonrpc_request_free(&req);
PASS();
}
TEST(jsonrpc_parse_missing_jsonrpc_field) {
/* jsonrpc field absent — parser defaults to "2.0" if method present */
const char *line = "{\"id\":1,\"method\":\"initialize\",\"params\":{}}";
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse(line, &req);
ASSERT_EQ(rc, 0);
ASSERT_STR_EQ(req.jsonrpc, "2.0");
ASSERT_STR_EQ(req.method, "initialize");
ASSERT_TRUE(req.has_id);
cbm_jsonrpc_request_free(&req);
PASS();
}
TEST(jsonrpc_parse_missing_method) {
/* method is required — should fail */
const char *line = "{\"jsonrpc\":\"2.0\",\"id\":1,\"params\":{}}";
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse(line, &req);
ASSERT_EQ(rc, -1);
cbm_jsonrpc_request_free(&req);
PASS();
}
TEST(jsonrpc_parse_string_id) {
/* JSON-RPC §4: string and numeric ids are distinct. A string id is
* preserved verbatim (issue #253), never coerced to a number. */
const char *line = "{\"jsonrpc\":\"2.0\",\"id\":\"99\",\"method\":\"tools/list\"}";
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse(line, &req);
ASSERT_EQ(rc, 0);
ASSERT_TRUE(req.has_id);
ASSERT_NOT_NULL(req.id_str);
ASSERT_STR_EQ(req.id_str, "99");
ASSERT_STR_EQ(req.method, "tools/list");
cbm_jsonrpc_request_free(&req);
PASS();
}
TEST(jsonrpc_parse_no_params) {
/* Request with no params field — params_raw should be NULL */
const char *line = "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/list\"}";
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse(line, &req);
ASSERT_EQ(rc, 0);
ASSERT_NULL(req.params_raw);
ASSERT_EQ(req.id, 5);
cbm_jsonrpc_request_free(&req);
PASS();
}
TEST(jsonrpc_parse_extra_whitespace) {
/* Leading/trailing whitespace and internal spacing in JSON */
const char *line = " { \"jsonrpc\" : \"2.0\" , \"id\" : 7 , \"method\" : \"ping\" } ";
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse(line, &req);
ASSERT_EQ(rc, 0);
ASSERT_EQ(req.id, 7);
ASSERT_STR_EQ(req.method, "ping");
cbm_jsonrpc_request_free(&req);
PASS();
}
TEST(jsonrpc_parse_array_not_object) {
/* JSON array at root — not a valid JSON-RPC request */
cbm_jsonrpc_request_t req = {0};
int rc = cbm_jsonrpc_parse("[1,2,3]", &req);
ASSERT_EQ(rc, -1);
cbm_jsonrpc_request_free(&req);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* ARGUMENT EXTRACTION — EDGE CASES
* ══════════════════════════════════════════════════════════════════ */
TEST(mcp_get_string_arg_empty_json) {
/* Empty JSON string — yyjson_read fails → NULL */
char *val = cbm_mcp_get_string_arg("", "key");
ASSERT_NULL(val);
PASS();
}
TEST(mcp_get_string_arg_empty_object) {
/* Valid JSON with no keys → NULL for any key */
char *val = cbm_mcp_get_string_arg("{}", "key");
ASSERT_NULL(val);
PASS();
}
TEST(mcp_get_string_arg_nested_value) {
/* Value is an object, not a string → should return NULL */
const char *args = "{\"config\":{\"nested\":true},\"name\":\"hello\"}";
char *val = cbm_mcp_get_string_arg(args, "config");
ASSERT_NULL(val); /* not a string type */
val = cbm_mcp_get_string_arg(args, "name");
ASSERT_NOT_NULL(val);
ASSERT_STR_EQ(val, "hello");
free(val);
PASS();
}
TEST(mcp_get_string_arg_int_value) {
/* Value is an integer, not a string → NULL */
char *val = cbm_mcp_get_string_arg("{\"count\":42}", "count");
ASSERT_NULL(val);
PASS();
}
TEST(mcp_get_int_arg_empty_json) {
int val = cbm_mcp_get_int_arg("", "key", 99);
ASSERT_EQ(val, 99);
PASS();
}
TEST(mcp_get_int_arg_string_value) {
/* Value is a string, not int → should return default */
int val = cbm_mcp_get_int_arg("{\"limit\":\"ten\"}", "limit", 5);
ASSERT_EQ(val, 5);
PASS();
}
TEST(mcp_get_int_arg_bool_value) {
/* Value is a bool, not int → default */
int val = cbm_mcp_get_int_arg("{\"flag\":true}", "flag", -1);
ASSERT_EQ(val, -1);
PASS();
}
TEST(mcp_get_bool_arg_empty_json) {
bool val = cbm_mcp_get_bool_arg("", "key");
ASSERT_FALSE(val);
PASS();
}
TEST(mcp_get_bool_arg_int_value) {
/* Value is int 1, not bool → should return false */
bool val = cbm_mcp_get_bool_arg("{\"flag\":1}", "flag");
ASSERT_FALSE(val);
PASS();
}
TEST(mcp_get_tool_name_empty_json) {
char *name = cbm_mcp_get_tool_name("");
ASSERT_NULL(name);
PASS();
}
TEST(mcp_get_tool_name_missing_name) {
char *name = cbm_mcp_get_tool_name("{\"arguments\":{}}");
ASSERT_NULL(name);
PASS();
}
TEST(mcp_get_arguments_empty_json) {
char *args = cbm_mcp_get_arguments("");
ASSERT_NULL(args);
PASS();
}
TEST(mcp_get_arguments_no_arguments_key) {
/* No "arguments" key → returns "{}" */
char *args = cbm_mcp_get_arguments("{\"name\":\"tool\"}");
ASSERT_NOT_NULL(args);
ASSERT_STR_EQ(args, "{}");
free(args);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* FILE URI PARSING — EDGE CASES
* ══════════════════════════════════════════════════════════════════ */
TEST(parse_file_uri_http_scheme) {
char path[256];
ASSERT_FALSE(cbm_parse_file_uri("http://example.com/path", path, sizeof(path)));
ASSERT_STR_EQ(path, "");
PASS();
}
TEST(parse_file_uri_ftp_scheme) {
char path[256];
ASSERT_FALSE(cbm_parse_file_uri("ftp://server/file.txt", path, sizeof(path)));
ASSERT_STR_EQ(path, "");
PASS();
}
TEST(parse_file_uri_buffer_too_small) {
char path[5]; /* only 5 bytes — path gets truncated */
ASSERT_TRUE(cbm_parse_file_uri("file:///usr/local/bin", path, sizeof(path)));
/* snprintf truncates to 4 chars + NUL */
ASSERT_EQ(strlen(path), 4);
ASSERT_STR_EQ(path, "/usr");
PASS();
}
TEST(parse_file_uri_spaces_in_path) {
char path[256];
ASSERT_TRUE(cbm_parse_file_uri("file:///home/user/my%20project", path, sizeof(path)));
/* Raw percent-encoding is preserved (not decoded) */
ASSERT_STR_EQ(path, "/home/user/my%20project");
PASS();
}
TEST(parse_file_uri_null_out_path) {
/* NULL out_path — should not crash */
ASSERT_FALSE(cbm_parse_file_uri("file:///tmp", NULL, 256));
PASS();
}
TEST(parse_file_uri_zero_size) {
char path[256] = "garbage";
/* out_size=0 → should fail safely */
ASSERT_FALSE(cbm_parse_file_uri("file:///tmp", path, 0));
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SERVER HANDLE — EDGE CASES
* ══════════════════════════════════════════════════════════════════ */
TEST(server_handle_invalid_json) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp = cbm_mcp_server_handle(srv, "this is not json at all");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"error\""));
ASSERT_NOT_NULL(strstr(resp, "-32700")); /* Parse error */
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(server_handle_empty_object) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
/* Valid JSON but no method field → parse error */
char *resp = cbm_mcp_server_handle(srv, "{}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"error\""));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(server_handle_tools_call_missing_name) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
/* tools/call with no tool name in params */
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\","
"\"params\":{\"arguments\":{}}}");
ASSERT_NOT_NULL(resp);
/* Should return error about unknown/missing tool */
ASSERT_NOT_NULL(strstr(resp, "\"id\":50"));
ASSERT_TRUE(strstr(resp, "error") || strstr(resp, "isError") || strstr(resp, "unknown"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* POLL/GETLINE FILE* BUFFERING FIX
* ══════════════════════════════════════════════════════════════════ */
#ifndef _WIN32
#include <unistd.h>
#include <signal.h>
/* Signal handler used by alarm() to abort the test if it hangs */
static void alarm_handler(int sig) {
(void)sig;
/* Writing to stderr is async-signal-safe */
const char msg[] = "FAIL: mcp_server_run_rapid_messages timed out (>5s)\n";
write(STDERR_FILENO, msg, sizeof(msg) - 1);
_exit(1);
}
TEST(mcp_server_run_rapid_messages) {
/* Simulate a client sending initialize + notifications/initialized +
* tools/list all at once (no delays), which exercises the FILE*
* buffering fix: the first getline() over-reads kernel data into the
* libc buffer; without the fix, subsequent poll() calls block for 60s.
*
* We use alarm(5) to abort the test process if the server hangs. */
int fds[2];
ASSERT_EQ(pipe(fds), 0);
/* Write all 3 messages to the write end in one shot */
const char *msgs = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
"\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}\n"
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n"
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n";
ssize_t written = write(fds[1], msgs, strlen(msgs));
ASSERT_TRUE(written > 0);
close(fds[1]); /* EOF signals end of input to the server */
FILE *in_fp = fdopen(fds[0], "r");
ASSERT_NOT_NULL(in_fp);
FILE *out_fp = tmpfile();
ASSERT_NOT_NULL(out_fp);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
/* Install alarm to fail the test if cbm_mcp_server_run blocks */
signal(SIGALRM, alarm_handler);
alarm(5);
int rc = cbm_mcp_server_run(srv, in_fp, out_fp);
alarm(0); /* cancel alarm */
signal(SIGALRM, SIG_DFL);
ASSERT_EQ(rc, 0);
/* Verify both responses are present:
* id:1 — initialize response
* id:2 — tools/list response (notifications/initialized produces none)
* and that the tools list payload is included. */
rewind(out_fp);
char buf[4096] = {0};
size_t nread = fread(buf, 1, sizeof(buf) - 1, out_fp);
ASSERT_TRUE(nread > 0);
ASSERT_NOT_NULL(strstr(buf, "\"id\":1"));
ASSERT_NOT_NULL(strstr(buf, "\"id\":2"));
ASSERT_NOT_NULL(strstr(buf, "tools"));
cbm_mcp_server_free(srv);
fclose(out_fp);
/* in_fp already EOF; fclose cleans up */
fclose(in_fp);
PASS();
}
#endif /* !_WIN32 */
/* Issue #235: passing an unrecognised project name to a tool crashed the
* binary with a buffer overflow while building the "available_projects"
* error list — collect_db_project_names overflowed projects[CBM_SZ_4K] via
* an unsigned underflow on (out_sz - offset) once the listed names exceeded
* the buffer. Fill a temp cache dir with enough long-named .db files to
* exceed 4 KB, then hit the bad-project path. Under ASan a regression aborts
* here; the fixed bounds-check keeps it clean and returns a normal error. */
#define ISSUE235_DBNAME(buf, dir, i) \
snprintf((buf), sizeof(buf), \
"%s/proj_%02d_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.db", \
(dir), (i))
TEST(tool_bad_project_name_no_overflow_issue235) {
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-badproj-XXXXXX");
if (!cbm_mkdtemp(cache)) {
PASS(); /* skip if mkdtemp fails */
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
/* 40 * ~120-char names overflows the 4 KB available-projects buffer.
* collect_db_project_names advertises each db's INTERNAL project name
* (#704), so the fixture must hold valid dbs with long internal names —
* not stub files — for the bounds-check path to actually be exercised. */
enum { ISSUE235_N = 40 };
for (int i = 0; i < ISSUE235_N; i++) {
char name[512];
ISSUE235_DBNAME(name, cache, i);
char iname[256];
snprintf(iname, sizeof(iname),
"proj_%02d_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
i);
cbm_store_t *st = cbm_store_open_path(name);
if (st) {
cbm_store_upsert_project(st, iname, cache);
cbm_store_close(st);
}
}
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":"
"\"search_graph\",\"arguments\":{\"label\":\"Function\","
"\"project\":\"definitely-not-a-real-project-xyz\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "not found"));
free(resp);
cbm_mcp_server_free(srv);
if (saved_copy) {
cbm_setenv("CBM_CACHE_DIR", saved_copy, 1);
free(saved_copy);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
for (int i = 0; i < ISSUE235_N; i++) {
char name[512];
ISSUE235_DBNAME(name, cache, i);
cbm_unlink(name);
char side[540];
snprintf(side, sizeof(side), "%s-wal", name);
cbm_unlink(side);
snprintf(side, sizeof(side), "%s-shm", name);
cbm_unlink(side);
}
cbm_rmdir(cache);
PASS();
}
#undef ISSUE235_DBNAME
/* Issue #235 (follow-up): with many long-named projects indexed,
* collect_db_project_names overflowed projects[CBM_SZ_4K] and truncated the
* LAST name MID-TOKEN, then clamped offset to out_sz-1 — emitting malformed,
* unterminated JSON like
* ...,"available_projects":["a",...,"vjson_49_bbb],"count":50}
* (unclosed string + unclosed array). build_project_list_error wrapped that
* invalid body into the tool error, so a "project not found" reply was NOT
* valid JSON once enough projects were indexed.
*
* Reproduce-first: fill an isolated cache dir with enough long INTERNAL-named
* dbs to overflow the 4 KB buffer, hit the bad-project path, then assert the
* ERROR BODY (the inner MCP text content) parses as valid JSON and that
* available_projects is a JSON array whose length == count. RED on the
* truncating code (yyjson_read returns NULL on the mid-token cut); GREEN after
* the element-boundary fix, which only ever writes whole "name" tokens. */
#define BADPROJ_JSON_DBNAME(buf, dir, i) \
snprintf((buf), sizeof(buf), \
"%s/vjson_%02d_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.db", \
(dir), (i))
TEST(tool_bad_project_error_valid_json_issue235) {
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-badproj-vjson-XXXXXX");
if (!cbm_mkdtemp(cache)) {
PASS(); /* skip if mkdtemp fails */
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
/* 50 * ~120-char INTERNAL names >> 4 KB → the available_projects buffer
* overflows and the last name is cut mid-token on the unfixed code. */
enum { BADPROJ_N = 50 };
for (int i = 0; i < BADPROJ_N; i++) {
char name[512];
BADPROJ_JSON_DBNAME(name, cache, i);
char iname[256];
snprintf(iname, sizeof(iname),
"vjson_%02d_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
i);
cbm_store_t *st = cbm_store_open_path(name);
if (st) {
cbm_store_upsert_project(st, iname, cache);
cbm_store_close(st);
}
}
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":"
"\"search_graph\",\"arguments\":{\"label\":\"Function\","
"\"project\":\"definitely-not-a-real-project-xyz\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "not found"));
/* The inner MCP text content is the error body built by
* build_project_list_error. Capture its validity BEFORE cleanup so a RED
* failure still restores the environment. */
char *body = extract_text_content(resp);
bool body_valid = false;
bool aps_ok = false; /* available_projects is an array whose len == count */
if (body) {
yyjson_doc *bdoc = yyjson_read(body, strlen(body), 0);
if (bdoc) {
body_valid = true;
yyjson_val *broot = yyjson_doc_get_root(bdoc);
yyjson_val *aps = yyjson_obj_get(broot, "available_projects");
yyjson_val *cnt = yyjson_obj_get(broot, "count");
if (aps && yyjson_is_arr(aps) && cnt && yyjson_is_int(cnt)) {
aps_ok = (yyjson_arr_size(aps) == (size_t)yyjson_get_int(cnt));
}
yyjson_doc_free(bdoc);
}
}
free(body);
free(resp);
cbm_mcp_server_free(srv);
if (saved_copy) {
cbm_setenv("CBM_CACHE_DIR", saved_copy, 1);
free(saved_copy);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
for (int i = 0; i < BADPROJ_N; i++) {
char name[512];
BADPROJ_JSON_DBNAME(name, cache, i);
cbm_unlink(name);
char side[540];
snprintf(side, sizeof(side), "%s-wal", name);
cbm_unlink(side);
snprintf(side, sizeof(side), "%s-shm", name);
cbm_unlink(side);
}
cbm_rmdir(cache);
/* RED on the unfixed code: mid-token truncation → invalid JSON body. */
ASSERT_TRUE(body_valid);
ASSERT_TRUE(aps_ok);
PASS();
}
#undef BADPROJ_JSON_DBNAME
/* ── #704: project resolution must key on the db's INTERNAL project name ──
*
* Issue #704: project resolution is registry-less and filename-addressed.
* resolve_store() opens <cache>/<passed>.db and then requires the internal
* `projects.name` row to equal the passed name; list_projects /
* collect_db_project_names derive the advertised name from the .db FILENAME.
* When a db's filename != its internal name (a legacy '.'-vs-'-' username
* twin, or a copied/renamed file) it shows up in list_projects under the
* filename, but every query returns "project not found" — node rows are
* tagged with the INTERNAL name, so neither the filename nor the resolve
* path lines up. The fix makes list + resolve both key on the INTERNAL name.
*
* Reproduce-first fixture in an isolated CBM_CACHE_DIR:
* - alpha704.db : filename == internal name "alpha704" (control / fast path)
* - gamma704.db : internal name "beta704" (DRIFT: built as
* beta704.db then renamed → filename != internal name)
* - ghost704.db : 0-byte file (ghost / unresolvable)
*
* RED on buggy code / GREEN on the fix:
* A. list_projects advertises "beta704" (internal), NOT "gamma704" (filename),
* and NOT "ghost704" (0-byte filtered).
* B. search_graph(project="beta704") resolves via the cache-dir scan and
* returns the node — not the "project not found" error.
* C. control project "alpha704" still resolves on the fast path.
* D. the 0-byte ghost is not resolvable.
* E. addressing the drifted db by its FILENAME ("gamma704") stays not-found
* (we key on the internal name, never the file on disk).
*/
/* Create a file-backed project db at <dir>/<filename> whose INTERNAL project
* name is `internal` (which may differ from the filename), holding one
* Function node named `fn`. Returns true on success. */
static bool issue704_make_db(const char *dir, const char *filename, const char *internal,
const char *fn) {
char path[700];
snprintf(path, sizeof(path), "%s/%s", dir, filename);
cbm_store_t *st = cbm_store_open_path(path);
if (!st) {
return false;
}
bool ok = (cbm_store_upsert_project(st, internal, dir) == CBM_STORE_OK);
if (ok) {
char qn[256];
snprintf(qn, sizeof(qn), "%s.%s", internal, fn);
cbm_node_t n = {0};
n.project = internal;
n.label = "Function";
n.name = fn;
n.qualified_name = qn;
n.file_path = "main.go";
n.start_line = 1;
n.end_line = 2;
ok = (cbm_store_upsert_node(st, &n) > 0);
}
cbm_store_close(st);
return ok;
}
TEST(tool_resolve_store_by_internal_name_issue704) {
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-issue704-XXXXXX");
if (!cbm_mkdtemp(cache)) {
PASS(); /* skip if mkdtemp fails — not a #704 signal */
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
/* (1) control: filename == internal name */
ASSERT_TRUE(issue704_make_db(cache, "alpha704.db", "alpha704", "alphaFunc704"));
/* (2) DRIFT: build beta704.db (internal "beta704") then rename the file to
* gamma704.db, so filename "gamma704" != internal "beta704". */
ASSERT_TRUE(issue704_make_db(cache, "beta704.db", "beta704", "betaFunc704"));
char beta_path[700];
char gamma_path[700];
snprintf(beta_path, sizeof(beta_path), "%s/beta704.db", cache);
snprintf(gamma_path, sizeof(gamma_path), "%s/gamma704.db", cache);
ASSERT_EQ(rename(beta_path, gamma_path), 0);
/* (3) ghost: 0-byte db file */
char ghost_path[700];
snprintf(ghost_path, sizeof(ghost_path), "%s/ghost704.db", cache);
FILE *gp = fopen(ghost_path, "w");
ASSERT_NOT_NULL(gp);
fclose(gp);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
/* ── A: list_projects reports INTERNAL names; filters the ghost ── */
char *list =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
"\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}");
ASSERT_NOT_NULL(list);
ASSERT_NOT_NULL(strstr(list, "alpha704")); /* control */
ASSERT_NOT_NULL(strstr(list, "beta704")); /* internal name of drifted db (RED before) */
ASSERT_NULL(strstr(list, "gamma704")); /* filename must NOT be advertised (RED before) */
ASSERT_NULL(strstr(list, "ghost704")); /* 0-byte ghost filtered (RED before) */
free(list);
/* ── B: the drifted project resolves by its INTERNAL name ──────── */
char *q_beta = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"project\":\"beta704\",\"name_pattern\":\"betaFunc704\",\"limit\":5}}}");
ASSERT_NOT_NULL(q_beta);
ASSERT_NOT_NULL(strstr(q_beta, "betaFunc704")); /* resolved + returned node (RED before) */
ASSERT_NULL(strstr(q_beta, "not found")); /* not the not-found error */
free(q_beta);
/* ── C: control project still resolves on the fast path ────────── */
char *q_alpha = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"project\":\"alpha704\",\"name_pattern\":\"alphaFunc704\",\"limit\":5}}}");
ASSERT_NOT_NULL(q_alpha);
ASSERT_NOT_NULL(strstr(q_alpha, "alphaFunc704"));
free(q_alpha);
/* ── D: the 0-byte ghost is NOT resolvable ─────────────────────── */
char *q_ghost = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"project\":\"ghost704\",\"name_pattern\":\".*\",\"limit\":5}}}");
ASSERT_NOT_NULL(q_ghost);
ASSERT_NOT_NULL(strstr(q_ghost, "not found"));
free(q_ghost);
/* ── E: addressing the drifted db by its FILENAME stays not-found ── */
char *q_gamma = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
"\"project\":\"gamma704\",\"name_pattern\":\".*\",\"limit\":5}}}");
ASSERT_NOT_NULL(q_gamma);
ASSERT_NOT_NULL(strstr(q_gamma, "not found"));
free(q_gamma);
cbm_mcp_server_free(srv);
/* ── cleanup ───────────────────────────────────────────────────── */
if (saved_copy) {
cbm_setenv("CBM_CACHE_DIR", saved_copy, 1);
free(saved_copy);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
char a_path[700];
snprintf(a_path, sizeof(a_path), "%s/alpha704.db", cache);
char corrupt_path[720];
snprintf(corrupt_path, sizeof(corrupt_path), "%s.corrupt", ghost_path);
cbm_unlink(a_path);
cbm_unlink(gamma_path);
cbm_unlink(ghost_path);
cbm_unlink(corrupt_path); /* ghost may be quarantined by resolve_store */
char side[740];
snprintf(side, sizeof(side), "%s-wal", a_path);
cbm_unlink(side);
snprintf(side, sizeof(side), "%s-shm", a_path);
cbm_unlink(side);
snprintf(side, sizeof(side), "%s-wal", gamma_path);
cbm_unlink(side);
snprintf(side, sizeof(side), "%s-shm", gamma_path);
cbm_unlink(side);
cbm_rmdir(cache);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* QUERY STORE READ-ONLY (data-integrity reproductions)
*
* Bug: query tools resolve the project store via resolve_store() ->
* cbm_store_open_path_query(), which opens the DB SQLITE_OPEN_READWRITE
* and runs configure_pragmas() with the WRITE pragmas
* (journal_mode=WAL + wal_checkpoint + synchronous). Two consequences:
* (a) read-only query tools MUTATE the on-disk DB (write pragmas), and
* (b) query tools FAIL outright on a read-only DB file / filesystem
* (the READWRITE open returns CANTOPEN -> resolve_store NULL ->
* "project not found").
* Both tests below are written reproduce-first and are RED on the
* unfixed code, GREEN once query opens are READONLY with read-only
* pragmas.
* ══════════════════════════════════════════════════════════════════ */
#define ROQ_PROJECT "cbm-roq-test"
/* Whole-file byte snapshot. Returns malloc'd buffer (caller frees) and
* writes the length to *out_len. Returns NULL on failure. */
static unsigned char *roq_read_file_bytes(const char *path, long *out_len) {
*out_len = 0;
FILE *fp = fopen(path, "rb");
if (!fp) {
return NULL;
}
if (fseek(fp, 0, SEEK_END) != 0) {
fclose(fp);
return NULL;
}
long sz = ftell(fp);
if (sz < 0) {
fclose(fp);
return NULL;
}
rewind(fp);
unsigned char *buf = malloc((size_t)sz > 0 ? (size_t)sz : 1);
if (!buf) {
fclose(fp);
return NULL;
}
size_t got = fread(buf, 1, (size_t)sz, fp);
fclose(fp);
if (got != (size_t)sz) {
free(buf);
return NULL;
}
*out_len = sz;
return buf;
}
static int roq_file_exists(const char *path) {
struct stat st;
return (stat(path, &st) == 0) ? 1 : 0;
}
/* ── (a) NO-MUTATION ──────────────────────────────────────────────────
*
* readonly_query_does_not_mutate_db
*
* Create a real project DB, convert it to rollback (DELETE) journal mode
* on disk, snapshot its exact bytes, run search_graph through the server,
* then re-snapshot. The buggy query path runs `PRAGMA journal_mode=WAL`,
* which rewrites the file header (1,1 -> 2,2) and spawns a -wal sidecar —
* so the snapshots differ. The fixed READONLY path runs no write pragma,
* so the file is byte-identical.
*
* The DELETE-mode fixture is what makes the mutation OBSERVABLE: on an
* already-WAL file `journal_mode=WAL` is a silent no-op, so we deliberately
* stage the DB in rollback mode (the same technique repro_issue557 uses to
* plant a deterministic trigger).
*
* WHY RED on unfixed code:
* journal_mode=WAL rewrites the header -> memcmp(before, after) != 0 and
* a -wal file is created while the cached store is open. Both assertions
* that demand "unchanged" fire.
* ─────────────────────────────────────────────────────────────────── */
TEST(readonly_query_does_not_mutate_db) {
char tmp_cache[512];
snprintf(tmp_cache, sizeof(tmp_cache), "%s/cbm_roq_a_XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(tmp_cache)) {
ASSERT_NOT_NULL(NULL); /* setup failure */
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", tmp_cache, 1);
char db_path[700];
snprintf(db_path, sizeof(db_path), "%s/%s.db", tmp_cache, ROQ_PROJECT);
char wal_path[730];
char shm_path[730];
snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path);
snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path);
/* Build the DB and flip it to rollback journal mode on disk. */
cbm_store_t *setup = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(setup);
ASSERT_EQ(cbm_store_upsert_project(setup, ROQ_PROJECT, "/tmp/roq"), CBM_STORE_OK);
cbm_node_t node = {.project = ROQ_PROJECT,
.label = "Function",
.name = "ReadOnlyProbe",
.qualified_name = "roq.mod.ReadOnlyProbe",
.file_path = "mod.c"};
ASSERT_TRUE(cbm_store_upsert_node(setup, &node) > 0);
ASSERT_EQ(cbm_store_exec(setup, "PRAGMA journal_mode=DELETE;"), 0);
cbm_store_close(setup);
/* Snapshot BEFORE any query. */
long before_len = 0;
unsigned char *before = roq_read_file_bytes(db_path, &before_len);
ASSERT_NOT_NULL(before);
/* Run a query tool through the server (the resolve_store path). */
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char args[512];
snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\".*ReadOnlyProbe.*\"}",
ROQ_PROJECT);
char *resp = cbm_mcp_handle_tool(srv, "search_graph", args);
/* Capture sidecar state WHILE the cached store is still open (the buggy
* RW+WAL open creates -wal here; on close it would be removed again). */
int wal_while_open = roq_file_exists(wal_path);
int query_ok = (resp && strstr(resp, "ReadOnlyProbe") != NULL);
int query_failed = (resp && (strstr(resp, "not found") || strstr(resp, "not indexed")));
cbm_mcp_server_free(srv); /* closes the store; header change is persisted */
long after_len = 0;
unsigned char *after = roq_read_file_bytes(db_path, &after_len);
int identical = (before && after && before_len == after_len &&
memcmp(before, after, (size_t)before_len) == 0);
if (resp) {
free(resp);
}
free(before);
free(after);
cbm_unlink(db_path);
cbm_unlink(wal_path);
cbm_unlink(shm_path);
cbm_rmdir(tmp_cache);
if (saved_copy) {
cbm_setenv("CBM_CACHE_DIR", saved_copy, 1);
free(saved_copy);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
ASSERT_TRUE(query_ok); /* read path ran and returned the node */
ASSERT_FALSE(query_failed); /* not the "project not found" path */
ASSERT_TRUE(identical); /* RED on buggy code: WAL pragma rewrote header */
ASSERT_FALSE(wal_while_open); /* RED on buggy code: RW+WAL open spawned -wal */
PASS();
}
/* ── (b) READ-ONLY FILESYSTEM ─────────────────────────────────────────
*
* readonly_query_succeeds_on_readonly_fs
*
* Create a real project DB (left in WAL journal mode, as the indexer
* writes it), then chmod the CONTAINING DIRECTORY to 0555 (read-only) to
* simulate a read-only mount / immutable media, then run search_graph.
*
* Note on why the directory (not just the file) must be read-only: SQLite's
* unix VFS auto-downgrades a failed O_RDWR main-db open to O_RDONLY, so a
* 0444 *file* alone does NOT surface the bug — the connection silently
* becomes read-only and, with a writable dir, still creates the WAL -shm
* and reads. The genuine read-only-FS symptom is the WAL write-pragma
* (journal_mode=WAL) being unable to create the -shm/-wal sidecars in a
* read-only directory.
*
* WHY RED on unfixed code:
* cbm_store_open_path_query() runs configure_pragmas(.., false) which
* executes `PRAGMA journal_mode = WAL`. In a read-only directory the WAL
* wal-index (-shm) cannot be created, so the pragma errors ->
* configure_pragmas fails -> the open returns NULL -> resolve_store()
* returns NULL -> the handler emits "project not found or not indexed".
*
* GREEN on fixed code:
* the READONLY open skips the WAL write-pragma; the plain READONLY open
* of a WAL-mode DB in a read-only dir still needs -shm, so it fails and
* the immutable-URI fallback (file:..?immutable=1) reads the main DB
* file directly and the query returns the node. (This is the test that
* exercises the immutable fallback path.)
* ─────────────────────────────────────────────────────────────────── */
TEST(readonly_query_succeeds_on_readonly_fs) {
char tmp_cache[512];
snprintf(tmp_cache, sizeof(tmp_cache), "%s/cbm_roq_b_XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(tmp_cache)) {
ASSERT_NOT_NULL(NULL); /* setup failure */
}
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", tmp_cache, 1);
char db_path[700];
snprintf(db_path, sizeof(db_path), "%s/%s.db", tmp_cache, ROQ_PROJECT);
char wal_path[730];
char shm_path[730];
snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path);
snprintf(shm_path, sizeof(shm_path), "%s-shm", db_path);
/* Build the DB in its natural WAL journal mode and ensure it is cleanly
* checkpointed (no -wal frames) so the immutable fallback can read all
* data from the main file. */
cbm_store_t *setup = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(setup);
ASSERT_EQ(cbm_store_upsert_project(setup, ROQ_PROJECT, "/tmp/roq"), CBM_STORE_OK);
cbm_node_t node = {.project = ROQ_PROJECT,
.label = "Function",
.name = "ReadOnlyProbe",
.qualified_name = "roq.mod.ReadOnlyProbe",
.file_path = "mod.c"};
ASSERT_TRUE(cbm_store_upsert_node(setup, &node) > 0);
(void)cbm_store_checkpoint(setup); /* fold WAL frames into the main file */
cbm_store_close(setup); /* clean close removes -wal/-shm */
/* Make the containing directory read-only (simulate a read-only mount).
* SQLite can still traverse + read files, but cannot create -shm/-wal. */
ASSERT_EQ(chmod(tmp_cache, 0555), 0);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char args[512];
snprintf(args, sizeof(args), "{\"project\":\"%s\",\"name_pattern\":\".*ReadOnlyProbe.*\"}",
ROQ_PROJECT);
char *resp = cbm_mcp_handle_tool(srv, "search_graph", args);
int query_ok = (resp && strstr(resp, "ReadOnlyProbe") != NULL);
int query_failed = (resp && (strstr(resp, "not found") || strstr(resp, "not indexed")));
if (resp) {
free(resp);
}
cbm_mcp_server_free(srv);
/* Restore write permission on the dir BEFORE unlink (cannot remove dir
* entries while the directory is read-only). */
chmod(tmp_cache, 0755);
cbm_unlink(db_path);
cbm_unlink(wal_path);
cbm_unlink(shm_path);
cbm_rmdir(tmp_cache);
if (saved_copy) {
cbm_setenv("CBM_CACHE_DIR", saved_copy, 1);
free(saved_copy);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
ASSERT_FALSE(query_failed); /* RED on buggy code: WAL pragma fails on RO dir */
ASSERT_TRUE(query_ok); /* RED on buggy code: no node returned */
PASS();
}
#undef ROQ_PROJECT
/* ══════════════════════════════════════════════════════════════════
* #823 — CLI/supervised index_repository must preserve name override
* ══════════════════════════════════════════════════════════════════ */
enum {
IDX823_OK = 0,
IDX823_NO_SERVER = 61,
IDX823_NO_RESULT = 62,
IDX823_NOT_INDEXED = 63,
IDX823_RESPONSE_NAME_MISSING = 64,
IDX823_LIST_NAME_MISSING = 65,
IDX823_SEARCH_FAILED = 66,
};
#ifndef _WIN32 /* helper used only by the POSIX fork harness below */
static int idx823_supervised_name_override_check(const char *repo_dir, const char *custom_name) {
/* Match the real CLI/MCP server state: a marked host with the supervisor
* enabled. The worker receives the same args JSON the CLI forwards. */
cbm_index_supervisor_mark_host();
cbm_unsetenv("CBM_INDEX_SUPERVISOR");
cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1);
cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (!srv) {
return IDX823_NO_SERVER;
}
char args[1024];
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\",\"name\":\"%s\"}",
repo_dir, custom_name);
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
int code = IDX823_OK;
if (!resp) {
code = IDX823_NO_RESULT;
} else if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) {
code = IDX823_NOT_INDEXED;
} else {
char expected[256];
snprintf(expected, sizeof(expected), "\"project\":\"%s\"", custom_name);
if (!response_contains_json_fragment(resp, expected)) {
code = IDX823_RESPONSE_NAME_MISSING;
}
}
free(resp);
if (code == IDX823_OK) {
char *projects = cbm_mcp_handle_tool(srv, "list_projects", "{}");
char expected[256];
snprintf(expected, sizeof(expected), "\"name\":\"%s\"", custom_name);
if (!projects || !response_contains_json_fragment(projects, expected)) {
code = IDX823_LIST_NAME_MISSING;
}
free(projects);
}
if (code == IDX823_OK) {
char q[512];
snprintf(q, sizeof(q),
"{\"project\":\"%s\",\"name_pattern\":\"idx823_fn\",\"label\":\"Function\"}",
custom_name);
char *sr = cbm_mcp_handle_tool(srv, "search_graph", q);
if (!sr || !strstr(sr, "idx823_fn")) {
code = IDX823_SEARCH_FAILED;
}
free(sr);
}
cbm_mcp_server_free(srv);
return code;
}
#endif
TEST(index_repository_cli_name_override_issue823) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX fork harness required to isolate supervisor host mark");
#else
char tmp_dir[256];
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx823-repo-XXXXXX");
if (!cbm_mkdtemp(tmp_dir)) {
FAIL("cbm_mkdtemp repo failed");
}
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-idx823-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
th_rmtree(tmp_dir);
FAIL("cbm_mkdtemp cache failed");
}
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
char src_path[512];
snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir);
ASSERT_EQ(th_write_file(src_path, "def idx823_fn():\n return 823\n"), 0);
const char *custom_name = "issue823-custom-project";
int code = -1;
bool signalled = false;
int sig = 0;
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
alarm(60);
_exit(idx823_supervised_name_override_check(tmp_dir, custom_name));
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
code = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
signalled = true;
sig = WTERMSIG(status);
}
char *path_project = cbm_project_name_from_path(tmp_dir);
cleanup_project_db(cache, custom_name);
cleanup_project_db(cache, path_project);
free(path_project);
restore_cache_dir(saved_cache_copy);
free(saved_cache_copy);
th_rmtree(cache);
th_rmtree(tmp_dir);
if (signalled) {
printf(" child killed by signal %d (alarm => worker hang)\n", sig);
} else if (code != IDX823_OK) {
printf(" child exit code %d (64=response name, 65=list name, 66=search)\n", code);
}
ASSERT_FALSE(signalled);
ASSERT_EQ(code, IDX823_OK);
PASS();
#endif
}
/* ══════════════════════════════════════════════════════════════════
* #845 — supervisor gate must not wrap embedders of cbm_mcp_handle_tool
* ══════════════════════════════════════════════════════════════════ */
/* Child-side check: index a tiny fixture and verify it ran IN-PROCESS.
* Distinct exit codes so the parent can report the exact failure mode. */
enum {
IDX845_OK = 0,
IDX845_SPAWNED = 41, /* a worker subprocess was spawned — the #845 bug */
IDX845_NO_RESULT = 42, /* handle_tool returned NULL */
IDX845_NOT_INDEXED = 43, /* response lacks status=indexed */
};
static int idx845_index_inprocess_check(const char *repo_dir) {
int spawns_before = cbm_index_supervisor_spawn_count();
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (!srv) {
return IDX845_NO_RESULT;
}
char args[1024];
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo_dir);
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
int code = IDX845_OK;
if (cbm_index_supervisor_spawn_count() != spawns_before) {
code = IDX845_SPAWNED;
} else if (!resp) {
code = IDX845_NO_RESULT;
} else if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) {
code = IDX845_NOT_INDEXED;
}
free(resp);
cbm_mcp_server_free(srv);
return code;
}
TEST(index_supervisor_gate_requires_marked_host_issue845) {
/* #845: index_repository via cbm_mcp_handle_tool from an EMBEDDER (this test
* binary) must index IN-PROCESS even with CBM_INDEX_SUPERVISOR unset. The
* supervisor gate may only wrap a process that called
* cbm_index_supervisor_mark_host() — i.e. the real binary's main(). Before
* the fix, should_wrap() was true for ANY embedder: the gate resolved the
* CURRENT binary (this test runner!) and spawned
* '<test-runner> cli --index-worker index_repository …', which a test binary
* interprets as suite-filter args → it re-runs test suites in the child →
* recursive spawn chains (observed 11-min hangs; kernel VM-map load during
* the 2026-07-04 host panics).
*
* POSIX: run the call in a forked child under alarm(20) so the pre-fix
* recursive behaviour cannot hang the runner; the child reports via exit
* code. Windows: no fork — run in-process (safe once the gate is fixed; the
* pre-fix redness is demonstrated on POSIX). */
char tmp_dir[256];
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx845-repo-XXXXXX");
if (!cbm_mkdtemp(tmp_dir)) {
PASS();
}
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-idx845-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
cbm_rmdir(tmp_dir);
PASS();
}
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
/* The point of the guard: NO kill switch. The gate itself must keep an
* unmarked host in-process. Save + restore the ambient value. */
const char *saved_sv = getenv("CBM_INDEX_SUPERVISOR");
char *saved_sv_copy = saved_sv ? strdup(saved_sv) : NULL;
cbm_unsetenv("CBM_INDEX_SUPERVISOR");
char src_path[512];
snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir);
FILE *fp = fopen(src_path, "w");
ASSERT_NOT_NULL(fp);
fputs("def main():\n return 'ok'\n", fp);
fclose(fp);
int code = -1;
bool signalled = false;
int sig = 0;
#ifdef _WIN32
code = idx845_index_inprocess_check(tmp_dir);
#else
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
alarm(20); /* pre-fix spawn chain must die here, not hang the runner */
_exit(idx845_index_inprocess_check(tmp_dir));
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
code = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
signalled = true;
sig = WTERMSIG(status);
}
#endif
/* Restore env BEFORE asserting so a red run doesn't leak state. */
if (saved_sv_copy) {
cbm_setenv("CBM_INDEX_SUPERVISOR", saved_sv_copy, 1);
free(saved_sv_copy);
} else {
cbm_unsetenv("CBM_INDEX_SUPERVISOR");
}
char *project = cbm_project_name_from_path(tmp_dir);
cleanup_project_db(cache, project);
free(project);
restore_cache_dir(saved_cache_copy);
free(saved_cache_copy);
remove(src_path);
cbm_rmdir(cache);
cbm_rmdir(tmp_dir);
if (signalled) {
printf(" child killed by signal %d (alarm => recursive spawn chain hang)\n", sig);
} else if (code != IDX845_OK) {
printf(" child exit code %d (41=worker spawned, 42=no result, 43=not indexed)\n", code);
}
ASSERT_FALSE(signalled);
ASSERT_EQ(code, IDX845_OK);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* #832 — background auto-index + watcher re-index must run in the
* supervised worker SUBPROCESS (RSS isolation)
* ══════════════════════════════════════════════════════════════════ */
/* The long-lived server ran the full index pipeline in-process on two background
* paths (session auto-index in mcp.c, watcher re-index in main.c). Worker-thread
* mimalloc heaps abandon pages at thread exit and mimalloc v3
* (page_reclaim_on_free=0) does not reclaim them when the main thread later frees
* their blocks, so RSS ratchets across re-index cycles (#832). The fix routes both
* paths through cbm_mcp_index_run_supervised_path() — the SAME supervised worker
* subprocess the index_repository tool uses — so the child hands 100%% of its RSS
* back to the OS on exit.
*
* This guard proves the ROUTING: on a supervisor-marked host with the kill switch
* OFF, the shared entry the watcher/auto-index now call must (a) spawn a worker
* child (cbm_index_supervisor_spawn_count() increases) and (b) actually index the
* fixture (the worker child writes the Function node). RED on the unfixed
* in-process routing: it calls cbm_pipeline_run directly, so spawn_count is
* unchanged → IDX832_NO_SPAWN. */
enum {
IDX832_OK = 0,
IDX832_NO_SPAWN = 51, /* spawn_count unchanged — routed in-process (RED) */
IDX832_NULL_RESP = 52, /* supervised entry degraded to NULL */
IDX832_NOT_INDEXED = 53, /* response/store lacks the indexed Function node */
IDX832_SERVER_FAIL = 54,
};
#ifndef _WIN32 /* helper used only by the POSIX fork harness below */
static int idx832_supervised_route_check(const char *repo_dir) {
/* Become a supervisor host with the kill switch OFF — exactly the real MCP
* server's state. Done in the FORKED CHILD only (see the harness) so the
* parent test-runner's process-wide host mark stays clear and the #845
* unmarked-embedder guard is unaffected. Bound the recovery loop + worker
* quiet-timeout so a stuck child cannot run long under the fork+alarm net. */
cbm_index_supervisor_mark_host();
cbm_unsetenv("CBM_INDEX_SUPERVISOR");
cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1);
cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1);
int spawns_before = cbm_index_supervisor_spawn_count();
char *resp = cbm_mcp_index_run_supervised_path(repo_dir);
int spawns_after = cbm_index_supervisor_spawn_count();
if (spawns_after == spawns_before) {
free(resp);
return IDX832_NO_SPAWN; /* the discriminating assertion: RED in-process */
}
if (!resp) {
return IDX832_NULL_RESP;
}
bool indexed = response_contains_json_fragment(resp, "\"status\":\"indexed\"");
free(resp);
if (!indexed) {
return IDX832_NOT_INDEXED;
}
/* Store-level proof the worker child did real work: the Function node it wrote
* must be queryable from a fresh server reading the DB the child produced. */
char *project = cbm_project_name_from_path(repo_dir);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (!srv) {
free(project);
return IDX832_SERVER_FAIL;
}
int code = IDX832_OK;
if (project) {
char q[512];
snprintf(q, sizeof(q),
"{\"project\":\"%s\",\"name_pattern\":\"idx832_fn\",\"label\":\"Function\"}",
project);
char *sr = cbm_mcp_handle_tool(srv, "search_graph", q);
if (!sr || !strstr(sr, "idx832_fn")) {
code = IDX832_NOT_INDEXED;
}
free(sr);
}
cbm_mcp_server_free(srv);
free(project);
return code;
}
#endif /* !_WIN32 */
TEST(index_bg_paths_route_through_supervisor_issue832) {
#ifdef _WIN32
/* The guard marks the process as a supervisor host, which cannot be undone.
* POSIX isolates that in a forked child; without fork we would pollute the
* shared test-runner (breaking the #845 unmarked-embedder guard). The routing
* logic is platform-independent and covered on POSIX CI; Windows containment
* is covered by the end-to-end crash-containment test. */
SKIP_PLATFORM("supervisor-host guard needs fork isolation (POSIX-only)");
#else
char tmp_dir[256];
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx832-repo-XXXXXX");
if (!cbm_mkdtemp(tmp_dir)) {
PASS();
}
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-idx832-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
cbm_rmdir(tmp_dir);
PASS();
}
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1); /* inherited by the worker child */
char src_path[512];
snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir);
FILE *fp = fopen(src_path, "w");
ASSERT_NOT_NULL(fp);
fputs("def idx832_fn():\n return 'ok'\n", fp);
fclose(fp);
int code = -1;
bool signalled = false;
int sig = 0;
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
alarm(60); /* a stuck worker dies here instead of hanging the runner */
_exit(idx832_supervised_route_check(tmp_dir));
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
code = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
signalled = true;
sig = WTERMSIG(status);
}
char *project = cbm_project_name_from_path(tmp_dir);
cleanup_project_db(cache, project);
free(project);
restore_cache_dir(saved_cache_copy);
free(saved_cache_copy);
remove(src_path);
cbm_rmdir(cache);
cbm_rmdir(tmp_dir);
if (signalled) {
printf(" child killed by signal %d (alarm => worker hang)\n", sig);
} else if (code != IDX832_OK) {
printf(" child exit code %d (51=no spawn/in-process=RED, 52=null resp, "
"53=not indexed, 54=server fail)\n",
code);
}
ASSERT_FALSE(signalled);
ASSERT_EQ(code, IDX832_OK);
PASS();
#endif
}
/* ══════════════════════════════════════════════════════════════════
* Parallel-only crash recovery (ms-typescript cascade fix)
* ══════════════════════════════════════════════════════════════════ */
/* The old recovery loop re-ran the worker SINGLE-THREADED to keep one exact
* crash marker. At scale that fell into the sequential crawl, was killed as
* a hang mid-pass, and the stale marker quarantined FOUR innocent
* ms-typescript fixtures, one 15-minute retry at a time. The reworked loop
* re-runs PARALLEL with a marker journal; a file is quarantined only when
* it is in-flight across two consecutive failed runs.
*
* This guard proves the CONTRACT: with an injected crasher among good
* files, the supervised index must (a) never spawn a single-threaded worker
* (cbm_index_supervisor_spawn_st_count stays 0 — RED on the old loop),
* (b) quarantine exactly the crasher, (c) leave the innocents indexed and
* NOT quarantined. */
enum {
IDXPAR_OK = 0,
IDXPAR_ST_SPAWN = 61, /* single-threaded recovery spawn happened (RED) */
IDXPAR_NULL_RESP = 62, /* supervised entry degraded to NULL */
IDXPAR_NOT_INDEXED = 63, /* response lacks status indexed */
IDXPAR_NO_QUARANTINE = 64, /* crasher missing from skipped[] */
IDXPAR_INNOCENT_HIT = 65, /* a good file was quarantined/skipped */
IDXPAR_GOOD_MISSING = 66, /* good file's Function absent from the store */
};
#ifndef _WIN32
static int idxpar_recovery_check(const char *repo_dir) {
cbm_index_supervisor_mark_host();
cbm_unsetenv("CBM_INDEX_SUPERVISOR");
/* Rounds needed: fail+record, fail+quarantine, clean. Generous cap. */
cbm_setenv("CBM_INDEX_MAX_RESTARTS", "5", 1);
cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1);
cbm_setenv("CBM_TEST_CRASH_ON", "idxpar_crasher", 1);
int st_before = cbm_index_supervisor_spawn_st_count();
char *resp = cbm_mcp_index_run_supervised_path(repo_dir);
int st_after = cbm_index_supervisor_spawn_st_count();
cbm_unsetenv("CBM_TEST_CRASH_ON");
if (st_after != st_before) {
free(resp);
return IDXPAR_ST_SPAWN; /* discriminating assertion: RED on the old loop */
}
if (!resp) {
return IDXPAR_NULL_RESP;
}
bool indexed = response_contains_json_fragment(resp, "\"status\":\"indexed\"");
bool crasher_skipped = strstr(resp, "idxpar_crasher.py") != NULL;
bool innocent_hit =
strstr(resp, "idxpar_good_a.py") != NULL || strstr(resp, "idxpar_good_b.py") != NULL;
free(resp);
if (!indexed) {
return IDXPAR_NOT_INDEXED;
}
if (!crasher_skipped) {
return IDXPAR_NO_QUARANTINE;
}
if (innocent_hit) {
return IDXPAR_INNOCENT_HIT;
}
/* Store proof: an innocent's Function node exists. */
char *project = cbm_project_name_from_path(repo_dir);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
int code = IDXPAR_OK;
if (srv && project) {
char q[512];
snprintf(q, sizeof(q),
"{\"project\":\"%s\",\"name_pattern\":\"idxpar_good_fn\",\"label\":\"Function\"}",
project);
char *sr = cbm_mcp_handle_tool(srv, "search_graph", q);
if (!sr || !strstr(sr, "idxpar_good_fn")) {
code = IDXPAR_GOOD_MISSING;
}
free(sr);
}
if (srv) {
cbm_mcp_server_free(srv);
}
free(project);
return code;
}
#endif /* !_WIN32 */
/* #773: SIGABRT (invalid free in ts_stack_delete via
* cbm_destroy_thread_parser) on the SECOND index_repository in one server
* process, once both repos take the PARALLEL path (~30+ files). The
* supervisor masks this on the default MCP path (fresh worker process per
* index); the in-process pipeline — CBM_INDEX_SUPERVISOR=0, and every
* embedded/test consumer — dies. Forked child so the abort cannot kill the
* runner; ASan legs print the exact bad free. */
enum {
IDX773_OK = 0,
IDX773_FIRST_FAILED = 71, /* first index didn't return indexed */
IDX773_SECOND_FAILED = 72, /* second index didn't return indexed */
};
#ifndef _WIN32
static void idx773_write_py_repo(const char *dir, int files, int variant) {
for (int i = 0; i < files; i++) {
char path[CBM_SZ_512];
snprintf(path, sizeof(path), "%s/mod_%d_%03d.py", dir, variant, i);
FILE *f = fopen(path, "w");
if (!f) {
continue;
}
fprintf(f,
"class Handler%d:\n"
" def run(self, x):\n"
" return self.helper(x) + %d\n"
" def helper(self, x):\n"
" for i in range(10):\n"
" x += i\n"
" return x\n"
"\n"
"def main_%d(x):\n"
" return Handler%d().run(x)\n",
i, i, i, i);
fclose(f);
}
}
static int idx773_double_index_check(const char *dir_a, const char *dir_b) {
cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (!srv) {
return IDX773_FIRST_FAILED;
}
char args[CBM_SZ_512];
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", dir_a);
char *r1 = cbm_mcp_handle_tool(srv, "index_repository", args);
bool ok1 = r1 && strstr(r1, "indexed") != NULL;
free(r1);
if (!ok1) {
cbm_mcp_server_free(srv);
return IDX773_FIRST_FAILED;
}
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"full\"}", dir_b);
char *r2 = cbm_mcp_handle_tool(srv, "index_repository", args); /* SIGABRT here (RED) */
bool ok2 = r2 && strstr(r2, "indexed") != NULL;
free(r2);
cbm_mcp_server_free(srv);
return ok2 ? IDX773_OK : IDX773_SECOND_FAILED;
}
#endif /* !_WIN32 */
/* #898: the SEQUENTIAL pipeline emitted malformed JSON for brokered
* ASYNC_CALLS edges ("broker":"bullmq} — missing closing quote) and stored
* the RAW broker/method string as the synthesized Route node's properties
* (literally `bullmq` instead of {"broker":"bullmq"}). json_extract over
* those rows errors, generated-column indexes fail, and PRAGMA quick_check
* aborts with "malformed JSON" — which since the artifact deep-integrity
* check also means such caches are refused at import. The parallel path
* was correct; both pipelines must emit identical, valid JSON. */
TEST(sequential_service_edge_props_are_valid_json_issue898) {
char tmp[CBM_SZ_256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_seq898_XXXXXX");
if (!cbm_mkdtemp(tmp)) {
FAIL("mkdtemp failed");
}
char src_path[CBM_SZ_512];
snprintf(src_path, sizeof(src_path), "%s/queue.py", tmp);
FILE *f = fopen(src_path, "w");
ASSERT_NOT_NULL(f);
/* celery.Celery("tasks") resolves through the import map to a QN the
* service-pattern table classifies as ASYNC with broker "celery". */
fputs("import celery\n"
"\n"
"def enqueue():\n"
" celery.Celery(\"tasks\")\n",
f);
fclose(f);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char args[CBM_SZ_512];
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", tmp);
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "indexed"));
free(resp);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
struct sqlite3 *db = cbm_store_get_db(store);
ASSERT_NOT_NULL(db);
/* Non-vacuous: the fixture must actually produce a brokered edge. */
sqlite3_stmt *stmt = NULL;
ASSERT_EQ(sqlite3_prepare_v2(db, "SELECT count(*) FROM edges WHERE type='ASYNC_CALLS';", -1,
&stmt, NULL),
SQLITE_OK);
ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW);
int async_edges = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
ASSERT_TRUE(async_edges >= 1);
/* THE BUG: malformed properties on edges (broker quote) and Route nodes
* (raw string). Every properties blob must be valid JSON. */
ASSERT_EQ(sqlite3_prepare_v2(db,
"SELECT count(*) FROM edges WHERE properties IS NOT NULL "
"AND properties != '' AND json_valid(properties)=0;",
-1, &stmt, NULL),
SQLITE_OK);
ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW);
int bad_edges = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
ASSERT_EQ(bad_edges, 0);
ASSERT_EQ(sqlite3_prepare_v2(db,
"SELECT count(*) FROM nodes WHERE properties IS NOT NULL "
"AND properties != '' AND json_valid(properties)=0;",
-1, &stmt, NULL),
SQLITE_OK);
ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW);
int bad_nodes = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
ASSERT_EQ(bad_nodes, 0);
/* Pipeline parity: the broker must be extractable exactly like the
* parallel path emits it. */
ASSERT_EQ(sqlite3_prepare_v2(db,
"SELECT count(*) FROM edges WHERE type='ASYNC_CALLS' AND "
"json_extract(properties,'$.broker')='celery';",
-1, &stmt, NULL),
SQLITE_OK);
ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW);
int brokered = sqlite3_column_int(stmt, 0);
sqlite3_finalize(stmt);
ASSERT_TRUE(brokered >= 1);
cbm_mcp_server_free(srv);
unlink(src_path);
cbm_rmdir(tmp);
PASS();
}
TEST(index_second_inprocess_run_survives_issue773) {
#ifdef _WIN32
SKIP_PLATFORM("fork-isolated crash guard (POSIX-only)");
#else
char dir_a[CBM_SZ_256];
char dir_b[CBM_SZ_256];
char cache[CBM_SZ_256];
snprintf(dir_a, sizeof(dir_a), "/tmp/cbm-idx773a-XXXXXX");
snprintf(dir_b, sizeof(dir_b), "/tmp/cbm-idx773b-XXXXXX");
snprintf(cache, sizeof(cache), "/tmp/cbm-idx773c-XXXXXX");
if (!cbm_mkdtemp(dir_a) || !cbm_mkdtemp(dir_b) || !cbm_mkdtemp(cache)) {
FAIL("mkdtemp failed");
}
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
/* Trigger shape: run 1 small enough for the SEQUENTIAL path (parses on
* the calling thread, mimalloc epoch), run 2 large enough for the
* PARALLEL path (switches the global ts allocator to the slab). */
idx773_write_py_repo(dir_a, 5, 0);
idx773_write_py_repo(dir_b, 60, 1);
int code = -1;
bool signalled = false;
int sig = 0;
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
alarm(180); /* generous: two full parallel indexes */
_exit(idx773_double_index_check(dir_a, dir_b));
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
code = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
signalled = true;
sig = WTERMSIG(status);
}
restore_cache_dir(saved_cache_copy);
free(saved_cache_copy);
if (signalled) {
printf(" child killed by signal %d (SIGABRT = the #773 invalid free)\n", sig);
} else if (code != IDX773_OK) {
printf(" child exit code %d (71=first index failed, 72=second failed)\n", code);
}
ASSERT_FALSE(signalled);
ASSERT_EQ(code, IDX773_OK);
PASS();
#endif
}
TEST(index_recovery_parallel_quarantines_crasher) {
#ifdef _WIN32
SKIP_PLATFORM("parallel-recovery guard needs fork isolation (POSIX-only)");
#else
char tmp_dir[CBM_SZ_256];
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idxpar-XXXXXX");
if (!cbm_mkdtemp(tmp_dir)) {
FAIL("mkdtemp failed");
}
char cache[CBM_SZ_256];
snprintf(cache, sizeof(cache), "/tmp/cbm-idxpar-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
FAIL("mkdtemp cache failed");
}
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
char p1[CBM_SZ_512];
char p2[CBM_SZ_512];
char pc[CBM_SZ_512];
snprintf(p1, sizeof(p1), "%s/idxpar_good_a.py", tmp_dir);
snprintf(p2, sizeof(p2), "%s/idxpar_good_b.py", tmp_dir);
snprintf(pc, sizeof(pc), "%s/idxpar_crasher.py", tmp_dir);
FILE *f = fopen(p1, "w");
ASSERT_NOT_NULL(f);
fputs("def idxpar_good_fn():\n return 'ok'\n", f);
fclose(f);
f = fopen(p2, "w");
ASSERT_NOT_NULL(f);
fputs("def idxpar_good_fn_b():\n return 'ok'\n", f);
fclose(f);
f = fopen(pc, "w");
ASSERT_NOT_NULL(f);
fputs("def idxpar_crash_fn():\n return 'boom'\n", f);
fclose(f);
int code = -1;
bool signalled = false;
int sig = 0;
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
alarm(120); /* generous: three supervised rounds + clean run */
_exit(idxpar_recovery_check(tmp_dir));
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
code = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
signalled = true;
sig = WTERMSIG(status);
}
char *project = cbm_project_name_from_path(tmp_dir);
cleanup_project_db(cache, project);
free(project);
restore_cache_dir(saved_cache_copy);
free(saved_cache_copy);
remove(p1);
remove(p2);
remove(pc);
cbm_rmdir(cache);
cbm_rmdir(tmp_dir);
if (signalled) {
printf(" child killed by signal %d (alarm => recovery loop hang)\n", sig);
} else if (code != IDXPAR_OK) {
printf(" child exit code %d (61=ST spawn/RED, 62=null resp, 63=not indexed, "
"64=no quarantine, 65=innocent hit, 66=good missing)\n",
code);
}
ASSERT_FALSE(signalled);
ASSERT_EQ(code, IDXPAR_OK);
PASS();
#endif
}
/* ══════════════════════════════════════════════════════════════════
* AUTO_WATCH GATE (distilled from PR #625)
*
* Background watcher registration on session connect is gated by the
* `auto_watch` config key (default TRUE = existing behavior).
* ══════════════════════════════════════════════════════════════════ */
/* Drive the already-indexed connect path (initialize → maybe_auto_index →
* watcher registration) and return the resulting watch count.
* auto_watch_value: NULL leaves the key unset (exercises the default),
* otherwise the key is set to that value before initialize.
* Returns a negative code on fixture setup failure. */
static int auto_watch_connect_watch_count(const char *auto_watch_value) {
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-autowatch-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
return -1;
}
char repodir[512];
snprintf(repodir, sizeof(repodir), "%s/repo", cache);
if (th_mkdir_p(repodir) != 0) {
th_rmtree(cache);
return -2;
}
/* Same derivation detect_session uses on the cwd — realpath-based, so
* the name matches even where /tmp is a symlink (macOS). */
char *project = cbm_project_name_from_path(repodir);
if (!project) {
th_rmtree(cache);
return -3;
}
/* Pre-create <cache>/<project>.db so maybe_auto_index takes the
* "already indexed" branch — the watcher-registration site under test. */
char db_path[1024];
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
if (th_write_file(db_path, "") != 0) {
free(project);
th_rmtree(cache);
return -4;
}
free(project);
const char *saved = getenv("CBM_CACHE_DIR");
char *saved_copy = saved ? strdup(saved) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1);
char old_cwd[1024];
if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(repodir) != 0) {
restore_cache_dir(saved_copy);
free(saved_copy);
th_rmtree(cache);
return -5;
}
int count = -6;
cbm_config_t *cfg = cbm_config_open(cache);
cbm_store_t *wstore = cbm_store_open_memory();
cbm_watcher_t *watcher = wstore ? cbm_watcher_new(wstore, NULL, NULL) : NULL;
if (cfg && watcher) {
if (auto_watch_value) {
cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, auto_watch_value);
}
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (srv) {
cbm_mcp_server_set_watcher(srv, watcher);
cbm_mcp_server_set_config(srv, cfg);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}");
free(resp);
count = cbm_watcher_watch_count(watcher);
cbm_mcp_server_free(srv);
}
}
if (watcher) {
cbm_watcher_free(watcher);
}
if (wstore) {
cbm_store_close(wstore);
}
if (cfg) {
cbm_config_close(cfg);
}
(void)cbm_chdir(old_cwd);
restore_cache_dir(saved_copy);
free(saved_copy);
th_rmtree(cache);
return count;
}
/* Default (key unset) → watcher registered on connect. Guards the
* no-behavior-change promise of the auto_watch gate: existing users keep
* background auto-sync without touching config. */
TEST(mcp_auto_watch_default_registers_watcher_on_connect) {
int count = auto_watch_connect_watch_count(NULL);
if (count < 0) {
PASS(); /* fixture setup failed (tmpdir/cwd unavailable) — skip */
}
ASSERT_EQ(count, 1);
PASS();
}
/* auto_watch=false → NO watcher registered on connect. RED on pre-gate code
* (registration was unconditional and the key did not exist). */
TEST(mcp_auto_watch_false_skips_watcher_on_connect) {
int count = auto_watch_connect_watch_count("false");
if (count < 0) {
PASS(); /* fixture setup failed (tmpdir/cwd unavailable) — skip */
}
ASSERT_EQ(count, 0);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* #853 — auto_watch=false must ALSO gate the SUPERVISED fresh-index
* watcher registration (keystone × #849 merge interaction)
* ══════════════════════════════════════════════════════════════════ */
/* #849 routed ALL watcher registration through register_watcher_if_enabled()
* (auto_watch gate). The #832 keystone then added a SECOND registration site in
* autoindex_thread's supervised-success branch, but wired it as a DIRECT
* cbm_watcher_watch() guarded only by `if (srv->watcher)` — srv->watcher is set
* unconditionally, so that guard does NOT honour `config set auto_watch false`.
* The above tests only cover the already-indexed on-connect path
* (register_watcher_if_enabled); this guard covers the fresh-index SUPERVISED
* autoindex_thread branch that #832 introduced.
*
* Drive the real public entry initialize → maybe_auto_index → autoindex_thread on
* a supervisor-marked host (kill switch off) with a FRESH project (no prior .db)
* and auto_watch=false. cbm_mcp_server_free() joins the autoindex thread, so the
* (buggy or gated) registration decision has run before we read the watch count.
*
* RED on the unfixed ungated block: the supervised success branch calls
* cbm_watcher_watch() unconditionally → watch_count == 1 → IDX853_WATCHER_REGISTERED.
* GREEN once it calls register_watcher_if_enabled() → auto_watch_off skip → 0.
* spawn_count is asserted to have advanced so the assertion cannot pass vacuously
* (i.e. green only because the supervised branch was never entered). */
enum {
IDX853_OK = 0, /* watch_count==0, supervised branch ran → GREEN */
IDX853_WATCHER_REGISTERED = 61, /* watch_count==1 → RED: ungated cbm_watcher_watch */
IDX853_NO_SPAWN = 62, /* spawn_count unchanged → supervised path not exercised */
IDX853_SETUP_FAIL = 63, /* config/watcher/server/cwd setup failed */
IDX853_BAD_COUNT = 64, /* unexpected watch_count (<0 or >1) */
};
#ifndef _WIN32 /* helper used only by the POSIX fork harness below */
static int idx853_supervised_autowatch_check(const char *repo_dir, const char *cache_dir) {
/* Become a supervisor host with the kill switch OFF — the real prod MCP
* server's state. Done in the FORKED CHILD only (see harness) so the parent
* test-runner's process-wide host mark stays clear (#845 invariant). Bound the
* worker so a stuck spawn cannot run long under the fork+alarm net. */
cbm_index_supervisor_mark_host();
cbm_unsetenv("CBM_INDEX_SUPERVISOR");
cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1);
cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1);
cbm_config_t *cfg = cbm_config_open(cache_dir);
cbm_store_t *wstore = cbm_store_open_memory();
cbm_watcher_t *watcher = wstore ? cbm_watcher_new(wstore, NULL, NULL) : NULL;
if (!cfg || !watcher) {
if (watcher) {
cbm_watcher_free(watcher);
}
if (wstore) {
cbm_store_close(wstore);
}
if (cfg) {
cbm_config_close(cfg);
}
return IDX853_SETUP_FAIL;
}
/* auto_index=true → maybe_auto_index launches autoindex_thread for the fresh
* project; auto_watch=false → the gate this guard exercises. */
cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true");
cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, "false");
/* detect_session derives session_root/session_project from the cwd. */
char old_cwd[1024];
if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(repo_dir) != 0) {
cbm_watcher_free(watcher);
cbm_store_close(wstore);
cbm_config_close(cfg);
return IDX853_SETUP_FAIL;
}
int spawns_before = cbm_index_supervisor_spawn_count();
int code = IDX853_SETUP_FAIL;
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
if (srv) {
cbm_mcp_server_set_watcher(srv, watcher);
cbm_mcp_server_set_config(srv, cfg);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}");
free(resp);
/* free() joins the autoindex thread → the supervised worker has finished
* and the registration decision (buggy or gated) has executed. */
cbm_mcp_server_free(srv);
int spawns_after = cbm_index_supervisor_spawn_count();
int watch_count = cbm_watcher_watch_count(watcher);
if (spawns_after == spawns_before) {
code = IDX853_NO_SPAWN; /* supervised branch never ran — not a valid probe */
} else if (watch_count == 1) {
code = IDX853_WATCHER_REGISTERED; /* the discriminating RED assertion */
} else if (watch_count == 0) {
code = IDX853_OK;
} else {
code = IDX853_BAD_COUNT;
}
}
(void)cbm_chdir(old_cwd);
cbm_watcher_free(watcher);
cbm_store_close(wstore);
cbm_config_close(cfg);
return code;
}
#endif /* !_WIN32 */
TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853) {
#ifdef _WIN32
/* Marks the process as a supervisor host (irreversible); POSIX isolates that
* in a forked child. The gate logic is platform-independent and covered on
* POSIX CI. */
SKIP_PLATFORM("supervisor-host guard needs fork isolation (POSIX-only)");
#else
char tmp_dir[256];
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx853-repo-XXXXXX");
if (!cbm_mkdtemp(tmp_dir)) {
PASS();
}
char cache[256];
snprintf(cache, sizeof(cache), "/tmp/cbm-idx853-cache-XXXXXX");
if (!cbm_mkdtemp(cache)) {
cbm_rmdir(tmp_dir);
PASS();
}
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
cbm_setenv("CBM_CACHE_DIR", cache, 1); /* inherited by the worker child */
char src_path[512];
snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir);
FILE *fp = fopen(src_path, "w");
ASSERT_NOT_NULL(fp);
fputs("def idx853_fn():\n return 'ok'\n", fp);
fclose(fp);
int code = -1;
bool signalled = false;
int sig = 0;
fflush(NULL);
pid_t pid = fork();
if (pid == 0) {
alarm(60); /* a stuck worker dies here instead of hanging the runner */
_exit(idx853_supervised_autowatch_check(tmp_dir, cache));
}
ASSERT_TRUE(pid > 0);
int status = 0;
(void)waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
code = WEXITSTATUS(status);
} else if (WIFSIGNALED(status)) {
signalled = true;
sig = WTERMSIG(status);
}
char *project = cbm_project_name_from_path(tmp_dir);
cleanup_project_db(cache, project);
free(project);
restore_cache_dir(saved_cache_copy);
free(saved_cache_copy);
remove(src_path);
cbm_rmdir(cache);
cbm_rmdir(tmp_dir);
if (signalled) {
printf(" child killed by signal %d (alarm => worker hang)\n", sig);
} else if (code != IDX853_OK) {
printf(" child exit code %d (61=watcher registered under auto_watch=false=RED, "
"62=no spawn, 63=setup fail, 64=bad count)\n",
code);
}
ASSERT_FALSE(signalled);
ASSERT_EQ(code, IDX853_OK);
PASS();
#endif
}
/* The containment guard both MCP file-read sinks route through
* (resolve_snippet_source for get_code_snippet, attach_result_source for
* search_code). A result path that resolves outside the indexed project root
* — via a `..` segment or a followed symlink/junction — must be rejected so
* its contents never reach a tool response. */
extern bool cbm_path_within_root(const char *root_path, const char *abs_path);
TEST(mcp_path_within_root_rejects_escape) {
#ifdef _WIN32
SKIP_PLATFORM("POSIX realpath repro; the Windows _fullpath branch is the same guard");
#else
char root[512];
snprintf(root, sizeof(root), "%s/cbm_pwr_XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(root)) {
FAIL("cbm_mkdtemp failed");
}
char inside[700];
snprintf(inside, sizeof(inside), "%s/inside.c", root);
FILE *fp = fopen(inside, "w");
ASSERT_NOT_NULL(fp);
fputs("int x;\n", fp);
fclose(fp);
/* The abs_path a sink builds for an in-root result stays contained; a `..`
* escape to an existing outside file (/etc/hosts) resolves out and must be
* rejected. */
char escape[900];
snprintf(escape, sizeof(escape), "%s/../../../../etc/hosts", root);
ASSERT_TRUE(cbm_path_within_root(root, inside));
ASSERT_FALSE(cbm_path_within_root(root, escape));
ASSERT_FALSE(cbm_path_within_root(root, "/etc/hosts"));
remove(inside);
cbm_rmdir(root);
PASS();
#endif
}
/* base_branch is spliced into a `git diff --name-only "<base>"...HEAD` command;
* a value starting with '-' would be taken by git as an option (e.g.
* --output=<path> writes the diff to an arbitrary file) rather than a ref. It
* must be rejected up front, alongside the shell-metacharacter check. */
TEST(detect_changes_rejects_option_like_base_branch) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":77,\"method\":\"tools/call\","
"\"params\":{\"name\":\"detect_changes\","
"\"arguments\":{\"project\":\"p\",\"base_branch\":\"--output=/tmp/cbm_pwn\"}}}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "invalid characters"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Opt-in workspace boundary: when CBM_ALLOWED_ROOT is set, index_repository
* must refuse a repo_path that resolves outside it. Unset (the default) imposes
* no restriction. */
TEST(index_repository_honors_allowed_root) {
char allowed[512];
snprintf(allowed, sizeof(allowed), "%s/cbm_allowed_XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(allowed)) {
FAIL("cbm_mkdtemp failed");
}
cbm_setenv("CBM_ALLOWED_ROOT", allowed, 1);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
char args[1024];
snprintf(args, sizeof(args),
"{\"jsonrpc\":\"2.0\",\"id\":88,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_repository\","
"\"arguments\":{\"repo_path\":\"%s/../..\"}}}",
allowed); /* resolves to a parent, outside the allowed root */
char *resp = cbm_mcp_server_handle(srv, args);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "outside the allowed root"));
free(resp);
cbm_unsetenv("CBM_ALLOWED_ROOT");
cbm_mcp_server_free(srv);
cbm_rmdir(allowed);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* SUITE
* ══════════════════════════════════════════════════════════════════ */
SUITE(mcp) {
RUN_TEST(mcp_path_within_root_rejects_escape);
RUN_TEST(detect_changes_rejects_option_like_base_branch);
RUN_TEST(index_repository_honors_allowed_root);
/* JSON-RPC parsing */
RUN_TEST(jsonrpc_parse_request);
RUN_TEST(jsonrpc_parse_notification);
RUN_TEST(jsonrpc_parse_invalid);
RUN_TEST(jsonrpc_parse_tools_call);
RUN_TEST(jsonrpc_parse_string_id_issue253);
RUN_TEST(jsonrpc_format_response_string_id_issue253);
/* JSON-RPC parsing — edge cases */
RUN_TEST(jsonrpc_parse_empty_string);
RUN_TEST(jsonrpc_parse_missing_jsonrpc_field);
RUN_TEST(jsonrpc_parse_missing_method);
RUN_TEST(jsonrpc_parse_string_id);
RUN_TEST(jsonrpc_parse_no_params);
RUN_TEST(jsonrpc_parse_extra_whitespace);
RUN_TEST(jsonrpc_parse_array_not_object);
/* JSON-RPC formatting */
RUN_TEST(jsonrpc_format_response);
RUN_TEST(jsonrpc_format_error);
/* MCP protocol helpers */
RUN_TEST(mcp_initialize_response);
RUN_TEST(mcp_tools_list);
RUN_TEST(mcp_tools_list_latest_metadata);
RUN_TEST(mcp_index_repository_declares_name_override_issue571);
RUN_TEST(mcp_tools_array_schemas_have_items);
RUN_TEST(mcp_ingest_traces_items_disallow_additional_properties_issue731);
RUN_TEST(mcp_get_architecture_aspects_schema_enum_pr560);
RUN_TEST(mcp_text_result);
RUN_TEST(mcp_text_result_skips_structured_content_for_plain_text);
RUN_TEST(mcp_cancel_matches_request_id);
RUN_TEST(mcp_text_result_error);
/* Argument extraction */
RUN_TEST(mcp_get_tool_name);
RUN_TEST(mcp_get_arguments);
RUN_TEST(mcp_get_string_arg);
RUN_TEST(mcp_get_int_arg);
RUN_TEST(mcp_get_bool_arg);
/* Argument extraction — edge cases */
RUN_TEST(mcp_get_string_arg_empty_json);
RUN_TEST(mcp_get_string_arg_empty_object);
RUN_TEST(mcp_get_string_arg_nested_value);
RUN_TEST(mcp_get_string_arg_int_value);
RUN_TEST(mcp_get_int_arg_empty_json);
RUN_TEST(mcp_get_int_arg_string_value);
RUN_TEST(mcp_get_int_arg_bool_value);
RUN_TEST(mcp_get_bool_arg_empty_json);
RUN_TEST(mcp_get_bool_arg_int_value);
RUN_TEST(mcp_get_tool_name_empty_json);
RUN_TEST(mcp_get_tool_name_missing_name);
RUN_TEST(mcp_get_arguments_empty_json);
RUN_TEST(mcp_get_arguments_no_arguments_key);
/* Server protocol handling */
RUN_TEST(server_handle_initialize);
RUN_TEST(server_handle_initialized_notification);
RUN_TEST(server_handle_tools_list);
RUN_TEST(server_handle_tools_list_defaults_to_all_tools_and_accepts_cursor);
RUN_TEST(server_handle_logs_request_without_params);
RUN_TEST(server_handle_unknown_method);
/* Server handle — edge cases */
RUN_TEST(server_handle_invalid_json);
RUN_TEST(server_handle_empty_object);
RUN_TEST(server_handle_tools_call_missing_name);
/* Tool handlers */
RUN_TEST(tool_list_projects_empty);
RUN_TEST(tool_get_graph_schema_empty);
RUN_TEST(tool_unknown_tool);
RUN_TEST(tool_search_graph_basic);
RUN_TEST(tool_search_graph_includes_node_properties);
RUN_TEST(tool_search_graph_toon_never_leaks_internal_fields);
RUN_TEST(tool_output_byte_budgets);
RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552);
RUN_TEST(mcp_discovery_methods_return_empty_lists);
RUN_TEST(tool_query_graph_basic);
RUN_TEST(tool_index_status_no_project);
RUN_TEST(tool_index_status_includes_git_metadata);
/* Tool handlers with validation */
RUN_TEST(tool_trace_call_path_not_found);
RUN_TEST(tool_trace_missing_function_name);
RUN_TEST(tool_trace_call_path_ambiguous);
RUN_TEST(tool_trace_call_path_prefers_definition);
RUN_TEST(tool_trace_call_path_depth_clamped);
RUN_TEST(tool_trace_call_path_distinct_defs_not_over_unioned);
RUN_TEST(tool_trace_call_path_dts_stub_unions_with_impl);
RUN_TEST(tool_delete_project_not_found);
RUN_TEST(tool_get_architecture_empty);
RUN_TEST(tool_get_architecture_emits_populated_sections);
RUN_TEST(tool_get_architecture_overview_compact_subset_pr560);
RUN_TEST(tool_get_architecture_rejects_unknown_aspect_pr560);
RUN_TEST(tool_get_architecture_accepts_project_name_alias_issue640);
RUN_TEST(tool_search_graph_accepts_project_name_alias_issue640);
RUN_TEST(tool_project_arg_resolves_unique_tail_issue1025);
RUN_TEST(tool_get_architecture_path_scoping);
RUN_TEST(tool_query_graph_missing_query);
/* Pipeline-dependent tool handlers */
RUN_TEST(tool_index_repository_missing_path);
RUN_TEST(tool_get_code_snippet_missing_qn);
RUN_TEST(tool_get_code_snippet_not_found);
RUN_TEST(tool_search_code_missing_pattern);
RUN_TEST(tool_search_code_no_project);
RUN_TEST(search_code_multi_word);
RUN_TEST(search_code_scoped_path_with_spaces_issue687);
#ifdef _WIN32
RUN_TEST(search_code_scoped_path_with_cjk_root_issue903);
#endif
RUN_TEST(search_code_path_filter_prefilter_keeps_matches);
RUN_TEST(search_code_path_filter_matches_nothing);
RUN_TEST(search_code_invalid_regex_errors_issue283);
RUN_TEST(search_code_literal_pipe_warns_issue282);
RUN_TEST(search_code_ampersand_accepted_issue272);
RUN_TEST(tool_detect_changes_no_project);
RUN_TEST(tool_manage_adr_no_project);
RUN_TEST(tool_manage_adr_get_with_existing_adr);
RUN_TEST(tool_manage_adr_unified_backend_issue256);
RUN_TEST(tool_index_repository_reports_store_backed_adr);
RUN_TEST(tool_index_repository_dot_uses_absolute_project_key_and_preserves_adr);
RUN_TEST(index_repository_cli_name_override_issue823);
RUN_TEST(index_supervisor_gate_requires_marked_host_issue845);
RUN_TEST(index_bg_paths_route_through_supervisor_issue832);
RUN_TEST(sequential_service_edge_props_are_valid_json_issue898);
RUN_TEST(index_second_inprocess_run_survives_issue773);
RUN_TEST(index_recovery_parallel_quarantines_crasher);
RUN_TEST(tool_manage_adr_not_found_rich_error);
RUN_TEST(tool_manage_adr_get_accepts_abs_path);
RUN_TEST(tool_manage_adr_get_accepts_symlink_path);
RUN_TEST(tool_detect_changes_not_found_rich_error);
RUN_TEST(tool_ingest_traces_basic);
RUN_TEST(tool_ingest_traces_empty);
/* Query store read-only (data integrity) */
RUN_TEST(readonly_query_does_not_mutate_db);
RUN_TEST(readonly_query_succeeds_on_readonly_fs);
/* Idle store eviction */
RUN_TEST(store_idle_eviction);
RUN_TEST(store_idle_no_eviction_within_timeout);
RUN_TEST(store_idle_evict_protects_initial_store);
RUN_TEST(store_idle_evict_access_resets_timer);
/* URI helpers */
RUN_TEST(parse_file_uri_unix);
RUN_TEST(parse_file_uri_windows);
RUN_TEST(parse_file_uri_invalid);
/* URI helpers — edge cases */
RUN_TEST(parse_file_uri_http_scheme);
RUN_TEST(parse_file_uri_ftp_scheme);
RUN_TEST(parse_file_uri_buffer_too_small);
RUN_TEST(parse_file_uri_spaces_in_path);
RUN_TEST(parse_file_uri_null_out_path);
RUN_TEST(parse_file_uri_zero_size);
/* Poll/getline FILE* buffering fix */
#ifndef _WIN32
RUN_TEST(mcp_server_run_rapid_messages);
#endif
/* Snippet resolution (port of snippet_test.go) */
RUN_TEST(snippet_exact_qn);
RUN_TEST(snippet_qn_suffix);
RUN_TEST(snippet_unique_short_name);
RUN_TEST(snippet_name_tier);
RUN_TEST(snippet_ambiguous_short_name);
RUN_TEST(snippet_not_found);
RUN_TEST(snippet_fuzzy_suggestions);
RUN_TEST(snippet_enriched_properties);
RUN_TEST(snippet_fuzzy_last_segment);
RUN_TEST(snippet_auto_resolve_default);
RUN_TEST(snippet_auto_resolve_enabled);
RUN_TEST(snippet_include_neighbors_default);
RUN_TEST(snippet_include_neighbors_enabled);
RUN_TEST(snippet_source_invalid_utf8);
RUN_TEST(tool_bad_project_name_no_overflow_issue235);
RUN_TEST(tool_bad_project_error_valid_json_issue235);
RUN_TEST(tool_resolve_store_by_internal_name_issue704);
/* auto_watch gate (distilled from PR #625) */
RUN_TEST(mcp_auto_watch_default_registers_watcher_on_connect);
RUN_TEST(mcp_auto_watch_false_skips_watcher_on_connect);
RUN_TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853);
}