/* * 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 #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 #include /* spawn-count hook — #845 in-process guard */ #include #include #include #include #include #include #include #include #include #include /* chmod / stat for read-only query reproductions */ #ifdef _WIN32 #include #define cbm_chdir _chdir #define cbm_getcwd _getcwd #else #include /* waitpid — #845 fork+alarm harness */ #include #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 ("-" 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 "/" 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 #include #include /* 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":""}]} * 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 #include /* 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 /.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 / 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 * ' 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 /.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 ""...HEAD` command; * a value starting with '-' would be taken by git as an option (e.g. * --output= 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); }