项目文件夹

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

5872 行
225 KiB
C

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/*
* 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);
}