9738 lines
386 KiB
C
9738 lines
386 KiB
C
/*
|
||
* test_mcp.c — Tests for the MCP server module.
|
||
*
|
||
* Covers: JSON-RPC parsing, MCP protocol, tool dispatch, tool handlers.
|
||
*/
|
||
#include "../src/foundation/compat.h"
|
||
#include <sqlite3.h>
|
||
#include "../src/foundation/compat_fs.h" /* cbm_unlink / cbm_rmdir */
|
||
#include "../src/foundation/constants.h"
|
||
#include "../src/foundation/log.h"
|
||
#include "../src/foundation/platform.h" /* cbm_file_size */
|
||
#include "../src/foundation/subprocess.h"
|
||
#include "../src/mcp/compact_out.h"
|
||
#include "test_framework.h"
|
||
#include "test_helpers.h"
|
||
#include <cli/cli.h>
|
||
#include <mcp/index_supervisor.h> /* spawn-count hook — #845 in-process guard */
|
||
#include <mcp/mcp.h>
|
||
#include <mcp/mcp_internal.h>
|
||
#include <pipeline/pipeline.h>
|
||
#include <store/store.h>
|
||
#include <watcher/watcher.h>
|
||
#include <yyjson/yyjson.h>
|
||
#include <ctype.h>
|
||
#include <stdbool.h>
|
||
#include <stdio.h>
|
||
#include <stdlib.h>
|
||
#include <string.h>
|
||
#include <sys/stat.h> /* chmod / stat for read-only query reproductions */
|
||
#ifdef _WIN32
|
||
#include <direct.h>
|
||
#define cbm_chdir _chdir
|
||
#define cbm_getcwd _getcwd
|
||
#else
|
||
#ifdef __APPLE__
|
||
#include <libproc.h>
|
||
#endif
|
||
#include <spawn.h>
|
||
#include <signal.h>
|
||
#include <sys/wait.h>
|
||
#include <unistd.h>
|
||
#define cbm_chdir chdir
|
||
#define cbm_getcwd getcwd
|
||
extern char **environ;
|
||
#endif
|
||
|
||
static bool mcp_response_has_exact_tool(const char *response, const char *expected_name) {
|
||
yyjson_doc *doc = response ? yyjson_read(response, strlen(response), 0) : NULL;
|
||
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
|
||
yyjson_val *result = root ? yyjson_obj_get(root, "result") : NULL;
|
||
yyjson_val *tools = result ? yyjson_obj_get(result, "tools") : NULL;
|
||
bool found = false;
|
||
if (tools && yyjson_is_arr(tools)) {
|
||
size_t index, max;
|
||
yyjson_val *tool;
|
||
yyjson_arr_foreach(tools, index, max, tool) {
|
||
yyjson_val *name = yyjson_obj_get(tool, "name");
|
||
if (name && yyjson_is_str(name) && strcmp(yyjson_get_str(name), expected_name) == 0) {
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
yyjson_doc_free(doc);
|
||
return found;
|
||
}
|
||
|
||
static size_t mcp_response_tool_count(const char *response) {
|
||
yyjson_doc *doc = response ? yyjson_read(response, strlen(response), 0) : NULL;
|
||
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
|
||
yyjson_val *result = root ? yyjson_obj_get(root, "result") : NULL;
|
||
yyjson_val *tools = result ? yyjson_obj_get(result, "tools") : NULL;
|
||
size_t count = tools && yyjson_is_arr(tools) ? yyjson_arr_size(tools) : 0U;
|
||
yyjson_doc_free(doc);
|
||
return count;
|
||
}
|
||
|
||
static char mcp_log_buf[4096];
|
||
static bool mcp_saw_autoindex_log;
|
||
|
||
static void mcp_capture_log(const char *line) {
|
||
snprintf(mcp_log_buf, sizeof(mcp_log_buf), "%s", line ? line : "");
|
||
if (line && strstr(line, "msg=autoindex.")) {
|
||
mcp_saw_autoindex_log = true;
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
#define MCP_MUTATION_GUARD_MAX_EVENTS 16
|
||
|
||
typedef struct {
|
||
int deny_begin_call; /* one-based; zero allows every acquisition */
|
||
int cancel_on_begin_call; /* one-based; zero never requests cancellation */
|
||
int begin_count;
|
||
int end_count;
|
||
cbm_mcp_server_t *cancel_server;
|
||
bool cancel_attempted;
|
||
bool cancel_accepted;
|
||
const char *observed_db_path;
|
||
const char *observed_backup_path;
|
||
bool db_exists_at_begin;
|
||
bool backup_exists_at_begin;
|
||
bool db_exists_at_end;
|
||
bool backup_exists_at_end;
|
||
char begin_projects[MCP_MUTATION_GUARD_MAX_EVENTS][CBM_SZ_256];
|
||
char end_projects[MCP_MUTATION_GUARD_MAX_EVENTS][CBM_SZ_256];
|
||
} mcp_mutation_guard_probe_t;
|
||
|
||
typedef struct {
|
||
const char *deny_step;
|
||
int call_count;
|
||
char steps[4][64];
|
||
} mcp_quarantine_hook_probe_t;
|
||
|
||
typedef struct {
|
||
bool reject_merge_base;
|
||
int diff_calls;
|
||
int merge_base_calls;
|
||
} mcp_command_hook_probe_t;
|
||
|
||
static bool mcp_quarantine_hook_probe(void *context, const char *step) {
|
||
mcp_quarantine_hook_probe_t *probe = context;
|
||
if (!probe || !step) {
|
||
return false;
|
||
}
|
||
int event = probe->call_count++;
|
||
if (event >= 0 && event < 4) {
|
||
snprintf(probe->steps[event], sizeof(probe->steps[event]), "%s", step);
|
||
}
|
||
return !probe->deny_step || strcmp(probe->deny_step, step) != 0;
|
||
}
|
||
|
||
static bool mcp_command_hook_probe(void *context, const char *command) {
|
||
mcp_command_hook_probe_t *probe = context;
|
||
if (!probe || !command) {
|
||
return false;
|
||
}
|
||
if (strstr(command, "merge-base")) {
|
||
probe->merge_base_calls++;
|
||
return !probe->reject_merge_base;
|
||
}
|
||
probe->diff_calls++;
|
||
return true;
|
||
}
|
||
|
||
typedef struct {
|
||
const char *name;
|
||
char *value;
|
||
bool present;
|
||
} mcp_test_env_backup_t;
|
||
|
||
static void mcp_test_restore_env(mcp_test_env_backup_t *backups, size_t count) {
|
||
for (size_t index = 0; index < count; index++) {
|
||
if (backups[index].present) {
|
||
(void)cbm_setenv(backups[index].name, backups[index].value, 1);
|
||
} else {
|
||
(void)cbm_unsetenv(backups[index].name);
|
||
}
|
||
free(backups[index].value);
|
||
}
|
||
}
|
||
|
||
static int mcp_test_git(const char *root, const char *const *arguments) {
|
||
char empty_config[CBM_SZ_4K];
|
||
int config_length =
|
||
snprintf(empty_config, sizeof(empty_config), "%s/.cbm-empty-gitconfig", root);
|
||
if (config_length <= 0 || (size_t)config_length >= sizeof(empty_config)) {
|
||
return -1;
|
||
}
|
||
FILE *config = cbm_fopen(empty_config, "wb");
|
||
if (!config) {
|
||
return -1;
|
||
}
|
||
if (fclose(config) != 0) {
|
||
return -1;
|
||
}
|
||
|
||
mcp_test_env_backup_t backups[] = {
|
||
{.name = "GIT_CONFIG_GLOBAL"}, {.name = "GIT_CONFIG_SYSTEM"},
|
||
{.name = "GIT_CONFIG_NOSYSTEM"}, {.name = "GIT_CONFIG_COUNT"},
|
||
{.name = "GIT_CONFIG_PARAMETERS"},
|
||
};
|
||
bool snapshot_ok = true;
|
||
for (size_t index = 0; index < sizeof(backups) / sizeof(backups[0]); index++) {
|
||
const char *value = getenv(backups[index].name);
|
||
backups[index].present = value != NULL;
|
||
backups[index].value = value ? strdup(value) : NULL;
|
||
snapshot_ok = snapshot_ok && (!value || backups[index].value);
|
||
}
|
||
if (!snapshot_ok) {
|
||
for (size_t index = 0; index < sizeof(backups) / sizeof(backups[0]); index++) {
|
||
free(backups[index].value);
|
||
}
|
||
return -1;
|
||
}
|
||
bool environment_ok = cbm_setenv("GIT_CONFIG_GLOBAL", empty_config, 1) == 0 &&
|
||
cbm_setenv("GIT_CONFIG_SYSTEM", empty_config, 1) == 0 &&
|
||
cbm_setenv("GIT_CONFIG_NOSYSTEM", "1", 1) == 0 &&
|
||
cbm_setenv("GIT_CONFIG_COUNT", "0", 1) == 0 &&
|
||
cbm_unsetenv("GIT_CONFIG_PARAMETERS") == 0;
|
||
if (!environment_ok) {
|
||
mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0]));
|
||
return -1;
|
||
}
|
||
|
||
const char *git = "git";
|
||
#ifdef _WIN32
|
||
char git_executable[CBM_SZ_4K];
|
||
const char *resolved = cbm_find_cli("git", cbm_get_home_dir());
|
||
int resolved_length =
|
||
resolved ? snprintf(git_executable, sizeof(git_executable), "%s", resolved) : -1;
|
||
if (resolved_length <= 0 || (size_t)resolved_length >= sizeof(git_executable)) {
|
||
mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0]));
|
||
return -1;
|
||
}
|
||
git = git_executable;
|
||
#endif
|
||
const char *argv[24] = {git, "-C", root};
|
||
size_t index = 3;
|
||
while (arguments && *arguments && index + 1 < sizeof(argv) / sizeof(argv[0])) {
|
||
argv[index++] = *arguments++;
|
||
}
|
||
if ((arguments && *arguments) || index >= sizeof(argv) / sizeof(argv[0])) {
|
||
mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0]));
|
||
return -1;
|
||
}
|
||
argv[index] = NULL;
|
||
|
||
cbm_proc_opts_t options = {
|
||
.bin = git,
|
||
.argv = argv,
|
||
.quiet_timeout_ms = 10000,
|
||
};
|
||
cbm_proc_result_t result = {0};
|
||
int run_result =
|
||
cbm_subprocess_run(&options, &result) == 0 && result.outcome == CBM_PROC_CLEAN ? 0 : -1;
|
||
mcp_test_restore_env(backups, sizeof(backups) / sizeof(backups[0]));
|
||
return run_result;
|
||
}
|
||
|
||
static bool mcp_mutation_guard_probe_begin(void *context, const char *project) {
|
||
mcp_mutation_guard_probe_t *probe = context;
|
||
if (!probe) {
|
||
return false;
|
||
}
|
||
|
||
int event = probe->begin_count++;
|
||
if (event < MCP_MUTATION_GUARD_MAX_EVENTS) {
|
||
snprintf(probe->begin_projects[event], sizeof(probe->begin_projects[event]), "%s",
|
||
project ? project : "");
|
||
}
|
||
if (probe->cancel_on_begin_call > 0 && probe->begin_count == probe->cancel_on_begin_call) {
|
||
probe->cancel_attempted = true;
|
||
probe->cancel_accepted = cbm_mcp_server_cancel_active(probe->cancel_server);
|
||
}
|
||
if (probe->observed_db_path) {
|
||
probe->db_exists_at_begin = cbm_file_exists(probe->observed_db_path);
|
||
}
|
||
if (probe->observed_backup_path) {
|
||
probe->backup_exists_at_begin = cbm_file_exists(probe->observed_backup_path);
|
||
}
|
||
return probe->deny_begin_call == 0 || probe->begin_count != probe->deny_begin_call;
|
||
}
|
||
|
||
static void mcp_mutation_guard_probe_end(void *context, const char *project) {
|
||
mcp_mutation_guard_probe_t *probe = context;
|
||
if (!probe) {
|
||
return;
|
||
}
|
||
|
||
int event = probe->end_count++;
|
||
if (event < MCP_MUTATION_GUARD_MAX_EVENTS) {
|
||
snprintf(probe->end_projects[event], sizeof(probe->end_projects[event]), "%s",
|
||
project ? project : "");
|
||
}
|
||
if (probe->observed_db_path) {
|
||
probe->db_exists_at_end = cbm_file_exists(probe->observed_db_path);
|
||
}
|
||
if (probe->observed_backup_path) {
|
||
probe->backup_exists_at_end = cbm_file_exists(probe->observed_backup_path);
|
||
}
|
||
}
|
||
|
||
static bool mcp_make_corrupt_project_store(const char *cache, const char *project) {
|
||
char db_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
if (!store) {
|
||
return false;
|
||
}
|
||
|
||
/* Numeric root paths are the deterministic corruption trigger used by
|
||
* cbm_store_check_integrity() and the issue #557 reproduction. */
|
||
bool created = cbm_store_upsert_project(store, project, "826") == CBM_STORE_OK;
|
||
cbm_store_close(store);
|
||
return created;
|
||
}
|
||
|
||
/* Keep a writer open so the fixture has a real, committed WAL generation.
|
||
* Query-only opens must not alter either file when quarantine is denied or
|
||
* cannot be published safely. The caller owns the returned store. */
|
||
static cbm_store_t *mcp_open_corrupt_project_store_with_wal(const char *cache,
|
||
const char *project) {
|
||
char db_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
if (!store) {
|
||
return NULL;
|
||
}
|
||
|
||
bool ready =
|
||
cbm_store_exec(store, "PRAGMA wal_autocheckpoint=0;") == CBM_STORE_OK &&
|
||
cbm_store_upsert_project(store, project, "826") == CBM_STORE_OK &&
|
||
cbm_store_exec(store, "CREATE TABLE IF NOT EXISTS guard_wal_sentinel(value TEXT);"
|
||
"INSERT INTO guard_wal_sentinel(value) VALUES('committed');") ==
|
||
CBM_STORE_OK;
|
||
if (!ready) {
|
||
cbm_store_close(store);
|
||
return NULL;
|
||
}
|
||
return store;
|
||
}
|
||
|
||
static bool mcp_make_valid_project_store_at(const char *path, const char *project,
|
||
const char *root_path) {
|
||
cbm_store_t *store = cbm_store_open_path(path);
|
||
if (!store) {
|
||
return false;
|
||
}
|
||
bool ready = cbm_store_upsert_project(store, project, root_path) == CBM_STORE_OK &&
|
||
cbm_store_prepare_for_publish(store) == CBM_STORE_OK;
|
||
cbm_store_close(store);
|
||
return ready;
|
||
}
|
||
|
||
static unsigned char *mcp_read_file_bytes(const char *path, long *out_len) {
|
||
if (!out_len) {
|
||
return NULL;
|
||
}
|
||
*out_len = 0;
|
||
FILE *fp = cbm_fopen(path, "rb");
|
||
if (!fp) {
|
||
return NULL;
|
||
}
|
||
if (fseek(fp, 0, SEEK_END) != 0) {
|
||
fclose(fp);
|
||
return NULL;
|
||
}
|
||
long size = ftell(fp);
|
||
if (size < 0 || fseek(fp, 0, SEEK_SET) != 0) {
|
||
fclose(fp);
|
||
return NULL;
|
||
}
|
||
unsigned char *bytes = malloc(size > 0 ? (size_t)size : 1);
|
||
if (!bytes) {
|
||
fclose(fp);
|
||
return NULL;
|
||
}
|
||
size_t read_count = fread(bytes, 1, (size_t)size, fp);
|
||
fclose(fp);
|
||
if (read_count != (size_t)size) {
|
||
free(bytes);
|
||
return NULL;
|
||
}
|
||
*out_len = size;
|
||
return bytes;
|
||
}
|
||
|
||
static bool mcp_file_matches_snapshot(const char *path, const unsigned char *expected,
|
||
long expected_len) {
|
||
long actual_len = 0;
|
||
unsigned char *actual = mcp_read_file_bytes(path, &actual_len);
|
||
bool matches = actual && expected && actual_len == expected_len &&
|
||
memcmp(actual, expected, (size_t)actual_len) == 0;
|
||
free(actual);
|
||
return matches;
|
||
}
|
||
|
||
/* Return the number of quarantine files for a project and, when present, the
|
||
* first path whose name is distinct from the legacy fixed `.corrupt` name. */
|
||
static bool mcp_is_corrupt_backup_main_name(const char *name, const char *prefix) {
|
||
size_t prefix_len = strlen(prefix);
|
||
if (strcmp(name, prefix) == 0) {
|
||
return true;
|
||
}
|
||
const char *suffix = name + prefix_len;
|
||
if (strncmp(name, prefix, prefix_len) != 0 || suffix[0] != '.' || strlen(suffix + 1) != 16) {
|
||
return false;
|
||
}
|
||
for (const char *cursor = suffix + 1; *cursor; cursor++) {
|
||
if (!isxdigit((unsigned char)*cursor)) {
|
||
return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static int mcp_find_corrupt_backups(const char *cache, const char *project, char *unique_path,
|
||
size_t unique_path_size) {
|
||
if (unique_path && unique_path_size > 0) {
|
||
unique_path[0] = '\0';
|
||
}
|
||
char prefix[CBM_DIRENT_NAME_MAX];
|
||
snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project);
|
||
int count = 0;
|
||
cbm_dir_t *dir = cbm_opendir(cache);
|
||
if (!dir) {
|
||
return 0;
|
||
}
|
||
cbm_dirent_t *entry;
|
||
while ((entry = cbm_readdir(dir)) != NULL) {
|
||
if (!mcp_is_corrupt_backup_main_name(entry->name, prefix)) {
|
||
continue;
|
||
}
|
||
char path[CBM_SZ_1K];
|
||
snprintf(path, sizeof(path), "%s/%s", cache, entry->name);
|
||
if (!cbm_file_exists(path)) {
|
||
continue;
|
||
}
|
||
count++;
|
||
if (unique_path && unique_path_size > 0 && unique_path[0] == '\0' &&
|
||
strcmp(entry->name, prefix) != 0) {
|
||
snprintf(unique_path, unique_path_size, "%s", path);
|
||
}
|
||
}
|
||
cbm_closedir(dir);
|
||
return count;
|
||
}
|
||
|
||
static int mcp_count_corrupt_artifacts(const char *cache, const char *project) {
|
||
char prefix[CBM_DIRENT_NAME_MAX];
|
||
snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project);
|
||
size_t prefix_len = strlen(prefix);
|
||
int count = 0;
|
||
cbm_dir_t *dir = cbm_opendir(cache);
|
||
if (!dir) {
|
||
return 0;
|
||
}
|
||
cbm_dirent_t *entry;
|
||
while ((entry = cbm_readdir(dir)) != NULL) {
|
||
if (strncmp(entry->name, prefix, prefix_len) == 0) {
|
||
count++;
|
||
}
|
||
}
|
||
cbm_closedir(dir);
|
||
return count;
|
||
}
|
||
|
||
static int mcp_count_directory_entries_with_prefix(const char *directory, const char *prefix) {
|
||
cbm_dir_t *dir = cbm_opendir(directory);
|
||
if (!dir) {
|
||
return -1;
|
||
}
|
||
size_t prefix_length = strlen(prefix);
|
||
int count = 0;
|
||
cbm_dirent_t *entry;
|
||
while ((entry = cbm_readdir(dir)) != NULL) {
|
||
if (strncmp(entry->name, prefix, prefix_length) == 0) {
|
||
count++;
|
||
}
|
||
}
|
||
cbm_closedir(dir);
|
||
return count;
|
||
}
|
||
|
||
static void mcp_cleanup_corrupt_backups(const char *cache, const char *project) {
|
||
char prefix[CBM_DIRENT_NAME_MAX];
|
||
snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project);
|
||
size_t prefix_len = strlen(prefix);
|
||
cbm_dir_t *dir = cbm_opendir(cache);
|
||
if (!dir) {
|
||
return;
|
||
}
|
||
cbm_dirent_t *entry;
|
||
while ((entry = cbm_readdir(dir)) != NULL) {
|
||
if (strncmp(entry->name, prefix, prefix_len) == 0) {
|
||
char path[CBM_SZ_1K];
|
||
snprintf(path, sizeof(path), "%s/%s", cache, entry->name);
|
||
cbm_unlink(path);
|
||
}
|
||
}
|
||
cbm_closedir(dir);
|
||
}
|
||
|
||
typedef struct {
|
||
mcp_mutation_guard_probe_t guard;
|
||
const char *replacement_path;
|
||
const char *live_path;
|
||
bool replacement_attempted;
|
||
bool replacement_succeeded;
|
||
} mcp_replacing_mutation_guard_t;
|
||
|
||
static bool mcp_replacing_mutation_guard_begin(void *context, const char *project) {
|
||
mcp_replacing_mutation_guard_t *replacement = context;
|
||
if (!replacement || !mcp_mutation_guard_probe_begin(&replacement->guard, project)) {
|
||
return false;
|
||
}
|
||
replacement->replacement_attempted = true;
|
||
bool sidecars_removed = cbm_remove_db_sidecars(replacement->live_path) == 0;
|
||
replacement->replacement_succeeded =
|
||
sidecars_removed &&
|
||
cbm_rename_replace(replacement->replacement_path, replacement->live_path) == 0;
|
||
return true;
|
||
}
|
||
|
||
static void mcp_replacing_mutation_guard_end(void *context, const char *project) {
|
||
mcp_replacing_mutation_guard_t *replacement = context;
|
||
if (replacement) {
|
||
mcp_mutation_guard_probe_end(&replacement->guard, project);
|
||
}
|
||
}
|
||
|
||
TEST(tree_cell_sanitizes_control_and_invalid_utf8) {
|
||
/* One raw control or invalid-UTF8 byte in a cell poisons LINE-ORIENTED
|
||
* consumers of the ENTIRE output (BSD grep treats all of it as
|
||
* unmatchable binary — the macos-15-intel release-smoke B3 class), so
|
||
* cell emission guarantees valid UTF-8: control bytes escape as \u00XX,
|
||
* invalid sequences become U+FFFD, and both force the quoted form. */
|
||
cbm_sb_t sb;
|
||
cbm_sb_init(&sb);
|
||
cbm_tree_cell_str(&sb,
|
||
"evil\x01name\xff"
|
||
"end",
|
||
true);
|
||
char *out = cbm_sb_finish(&sb);
|
||
ASSERT_NOT_NULL(out);
|
||
ASSERT_STR_EQ(out, "\"evil\\u0001name\xEF\xBF\xBD"
|
||
"end\"");
|
||
free(out);
|
||
|
||
cbm_sb_init(&sb);
|
||
cbm_tree_cell_str(&sb, "b\xC3\xA4r_ok", true);
|
||
out = cbm_sb_finish(&sb);
|
||
ASSERT_NOT_NULL(out);
|
||
ASSERT_STR_EQ(out, "b\xC3\xA4r_ok"); /* valid UTF-8 stays raw + unquoted */
|
||
free(out);
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* 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, "\"prompts\":{\"listChanged\":false}"));
|
||
ASSERT_NOT_NULL(strstr(json, "\"instructions\":"));
|
||
ASSERT_NOT_NULL(strstr(json, "search_graph"));
|
||
ASSERT_NOT_NULL(strstr(json, "auto-refresh"));
|
||
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 tools, including the targeted coverage gate. */
|
||
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, "check_index_coverage"));
|
||
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, "\"title\":\"Check index coverage\""));
|
||
ASSERT_NOT_NULL(strstr(json, "\"outputSchema\":{\"type\":\"object\""));
|
||
ASSERT_NOT_NULL(strstr(json, "\"additionalProperties\":true"));
|
||
free(json);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_tools_have_behavior_annotations) {
|
||
struct {
|
||
const char *name;
|
||
bool read_only;
|
||
bool destructive;
|
||
bool idempotent;
|
||
bool open_world;
|
||
} expected[] = {
|
||
{"index_repository", false, false, true, false},
|
||
/* These query tools can reach resolve_store(), whose corrupt-store
|
||
* recovery quarantines/removes database files. Keep the annotations
|
||
* conservative until query resolution is strictly non-mutating. */
|
||
{"search_graph", false, true, true, false},
|
||
{"query_graph", false, true, true, false},
|
||
{"trace_path", false, true, true, false},
|
||
{"get_code_snippet", false, true, true, false},
|
||
{"get_graph_schema", false, true, true, false},
|
||
{"get_architecture", false, true, true, false},
|
||
{"search_code", false, true, true, false},
|
||
{"list_projects", true, false, true, false},
|
||
{"delete_project", false, true, true, false},
|
||
{"index_status", false, true, true, false},
|
||
{"check_index_coverage", false, true, true, false},
|
||
{"detect_changes", false, true, true, false},
|
||
{"manage_adr", false, true, false, false},
|
||
{"ingest_traces", false, false, false, false},
|
||
};
|
||
|
||
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 *tools = yyjson_obj_get(yyjson_doc_get_root(doc), "tools");
|
||
ASSERT_NOT_NULL(tools);
|
||
ASSERT_EQ(yyjson_arr_size(tools), sizeof(expected) / sizeof(expected[0]));
|
||
|
||
size_t matched = 0;
|
||
yyjson_arr_iter iter;
|
||
yyjson_arr_iter_init(tools, &iter);
|
||
yyjson_val *tool;
|
||
while ((tool = yyjson_arr_iter_next(&iter)) != NULL) {
|
||
yyjson_val *name_val = yyjson_obj_get(tool, "name");
|
||
yyjson_val *annotations = yyjson_obj_get(tool, "annotations");
|
||
ASSERT_NOT_NULL(name_val);
|
||
ASSERT_NOT_NULL(annotations);
|
||
ASSERT_TRUE(yyjson_is_obj(annotations));
|
||
|
||
const char *name = yyjson_get_str(name_val);
|
||
bool found = false;
|
||
for (size_t i = 0; i < sizeof(expected) / sizeof(expected[0]); i++) {
|
||
if (strcmp(name, expected[i].name) != 0) {
|
||
continue;
|
||
}
|
||
yyjson_val *read_only = yyjson_obj_get(annotations, "readOnlyHint");
|
||
yyjson_val *destructive = yyjson_obj_get(annotations, "destructiveHint");
|
||
yyjson_val *idempotent = yyjson_obj_get(annotations, "idempotentHint");
|
||
yyjson_val *open_world = yyjson_obj_get(annotations, "openWorldHint");
|
||
ASSERT_TRUE(yyjson_is_bool(read_only));
|
||
ASSERT_TRUE(yyjson_is_bool(destructive));
|
||
ASSERT_TRUE(yyjson_is_bool(idempotent));
|
||
ASSERT_TRUE(yyjson_is_bool(open_world));
|
||
ASSERT_EQ(yyjson_get_bool(read_only), expected[i].read_only);
|
||
ASSERT_EQ(yyjson_get_bool(destructive), expected[i].destructive);
|
||
ASSERT_EQ(yyjson_get_bool(idempotent), expected[i].idempotent);
|
||
ASSERT_EQ(yyjson_get_bool(open_world), expected[i].open_world);
|
||
found = true;
|
||
matched++;
|
||
break;
|
||
}
|
||
ASSERT_TRUE(found);
|
||
}
|
||
|
||
ASSERT_EQ(matched, sizeof(expected) / sizeof(expected[0]));
|
||
yyjson_doc_free(doc);
|
||
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", "cycles"};
|
||
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_wraps_plain_text_as_structured_content) {
|
||
char *json = cbm_mcp_text_result("plain text", false);
|
||
ASSERT_NOT_NULL(json);
|
||
ASSERT_NOT_NULL(strstr(json, "\"structuredContent\":{\"text\":\"plain text\"}"));
|
||
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, "\"structuredContent\":{\"error\":\"something failed\"}"));
|
||
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_analysis_profile_filters_and_rejects_mutators) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_mcp_server_set_tool_profile(srv, CBM_MCP_TOOL_PROFILE_ANALYSIS);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":219,\"method\":\"initialize\",\"params\":{}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "analysis tool profile"));
|
||
ASSERT_NOT_NULL(strstr(resp, "check_index_coverage"));
|
||
ASSERT_NULL(strstr(resp, "index_repository"));
|
||
free(resp);
|
||
|
||
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":220,\"method\":\"tools/list\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
static const char *const analysis_tools[] = {
|
||
"search_graph", "query_graph", "trace_path", "get_code_snippet",
|
||
"get_graph_schema", "get_architecture", "search_code", "list_projects",
|
||
"index_status", "check_index_coverage", "detect_changes",
|
||
};
|
||
ASSERT_EQ(mcp_response_tool_count(resp), sizeof(analysis_tools) / sizeof(analysis_tools[0]));
|
||
for (size_t i = 0U; i < sizeof(analysis_tools) / sizeof(analysis_tools[0]); i++) {
|
||
ASSERT_TRUE(mcp_response_has_exact_tool(resp, analysis_tools[i]));
|
||
}
|
||
free(resp);
|
||
|
||
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":221,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"delete_project\","
|
||
"\"arguments\":{\"project\":\"anything\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "not available in the analysis tool profile"));
|
||
ASSERT_NOT_NULL(strstr(resp, "isError"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(server_handle_scout_profile_exposes_only_the_fast_tier) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_mcp_server_set_tool_profile(srv, CBM_MCP_TOOL_PROFILE_SCOUT);
|
||
mcp_saw_autoindex_log = false;
|
||
cbm_log_set_sink_ex(mcp_capture_log, CBM_LOG_SINK_REPLACE);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":222,\"method\":\"initialize\",\"params\":{}}");
|
||
cbm_log_set_sink(NULL);
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "scout tool profile"));
|
||
ASSERT_NOT_NULL(strstr(resp, "check_index_coverage"));
|
||
ASSERT_NULL(strstr(resp, "index_repository"));
|
||
ASSERT_FALSE(mcp_saw_autoindex_log);
|
||
free(resp);
|
||
|
||
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":223,\"method\":\"tools/list\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_EQ(mcp_response_tool_count(resp), 7U);
|
||
ASSERT_TRUE(mcp_response_has_exact_tool(resp, "search_graph"));
|
||
ASSERT_TRUE(mcp_response_has_exact_tool(resp, "trace_path"));
|
||
ASSERT_TRUE(mcp_response_has_exact_tool(resp, "get_code_snippet"));
|
||
ASSERT_TRUE(mcp_response_has_exact_tool(resp, "get_architecture"));
|
||
ASSERT_TRUE(mcp_response_has_exact_tool(resp, "list_projects"));
|
||
ASSERT_TRUE(mcp_response_has_exact_tool(resp, "index_status"));
|
||
ASSERT_TRUE(mcp_response_has_exact_tool(resp, "check_index_coverage"));
|
||
ASSERT_FALSE(mcp_response_has_exact_tool(resp, "query_graph"));
|
||
ASSERT_FALSE(mcp_response_has_exact_tool(resp, "search_code"));
|
||
ASSERT_FALSE(mcp_response_has_exact_tool(resp, "get_graph_schema"));
|
||
ASSERT_FALSE(mcp_response_has_exact_tool(resp, "detect_changes"));
|
||
ASSERT_FALSE(mcp_response_has_exact_tool(resp, "index_repository"));
|
||
free(resp);
|
||
|
||
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":224,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"query_graph\",\"arguments\":{}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "not available in the scout tool profile"));
|
||
ASSERT_NOT_NULL(strstr(resp, "isError"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(analysis_profile_arguments_fail_closed_and_disable_http) {
|
||
cbm_mcp_tool_profile_t profile = CBM_MCP_TOOL_PROFILE_ALL;
|
||
const char *no_profile[] = {"codebase-memory-mcp"};
|
||
const char *analysis_equals[] = {"codebase-memory-mcp", "--tool-profile=analysis"};
|
||
const char *analysis_pair[] = {"codebase-memory-mcp", "--tool-profile", "analysis"};
|
||
const char *scout_equals[] = {"codebase-memory-mcp", "--tool-profile=scout"};
|
||
const char *unknown_equals[] = {"codebase-memory-mcp", "--tool-profile=analaysis"};
|
||
const char *unknown_pair[] = {"codebase-memory-mcp", "--tool-profile", "all"};
|
||
const char *missing_value[] = {"codebase-memory-mcp", "--tool-profile"};
|
||
|
||
ASSERT_EQ(cbm_mcp_parse_tool_profile_args(1, no_profile, &profile), 0);
|
||
ASSERT_EQ(profile, CBM_MCP_TOOL_PROFILE_ALL);
|
||
ASSERT_TRUE(cbm_mcp_tool_profile_allows_http(profile));
|
||
|
||
ASSERT_EQ(cbm_mcp_parse_tool_profile_args(2, analysis_equals, &profile), 0);
|
||
ASSERT_EQ(profile, CBM_MCP_TOOL_PROFILE_ANALYSIS);
|
||
ASSERT_FALSE(cbm_mcp_tool_profile_allows_http(profile));
|
||
|
||
ASSERT_EQ(cbm_mcp_parse_tool_profile_args(3, analysis_pair, &profile), 0);
|
||
ASSERT_EQ(profile, CBM_MCP_TOOL_PROFILE_ANALYSIS);
|
||
ASSERT_EQ(cbm_mcp_parse_tool_profile_args(2, scout_equals, &profile), 0);
|
||
ASSERT_EQ(profile, CBM_MCP_TOOL_PROFILE_SCOUT);
|
||
ASSERT_FALSE(cbm_mcp_tool_profile_allows_http(profile));
|
||
ASSERT_EQ(cbm_mcp_parse_tool_profile_args(2, unknown_equals, &profile), -1);
|
||
ASSERT_EQ(cbm_mcp_parse_tool_profile_args(3, unknown_pair, &profile), -1);
|
||
ASSERT_EQ(cbm_mcp_parse_tool_profile_args(2, missing_value, &profile), -1);
|
||
PASS();
|
||
}
|
||
|
||
TEST(hook_windows_path_containment_is_case_insensitive_and_segment_safe) {
|
||
ASSERT_TRUE(cbm_hook_path_contains_for_testing("C:/Repo", "c:/repo/src/main.c", true));
|
||
ASSERT_FALSE(cbm_hook_path_contains_for_testing("C:/Repo", "c:/repository/src/main.c", true));
|
||
ASSERT_FALSE(cbm_hook_path_contains_for_testing("C:/Repo", "c:/repo/src/main.c", false));
|
||
PASS();
|
||
}
|
||
|
||
TEST(server_handle_prompts_list_workflows) {
|
||
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\":203,\"method\":\"prompts/list\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"id\":203"));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"explore_codebase\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"review_change_impact\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"project\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"question\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"change\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"base_branch\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"required\":true"));
|
||
ASSERT_NULL(strstr(resp, "\"nextCursor\""));
|
||
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(server_handle_prompts_get_workflows) {
|
||
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\":204,\"method\":\"prompts/get\","
|
||
"\"params\":{\"name\":\"explore_codebase\",\"arguments\":{"
|
||
"\"project\":\"payments\",\"question\":\"How are refunds routed?\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"role\":\"user\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"type\":\"text\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "payments"));
|
||
ASSERT_NOT_NULL(strstr(resp, "How are refunds routed?"));
|
||
ASSERT_NOT_NULL(strstr(resp, "search_graph"));
|
||
ASSERT_NOT_NULL(strstr(resp, "trace_path"));
|
||
ASSERT_NOT_NULL(strstr(resp, "get_code_snippet"));
|
||
free(resp);
|
||
|
||
resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":205,\"method\":\"prompts/get\","
|
||
"\"params\":{\"name\":\"review_change_impact\",\"arguments\":{"
|
||
"\"project\":\"payments\",\"change\":\"refund retry policy\","
|
||
"\"base_branch\":\"develop\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "refund retry policy"));
|
||
ASSERT_NOT_NULL(strstr(resp, "develop"));
|
||
ASSERT_NOT_NULL(strstr(resp, "detect_changes"));
|
||
ASSERT_NOT_NULL(strstr(resp, "trace_path"));
|
||
ASSERT_NOT_NULL(strstr(resp, "include_tests"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(server_handle_prompts_get_validates_arguments) {
|
||
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\":206,\"method\":\"prompts/get\","
|
||
"\"params\":{\"name\":\"unknown\",\"arguments\":{}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"code\":-32602"));
|
||
ASSERT_NOT_NULL(strstr(resp, "Invalid prompt name"));
|
||
free(resp);
|
||
|
||
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":207,\"method\":\"prompts/get\","
|
||
"\"params\":{\"name\":\"explore_codebase\",\"arguments\":{"
|
||
"\"project\":\"payments\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"code\":-32602"));
|
||
ASSERT_NOT_NULL(strstr(resp, "Missing required prompt arguments"));
|
||
free(resp);
|
||
|
||
/* Optional means it may be omitted, not that an explicitly invalid value
|
||
* may be silently substituted. */
|
||
resp = cbm_mcp_server_handle(srv,
|
||
"{\"jsonrpc\":\"2.0\",\"id\":208,\"method\":\"prompts/get\","
|
||
"\"params\":{\"name\":\"review_change_impact\",\"arguments\":{"
|
||
"\"project\":\"payments\",\"change\":\"refund retry policy\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NULL(strstr(resp, "\"error\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "base_branch \\\"main\\\""));
|
||
free(resp);
|
||
|
||
resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":209,\"method\":\"prompts/get\","
|
||
"\"params\":{\"name\":\"review_change_impact\",\"arguments\":{"
|
||
"\"project\":\"payments\",\"change\":\"refund retry policy\","
|
||
"\"base_branch\":\"\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"code\":-32602"));
|
||
ASSERT_NOT_NULL(strstr(resp, "Invalid prompt arguments"));
|
||
free(resp);
|
||
|
||
resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":210,\"method\":\"prompts/get\","
|
||
"\"params\":{\"name\":\"review_change_impact\",\"arguments\":{"
|
||
"\"project\":\"payments\",\"change\":\"refund retry policy\","
|
||
"\"base_branch\":17}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"code\":-32602"));
|
||
ASSERT_NOT_NULL(strstr(resp, "Invalid prompt arguments"));
|
||
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);
|
||
|
||
/* callers_total/callees_total must count what the caller can enumerate: with
|
||
* include_tests=false (default) test-file rows are hidden from the table, so
|
||
* the totals must apply the same filter — a raw visited_count overstated the
|
||
* set (field-eval agent read callers_total=175 against 2 visible rows and
|
||
* distrusted the tool). */
|
||
TEST(tool_trace_totals_respect_test_filter) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
const char *proj = "totproj";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, "/tmp/tot");
|
||
|
||
cbm_node_t tgt = {.project = proj,
|
||
.label = "Function",
|
||
.name = "tgt",
|
||
.qualified_name = "totproj.a.tgt",
|
||
.file_path = "a.c",
|
||
.start_line = 1,
|
||
.end_line = 5};
|
||
int64_t tid = cbm_store_upsert_node(st, &tgt);
|
||
ASSERT_GT(tid, 0);
|
||
cbm_node_t prod = {.project = proj,
|
||
.label = "Function",
|
||
.name = "prod_caller",
|
||
.qualified_name = "totproj.a.prod_caller",
|
||
.file_path = "a.c",
|
||
.start_line = 10,
|
||
.end_line = 15};
|
||
int64_t pid = cbm_store_upsert_node(st, &prod);
|
||
ASSERT_GT(pid, 0);
|
||
cbm_node_t tst = {.project = proj,
|
||
.label = "Function",
|
||
.name = "test_caller",
|
||
.qualified_name = "totproj.t.test_caller",
|
||
.file_path = "tests/test_x.c",
|
||
.start_line = 1,
|
||
.end_line = 5};
|
||
int64_t xid = cbm_store_upsert_node(st, &tst);
|
||
ASSERT_GT(xid, 0);
|
||
cbm_edge_t e1 = {.project = proj, .source_id = pid, .target_id = tid, .type = "CALLS"};
|
||
ASSERT_GT(cbm_store_insert_edge(st, &e1), 0);
|
||
cbm_edge_t e2 = {.project = proj, .source_id = xid, .target_id = tid, .type = "CALLS"};
|
||
ASSERT_GT(cbm_store_insert_edge(st, &e2), 0);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":90,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"totproj\","
|
||
"\"function_name\":\"tgt\",\"direction\":\"inbound\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
free(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "callers_total: 1")); /* test row filtered */
|
||
free(inner);
|
||
|
||
resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"totproj\","
|
||
"\"function_name\":\"tgt\",\"direction\":\"inbound\",\"include_tests\":true}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
inner = extract_text_content(resp);
|
||
free(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "callers_total: 2")); /* both visible now */
|
||
free(inner);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* SCC condensation (get_architecture aspect "cycles"): a 3-function CALLS
|
||
* cycle A->B->C->A must be reported as one circular dependency of size 3 with
|
||
* all three members; a separate acyclic chain (D->E) must NOT appear. The
|
||
* aspect is opt-in — a default get_architecture call must NOT compute it. */
|
||
TEST(tool_get_architecture_cycles_detects_scc) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
const char *proj = "cycproj";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, "/tmp/cyc");
|
||
|
||
const char *names[5] = {"A", "B", "C", "D", "E"};
|
||
int64_t id[5];
|
||
for (int i = 0; i < 5; i++) {
|
||
char qn[32];
|
||
snprintf(qn, sizeof(qn), "cycproj.m.%s", names[i]);
|
||
cbm_node_t n = {.project = proj,
|
||
.label = "Function",
|
||
.name = names[i],
|
||
.qualified_name = qn,
|
||
.file_path = "m.c",
|
||
.start_line = i + 1,
|
||
.end_line = i + 2};
|
||
id[i] = cbm_store_upsert_node(st, &n);
|
||
ASSERT_GT(id[i], 0);
|
||
}
|
||
/* cycle A->B->C->A, plus acyclic D->E */
|
||
struct {
|
||
int f;
|
||
int t;
|
||
} e[] = {{0, 1}, {1, 2}, {2, 0}, {3, 4}};
|
||
for (size_t i = 0; i < sizeof(e) / sizeof(e[0]); i++) {
|
||
cbm_edge_t ed = {
|
||
.project = proj, .source_id = id[e[i].f], .target_id = id[e[i].t], .type = "CALLS"};
|
||
ASSERT_GT(cbm_store_insert_edge(st, &ed), 0);
|
||
}
|
||
|
||
/* opt-in cycles aspect */
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"get_architecture\",\"arguments\":{\"project\":\"cycproj\","
|
||
"\"aspects\":[\"cycles\"]}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "cycles: 1")); /* exactly one SCC of size>1 */
|
||
ASSERT_NOT_NULL(strstr(inner, "cycproj.m.A"));
|
||
ASSERT_NOT_NULL(strstr(inner, "cycproj.m.B"));
|
||
ASSERT_NOT_NULL(strstr(inner, "cycproj.m.C"));
|
||
ASSERT_NULL(strstr(inner, "cycproj.m.D")); /* acyclic node not in any cycle */
|
||
free(inner);
|
||
free(resp);
|
||
|
||
/* default call (no aspects) must NOT run the scan. */
|
||
resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"get_architecture\",\"arguments\":{\"project\":\"cycproj\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NULL(strstr(inner, "cycles:"));
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* Context-bomb guard: get_code_snippet on a whole-file node (a Module/File
|
||
* span) used to read the ENTIRE file into one response — a field-eval agent
|
||
* that fell back to a Module snippet pulled ~400KB in a single call. The read
|
||
* must clip at MCP_SNIPPET_MAX_LINES and flag source_clipped, while the exact
|
||
* start/end range stays in the response for a targeted re-read. */
|
||
TEST(tool_get_code_snippet_clips_whole_file_node) {
|
||
char tmp[256];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_snipcap_XXXXXX");
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||
char proj_dir[512];
|
||
snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp);
|
||
cbm_mkdir(proj_dir);
|
||
char src_path[600];
|
||
snprintf(src_path, sizeof(src_path), "%s/big.py", proj_dir);
|
||
FILE *fp = fopen(src_path, "w");
|
||
ASSERT_NOT_NULL(fp);
|
||
enum { BIG_LINES = 2000 };
|
||
for (int i = 0; i < BIG_LINES; i++) {
|
||
fprintf(fp, "line_%04d = %d # padding to blow up an unclipped read\n", i, i);
|
||
}
|
||
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);
|
||
const char *proj = "test-project";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, proj_dir);
|
||
|
||
cbm_node_t mod = {0};
|
||
mod.project = proj;
|
||
mod.label = "Module";
|
||
mod.name = "big";
|
||
mod.qualified_name = "test-project.big";
|
||
mod.file_path = "big.py";
|
||
mod.start_line = 1;
|
||
mod.end_line = BIG_LINES;
|
||
ASSERT_GT(cbm_store_upsert_node(st, &mod), 0);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"get_code_snippet\",\"arguments\":{\"project\":\"test-project\","
|
||
"\"qualified_name\":\"test-project.big\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "\"source_clipped\":true"));
|
||
/* The whole 2000-line file (~100KB) must NOT be in the response. */
|
||
ASSERT_TRUE(strlen(inner) < 60000);
|
||
/* The last line must be absent (clipped), the first present. */
|
||
ASSERT_NOT_NULL(strstr(inner, "line_0000"));
|
||
ASSERT_NULL(strstr(inner, "line_1999"));
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
th_rmtree(tmp);
|
||
PASS();
|
||
}
|
||
|
||
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);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"structuredContent\":{\"text\":"));
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "results:")); /* TOON table header */
|
||
ASSERT_NOT_NULL(strstr(inner, "(rows: name label 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, "(rows: name label lines in out signature;"));
|
||
/* values with spaces are QUOTED so column positions survive */
|
||
ASSERT_NOT_NULL(strstr(inner, "\"func HandleRequest() error\""));
|
||
ASSERT_NOT_NULL(strstr(inner, "func HandleRequest"));
|
||
free(inner);
|
||
free(resp);
|
||
|
||
/* format:"json" = json-stringified tree: same grouped model, column-
|
||
* ordered row arrays — never per-row key envelopes or property blobs.
|
||
* fields adds columns there too. */
|
||
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\","
|
||
"\"fields\":[\"signature\"],\"limit\":5}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "\"qn_prefix\"")); /* grouped tree model */
|
||
ASSERT_NOT_NULL(strstr(inner, "\"cols\""));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"rows\""));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"signature\"")); /* requested column */
|
||
ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); /* its value */
|
||
ASSERT_NULL(strstr(inner, "is_exported")); /* blob never spills */
|
||
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_lean_defaults_schema_and_status) {
|
||
/* GUARDS for the lean-default contract (TOON round 2):
|
||
* 1. get_graph_schema must not advertise the blocked internal fields
|
||
* (fp/sp/bt) — the server refuses to emit them, so listing them in the
|
||
* schema invited agents to request fields they can never get.
|
||
* 2. index_status omits the git context block unless verbose:true — the
|
||
* worktree/shadow path variants only matter when debugging where an
|
||
* index lives. */
|
||
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);
|
||
|
||
cbm_node_t n = {0};
|
||
n.project = "test-project";
|
||
n.label = "Function";
|
||
n.name = "schemaCarrier";
|
||
n.qualified_name = "test-project.src.schemaCarrier";
|
||
n.file_path = "src/sc.go";
|
||
n.start_line = 1;
|
||
n.end_line = 2;
|
||
n.properties_json = "{\"fp\":\"x\",\"sp\":\"y\",\"bt\":\"z\",\"complexity\":3}";
|
||
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":48,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_graph_schema\","
|
||
"\"arguments\":{\"project\":\"test-project\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "Function")); /* non-vacuous: label present */
|
||
ASSERT_NOT_NULL(strstr(inner, "complexity")); /* obtainable property listed */
|
||
ASSERT_NULL(strstr(inner, "\"fp\"")); /* blocked fields not advertised */
|
||
ASSERT_NULL(strstr(inner, "\"sp\""));
|
||
ASSERT_NULL(strstr(inner, "\"bt\""));
|
||
free(inner);
|
||
free(resp);
|
||
|
||
/* index_status: no git block by default... */
|
||
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":49,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"index_status\","
|
||
"\"arguments\":{\"project\":\"test-project\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "\"status\""));
|
||
ASSERT_NULL(strstr(inner, "\"git\""));
|
||
free(inner);
|
||
free(resp);
|
||
|
||
/* ...and present with verbose:true. */
|
||
resp = cbm_mcp_server_handle(srv,
|
||
"{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"index_status\","
|
||
"\"arguments\":{\"project\":\"test-project\",\"verbose\":true}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "\"git\""));
|
||
free(inner);
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── Tool-output regression suite (gating) ──────────────────────────
|
||
* Context-explosion detector: flags the measured smells that re-introduce
|
||
* token bloat into default outputs, independent of any specific tool:
|
||
* 1. blocked internal fields (fp/sp/bt) appearing anywhere;
|
||
* 2. repeated-key JSON envelopes — the same key emitted per row instead of
|
||
* a header-once table (the un-TOONed enumeration smell; detect_changes
|
||
* shipped 4,787x3 of these = 416KB);
|
||
* 3. embedded prose notes/hints beyond one line (~220 chars) — long prose
|
||
* belongs in tool descriptions or docs, not repeated per response.
|
||
* Returns NULL when clean, else a static description of the violation. */
|
||
static const char *output_explosion_smell(const char *inner) {
|
||
static const char *row_keys[] = {
|
||
"\"name\":", "\"label\":", "\"file\":", "\"path\":", "\"qualified_name\":", "\"qn\":"};
|
||
if (strstr(inner, "\"fp\":") || strstr(inner, "\"sp\":") || strstr(inner, "\"bt\":")) {
|
||
return "blocked internal field (fp/sp/bt) leaked into output";
|
||
}
|
||
for (size_t k = 0; k < sizeof(row_keys) / sizeof(row_keys[0]); k++) {
|
||
int n = 0;
|
||
for (const char *p = strstr(inner, row_keys[k]); p && n <= 32;
|
||
p = strstr(p + 1, row_keys[k])) {
|
||
n++;
|
||
}
|
||
if (n > 32) {
|
||
return "repeated-key envelope (>32x same JSON key) — emit a header-once table";
|
||
}
|
||
}
|
||
for (const char *p = strstr(inner, "\"note\":\""); p; p = strstr(p + 1, "\"note\":\"")) {
|
||
const char *end = strchr(p + 9, '"');
|
||
while (end && end[-1] == '\\') {
|
||
end = strchr(end + 1, '"');
|
||
}
|
||
if (end && end - (p + 9) > 220) {
|
||
return "embedded note exceeds one line (~220 chars)";
|
||
}
|
||
}
|
||
return NULL;
|
||
}
|
||
|
||
/* Run one tool call on the fixture server, apply the explosion detector and
|
||
* an absolute byte ceiling, and require a semantic-floor marker so trimming
|
||
* can never hollow the response out either. */
|
||
static const char *check_tool_output(cbm_mcp_server_t *srv, const char *req, int ceiling,
|
||
const char *floor_marker) {
|
||
char *resp = cbm_mcp_server_handle(srv, req);
|
||
if (!resp) {
|
||
return "no response";
|
||
}
|
||
char *inner = extract_text_content(resp);
|
||
free(resp);
|
||
if (!inner) {
|
||
return "no text content";
|
||
}
|
||
static char why[256];
|
||
const char *smell = output_explosion_smell(inner);
|
||
if (smell) {
|
||
snprintf(why, sizeof(why), "%s", smell);
|
||
free(inner);
|
||
return why;
|
||
}
|
||
if ((int)strlen(inner) >= ceiling) {
|
||
snprintf(why, sizeof(why), "output %d B >= ceiling %d B", (int)strlen(inner), ceiling);
|
||
free(inner);
|
||
return why;
|
||
}
|
||
if (floor_marker && !strstr(inner, floor_marker)) {
|
||
snprintf(why, sizeof(why), "semantic floor missing: %s", floor_marker);
|
||
free(inner);
|
||
return why;
|
||
}
|
||
free(inner);
|
||
return NULL;
|
||
}
|
||
|
||
TEST(tool_output_regression_gate) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
struct {
|
||
const char *req;
|
||
int ceiling;
|
||
const char *floor;
|
||
} cases[] = {
|
||
{"{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\","
|
||
"\"name_pattern\":\".*\",\"limit\":50}}}",
|
||
6000, "results:"},
|
||
{"{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"get_graph_schema\",\"arguments\":{\"project\":\"test-project\"}}}",
|
||
6000, "node_labels"},
|
||
{"{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"index_status\",\"arguments\":{\"project\":\"test-project\"}}}",
|
||
7000, "\"status\""},
|
||
{"{\"jsonrpc\":\"2.0\",\"id\":73,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"test-project\","
|
||
"\"function_name\":\"HandleRequest\",\"direction\":\"both\"}}}",
|
||
1500, "callees:"},
|
||
};
|
||
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
|
||
const char *why = check_tool_output(srv, cases[i].req, cases[i].ceiling, cases[i].floor);
|
||
if (why) {
|
||
char msg[320];
|
||
snprintf(msg, sizeof(msg), "case %d: %s", (int)i, why);
|
||
FAIL(msg);
|
||
}
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
/* Resource discovery methods this server doesn't populate must return EMPTY
|
||
* lists, not -32601 Method-not-found: clients like Cline probe them on connect
|
||
* and surface the errors as a failed connection (#958). */
|
||
TEST(mcp_resource_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\":[]"},
|
||
{"resources/templates/list", "\"resourceTemplates\":[]"},
|
||
};
|
||
for (int i = 0; i < 2; 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();
|
||
}
|
||
|
||
/* Reproduce the exact-file false negative in the current Read hook: index_status
|
||
* intentionally caps each coverage category at 500 entries, so a later path is
|
||
* absent even though the authoritative index_coverage table contains it. The
|
||
* targeted coverage tool must query that table rather than scan the capped
|
||
* presentation response. */
|
||
TEST(tool_check_index_coverage_finds_path_beyond_status_cap) {
|
||
enum { ROW_COUNT = 502 };
|
||
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 *project = "coverage-cap-regression";
|
||
ASSERT_EQ(cbm_store_upsert_project(st, project, "/tmp/coverage-cap-regression"), CBM_STORE_OK);
|
||
cbm_mcp_server_set_project(srv, project);
|
||
|
||
char (*paths)[64] = calloc(ROW_COUNT, sizeof(*paths));
|
||
cbm_coverage_row_t *rows = calloc(ROW_COUNT, sizeof(*rows));
|
||
ASSERT_NOT_NULL(paths);
|
||
ASSERT_NOT_NULL(rows);
|
||
for (int i = 0; i < ROW_COUNT; i++) {
|
||
snprintf(paths[i], sizeof(paths[i]), "src/partial-%04d.c", i);
|
||
rows[i].rel_path = paths[i];
|
||
rows[i].kind = "parse_partial";
|
||
rows[i].detail = i == ROW_COUNT - 1 ? "777-790" : "1-2";
|
||
ASSERT_EQ(cbm_store_upsert_file_hash(st, project, paths[i], "fixture", i + 1, 10),
|
||
CBM_STORE_OK);
|
||
}
|
||
ASSERT_EQ(cbm_store_coverage_replace(st, project, rows, ROW_COUNT), CBM_STORE_OK);
|
||
|
||
char *status =
|
||
cbm_mcp_handle_tool(srv, "index_status", "{\"project\":\"coverage-cap-regression\"}");
|
||
ASSERT_NOT_NULL(status);
|
||
char *status_inner = extract_text_content(status);
|
||
ASSERT_NOT_NULL(status_inner);
|
||
ASSERT_NOT_NULL(strstr(status_inner, "\"truncated\":true"));
|
||
ASSERT_NULL(strstr(status_inner, "src/partial-0501.c"));
|
||
free(status_inner);
|
||
free(status);
|
||
|
||
char *coverage = cbm_mcp_handle_tool(
|
||
srv, "check_index_coverage",
|
||
"{\"project\":\"coverage-cap-regression\",\"paths\":[\"src/partial-0501.c\"]}");
|
||
ASSERT_NOT_NULL(coverage);
|
||
char *coverage_inner = extract_text_content(coverage);
|
||
ASSERT_NOT_NULL(coverage_inner);
|
||
ASSERT_NOT_NULL(strstr(coverage_inner, "src/partial-0501.c"));
|
||
ASSERT_NOT_NULL(strstr(coverage_inner, "\"status\":\"partial\""));
|
||
ASSERT_NOT_NULL(strstr(coverage_inner, "777-790"));
|
||
|
||
free(coverage_inner);
|
||
free(coverage);
|
||
free(rows);
|
||
free(paths);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_check_index_coverage_reports_paths_scopes_and_ranges) {
|
||
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);
|
||
|
||
ASSERT_EQ(cbm_store_upsert_file_hash(st, "test-project", "main.go", "", 0, 0), CBM_STORE_OK);
|
||
ASSERT_EQ(cbm_store_upsert_file_hash(st, "test-project", "src/skip.c", "", 0, 0), CBM_STORE_OK);
|
||
cbm_coverage_row_t rows[] = {
|
||
{.rel_path = "main.go", .kind = "parse_partial", .detail = "3-4,9"},
|
||
{.rel_path = "generated", .kind = "not_indexed_dir", .detail = "excluded subtree"},
|
||
{.rel_path = "src/skip.c", .kind = "oversized", .detail = "file exceeds cap"},
|
||
};
|
||
ASSERT_EQ(cbm_store_coverage_replace(st, "test-project", rows, 3), CBM_STORE_OK);
|
||
|
||
char *coverage =
|
||
cbm_mcp_handle_tool(srv, "check_index_coverage",
|
||
"{\"project\":\"test-project\","
|
||
"\"paths\":[\"main.go\",\"generated/pkg/a.c\",\"../escape.c\"],"
|
||
"\"scopes\":[\".\"]}");
|
||
ASSERT_NOT_NULL(coverage);
|
||
char *inner = extract_text_content(coverage);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "\"path\":\"main.go\""));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"status\":\"partial\""));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"start\":3"));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"end\":4"));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"start\":9"));
|
||
ASSERT_NOT_NULL(strstr(inner, "generated/pkg/a.c"));
|
||
ASSERT_NOT_NULL(strstr(inner, "not_indexed_dir"));
|
||
ASSERT_NOT_NULL(strstr(inner, "outside_project"));
|
||
ASSERT_NOT_NULL(strstr(inner, "src/skip.c"));
|
||
ASSERT_NOT_NULL(strstr(inner, "file exceeds cap"));
|
||
ASSERT_NOT_NULL(strstr(inner, "best_effort"));
|
||
|
||
free(inner);
|
||
free(coverage);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
static int write_coverage_meta(cbm_store_t *store, const char *generation,
|
||
const char *recording_status) {
|
||
cbm_coverage_meta_t meta = {
|
||
.generation = generation,
|
||
.index_mode = "fast",
|
||
.recorded_at = "2026-07-12T00:00:00Z",
|
||
.recording_status = recording_status,
|
||
.ignored_files_stored = 0,
|
||
.ignored_files_total = 0,
|
||
.coverage_version = 1,
|
||
.hash_records_complete = true,
|
||
};
|
||
return cbm_store_coverage_replace_ex(store, "test-project", NULL, 0, &meta);
|
||
}
|
||
|
||
TEST(tool_check_index_coverage_rejects_stale_generation) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(store);
|
||
ASSERT_EQ(write_coverage_meta(store, "stale-generation", "complete"), CBM_STORE_OK);
|
||
|
||
char *response = cbm_mcp_handle_tool(srv, "check_index_coverage",
|
||
"{\"project\":\"test-project\",\"paths\":[\"main.go\"]}");
|
||
ASSERT_NOT_NULL(response);
|
||
char *inner = extract_text_content(response);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "\"generation_matches\":false"));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"status\":\"coverage_unavailable\""));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"read_source_and_reindex\""));
|
||
|
||
free(inner);
|
||
free(response);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_check_index_coverage_requires_source_when_file_metadata_changed) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(store);
|
||
cbm_project_t project = {0};
|
||
ASSERT_EQ(cbm_store_get_project(store, "test-project", &project), CBM_STORE_OK);
|
||
ASSERT_EQ(write_coverage_meta(store, project.indexed_at, "complete"), CBM_STORE_OK);
|
||
cbm_project_free_fields(&project);
|
||
ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "fixture", 0, 0),
|
||
CBM_STORE_OK);
|
||
|
||
char *response = cbm_mcp_handle_tool(srv, "check_index_coverage",
|
||
"{\"project\":\"test-project\",\"paths\":[\"main.go\"]}");
|
||
ASSERT_NOT_NULL(response);
|
||
char *inner = extract_text_content(response);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "\"generation_matches\":true"));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"freshness\":\"metadata_changed\""));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"recommended_action\":\"read_source_and_reindex\""));
|
||
|
||
free(inner);
|
||
free(response);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_check_index_coverage_surfaces_lookup_errors) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(store);
|
||
cbm_project_t project = {0};
|
||
ASSERT_EQ(cbm_store_get_project(store, "test-project", &project), CBM_STORE_OK);
|
||
ASSERT_EQ(write_coverage_meta(store, project.indexed_at, "complete"), CBM_STORE_OK);
|
||
cbm_project_free_fields(&project);
|
||
ASSERT_EQ(
|
||
cbm_store_exec(store, "ALTER TABLE index_coverage RENAME COLUMN detail TO broken_detail;"),
|
||
CBM_STORE_OK);
|
||
|
||
char *response = cbm_mcp_handle_tool(
|
||
srv, "check_index_coverage",
|
||
"{\"project\":\"test-project\",\"paths\":[\"main.go\"],\"scopes\":[\".\"]}");
|
||
ASSERT_NOT_NULL(response);
|
||
char *inner = extract_text_content(response);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "\"coverage_lookup\":\"error\""));
|
||
ASSERT_NOT_NULL(strstr(inner, "\"status\":\"coverage_unavailable\""));
|
||
ASSERT_NULL(strstr(inner, "\"status\":\"no_recorded_issue\""));
|
||
|
||
free(inner);
|
||
free(response);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_index_status_includes_git_metadata) {
|
||
/* The git context block moved behind verbose:true (lean-default contract,
|
||
* TOON round 2) — this test pins the verbose path's content; the default-
|
||
* omission guard lives in tool_lean_defaults_schema_and_status. */
|
||
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\",\"verbose\":true}}}");
|
||
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();
|
||
}
|
||
|
||
/* Multi-seed union hop semantics: bfs_union_same_name deduped visited nodes
|
||
* keep-FIRST-seen, so a node reached at hop 2 from the first seed kept hop 2
|
||
* even when the second seed reaches it at hop 1. hop feeds risk_labels and
|
||
* (soon) pagination watermarks — it must be the MINIMUM across seeds, matching
|
||
* the single-BFS MIN(hop) semantics (#797). */
|
||
TEST(tool_trace_union_records_min_hop_across_seeds) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
const char *proj = "dualproj";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, "/tmp/dual");
|
||
|
||
/* One real definition + one body-less stub (start==end) — the #546/#650
|
||
* shape pick_resolved_node resolves WITHOUT ambiguity while
|
||
* bfs_union_same_name still traverses both. Seed A (real def, lower id,
|
||
* traversed first) reaches tgt only via mid (hop 2); the stub seed B
|
||
* reaches tgt directly (hop 1). */
|
||
cbm_node_t sa = {.project = proj,
|
||
.label = "Function",
|
||
.name = "dual",
|
||
.qualified_name = "dualproj.a.dual",
|
||
.file_path = "a.c",
|
||
.start_line = 1,
|
||
.end_line = 50};
|
||
cbm_node_t sb = {.project = proj,
|
||
.label = "Function",
|
||
.name = "dual",
|
||
.qualified_name = "dualproj.b.dual",
|
||
.file_path = "b.d.ts",
|
||
.start_line = 1,
|
||
.end_line = 1};
|
||
cbm_node_t mid = {.project = proj,
|
||
.label = "Function",
|
||
.name = "mid",
|
||
.qualified_name = "dualproj.c.mid",
|
||
.file_path = "c.c",
|
||
.start_line = 1,
|
||
.end_line = 5};
|
||
cbm_node_t tgt = {.project = proj,
|
||
.label = "Function",
|
||
.name = "tgt",
|
||
.qualified_name = "dualproj.c.tgt",
|
||
.file_path = "c.c",
|
||
.start_line = 10,
|
||
.end_line = 15};
|
||
int64_t ida = cbm_store_upsert_node(st, &sa);
|
||
int64_t idb = cbm_store_upsert_node(st, &sb);
|
||
int64_t idm = cbm_store_upsert_node(st, &mid);
|
||
int64_t idt = cbm_store_upsert_node(st, &tgt);
|
||
ASSERT_GT(ida, 0);
|
||
ASSERT_GT(idb, 0);
|
||
ASSERT_GT(idm, 0);
|
||
ASSERT_GT(idt, 0);
|
||
cbm_edge_t e1 = {.project = proj, .source_id = ida, .target_id = idm, .type = "CALLS"};
|
||
cbm_edge_t e2 = {.project = proj, .source_id = idm, .target_id = idt, .type = "CALLS"};
|
||
cbm_edge_t e3 = {.project = proj, .source_id = idb, .target_id = idt, .type = "CALLS"};
|
||
ASSERT_GT(cbm_store_insert_edge(st, &e1), 0);
|
||
ASSERT_GT(cbm_store_insert_edge(st, &e2), 0);
|
||
ASSERT_GT(cbm_store_insert_edge(st, &e3), 0);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"trace_call_path\","
|
||
"\"arguments\":{\"function_name\":\"dual\",\"project\":\"dualproj\","
|
||
"\"direction\":\"outbound\",\"depth\":3}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
/* tgt is one hop from seed B — the union must record hop 1, not seed A's 2. */
|
||
ASSERT_NOT_NULL(strstr(inner, " tgt 1"));
|
||
ASSERT_NULL(strstr(inner, " tgt 2"));
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* Exactly-once trace pagination: 12 callees paged at limit=5 must yield
|
||
* 5+5+2 rows with every callee appearing on exactly one page, exact totals
|
||
* on every page, and a final page without a cursor. Stale and mismatched
|
||
* cursors must fail with teaching errors, never silently restart. */
|
||
TEST(tool_trace_pagination_exactly_once) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
const char *proj = "pageproj";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, "/tmp/page");
|
||
|
||
cbm_node_t hub = {.project = proj,
|
||
.label = "Function",
|
||
.name = "hub",
|
||
.qualified_name = "pageproj.h.hub",
|
||
.file_path = "h.c",
|
||
.start_line = 1,
|
||
.end_line = 9};
|
||
int64_t hid = cbm_store_upsert_node(st, &hub);
|
||
ASSERT_GT(hid, 0);
|
||
enum { CALLEES = 12 };
|
||
for (int i = 0; i < CALLEES; i++) {
|
||
char nm[16];
|
||
char qn[48];
|
||
snprintf(nm, sizeof(nm), "c%02d", i);
|
||
snprintf(qn, sizeof(qn), "pageproj.m.c%02d", i);
|
||
cbm_node_t n = {.project = proj,
|
||
.label = "Function",
|
||
.name = nm,
|
||
.qualified_name = qn,
|
||
.file_path = "m.c",
|
||
.start_line = 1,
|
||
.end_line = 3};
|
||
int64_t nid = cbm_store_upsert_node(st, &n);
|
||
ASSERT_GT(nid, 0);
|
||
cbm_edge_t e = {.project = proj, .source_id = hid, .target_id = nid, .type = "CALLS"};
|
||
ASSERT_GT(cbm_store_insert_edge(st, &e), 0);
|
||
}
|
||
|
||
char pages[3][4096];
|
||
char tok[192] = "";
|
||
int npages = 0;
|
||
for (; npages < 3; npages++) {
|
||
char req[640];
|
||
if (tok[0]) {
|
||
snprintf(req, sizeof(req),
|
||
"{\"jsonrpc\":\"2.0\",\"id\":80,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\","
|
||
"\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5,"
|
||
"\"cursor\":\"%s\"}}}",
|
||
tok);
|
||
} else {
|
||
snprintf(req, sizeof(req),
|
||
"{\"jsonrpc\":\"2.0\",\"id\":80,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\","
|
||
"\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5}}}");
|
||
}
|
||
char *resp = cbm_mcp_server_handle(srv, req);
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
free(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
snprintf(pages[npages], sizeof(pages[npages]), "%s", inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "callees_total: 12")); /* exact total, every page */
|
||
const char *nx = strstr(inner, "next: ");
|
||
if (nx) {
|
||
const char *e = strchr(nx + 6, '\n');
|
||
size_t tl = e ? (size_t)(e - (nx + 6)) : strlen(nx + 6);
|
||
ASSERT_TRUE(tl < sizeof(tok));
|
||
memcpy(tok, nx + 6, tl);
|
||
tok[tl] = '\0';
|
||
} else {
|
||
tok[0] = '\0';
|
||
}
|
||
free(inner);
|
||
if (!tok[0]) {
|
||
npages++;
|
||
break;
|
||
}
|
||
}
|
||
ASSERT_EQ(npages, 3); /* 5 + 5 + 2 */
|
||
/* Exactly-once: every callee appears on exactly ONE page. */
|
||
for (int i = 0; i < CALLEES; i++) {
|
||
char qn[48];
|
||
snprintf(qn, sizeof(qn), " c%02d 1\n", i);
|
||
int seen = 0;
|
||
for (int p = 0; p < 3; p++) {
|
||
if (strstr(pages[p], qn)) {
|
||
seen++;
|
||
}
|
||
}
|
||
ASSERT_EQ(seen, 1);
|
||
}
|
||
/* Final page carries no cursor. */
|
||
ASSERT_NULL(strstr(pages[2], "next: "));
|
||
|
||
/* Params mismatch: replay a page-2-era cursor with a different depth. */
|
||
const char *nx1 = strstr(pages[0], "next: ");
|
||
ASSERT_NOT_NULL(nx1);
|
||
char tok1[192];
|
||
const char *e1 = strchr(nx1 + 6, '\n');
|
||
size_t tl1 = e1 ? (size_t)(e1 - (nx1 + 6)) : strlen(nx1 + 6);
|
||
memcpy(tok1, nx1 + 6, tl1);
|
||
tok1[tl1] = '\0';
|
||
char req2[640];
|
||
snprintf(req2, sizeof(req2),
|
||
"{\"jsonrpc\":\"2.0\",\"id\":81,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\","
|
||
"\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5,\"depth\":2,"
|
||
"\"cursor\":\"%s\"}}}",
|
||
tok1);
|
||
char *resp = cbm_mcp_server_handle(srv, req2);
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
free(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "cursor_params_mismatch"));
|
||
free(inner);
|
||
|
||
/* Stale: an index run (upsert_project bumps the generation) invalidates
|
||
* outstanding cursors with a loud, actionable error. */
|
||
cbm_store_upsert_project(st, proj, "/tmp/page");
|
||
snprintf(req2, sizeof(req2),
|
||
"{\"jsonrpc\":\"2.0\",\"id\":82,\"method\":\"tools/call\",\"params\":{"
|
||
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\","
|
||
"\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5,"
|
||
"\"cursor\":\"%s\"}}}",
|
||
tok1);
|
||
resp = cbm_mcp_server_handle(srv, req2);
|
||
ASSERT_NOT_NULL(resp);
|
||
inner = extract_text_content(resp);
|
||
free(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "stale_cursor"));
|
||
free(inner);
|
||
|
||
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_delete_project_mutation_guard_blocks_then_releases) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-delete-guard-XXXXXX");
|
||
if (!cbm_mkdtemp(cache)) {
|
||
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);
|
||
|
||
const char *project = "guard-delete-project";
|
||
char db_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
cbm_store_t *setup = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(setup);
|
||
ASSERT_EQ(cbm_store_upsert_project(setup, project, "/tmp/guard-delete-project"), CBM_STORE_OK);
|
||
cbm_store_close(setup);
|
||
ASSERT_TRUE(cbm_file_exists(db_path));
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
|
||
char *resp =
|
||
cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"guard-delete-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "blocked"));
|
||
ASSERT_EQ(probe.begin_count, 1);
|
||
ASSERT_EQ(probe.end_count, 0);
|
||
ASSERT_STR_EQ(probe.begin_projects[0], project);
|
||
ASSERT_TRUE(cbm_file_exists(db_path));
|
||
free(resp);
|
||
|
||
probe.deny_begin_call = 0;
|
||
resp = cbm_mcp_handle_tool(srv, "delete_project", "{\"project\":\"guard-delete-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "deleted"));
|
||
ASSERT_EQ(probe.begin_count, 2);
|
||
ASSERT_EQ(probe.end_count, 1);
|
||
ASSERT_STR_EQ(probe.begin_projects[1], project);
|
||
ASSERT_STR_EQ(probe.end_projects[0], project);
|
||
ASSERT_FALSE(cbm_file_exists(db_path));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_project_db(cache, project);
|
||
cbm_rmdir(cache);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_index_repository_mutation_guard_blocks_before_local_worker) {
|
||
char root[CBM_SZ_1K];
|
||
(void)snprintf(root, sizeof(root), "%s/cbm-index-guard-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(root));
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
|
||
char args[CBM_SZ_2K];
|
||
(void)snprintf(args, sizeof(args),
|
||
"{\"repo_path\":\"%s\",\"name\":\"GuardedIndex\","
|
||
"\"mode\":\"fast\"}",
|
||
root);
|
||
int spawn_before = cbm_index_supervisor_spawn_count();
|
||
char *response = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
int spawn_after = cbm_index_supervisor_spawn_count();
|
||
|
||
ASSERT_NOT_NULL(response);
|
||
ASSERT_NOT_NULL(strstr(response, "blocked"));
|
||
ASSERT_EQ(probe.begin_count, 1);
|
||
ASSERT_EQ(probe.end_count, 0);
|
||
ASSERT_STR_EQ(probe.begin_projects[0], "GuardedIndex");
|
||
ASSERT_EQ(spawn_after, spawn_before);
|
||
|
||
free(response);
|
||
cbm_mcp_server_free(srv);
|
||
(void)th_rmtree(root);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_get_architecture_empty) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":24,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_architecture\","
|
||
"\"arguments\":{\"project\":\"nonexistent\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
/* No store for nonexistent project — should return project error */
|
||
ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* Regression for #281: handle_get_architecture must actually call
|
||
* cbm_store_get_architecture and surface its sections. Before the fix
|
||
* only label/edge histograms were emitted regardless of which aspects
|
||
* were requested. The store-side arch_entry_points query reads
|
||
* properties.is_entry_point on Function nodes, so we tag one node and
|
||
* assert the resulting JSON surfaces an "entry_points" array containing
|
||
* the tagged function — which is impossible without the wiring. */
|
||
TEST(tool_get_architecture_emits_populated_sections) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(st);
|
||
|
||
const char *proj = "arch-test";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, "/tmp/arch-test");
|
||
|
||
cbm_node_t main_fn = {0};
|
||
main_fn.project = proj;
|
||
main_fn.label = "Function";
|
||
main_fn.name = "main";
|
||
main_fn.qualified_name = "arch-test.cmd.main";
|
||
main_fn.file_path = "cmd/main.go";
|
||
main_fn.start_line = 1;
|
||
main_fn.end_line = 3;
|
||
main_fn.properties_json = "{\"is_entry_point\":true}";
|
||
ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_architecture\","
|
||
"\"arguments\":{\"project\":\"arch-test\",\"aspects\":[\"all\"]}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
|
||
/* The handler always emits node/edge counts and schema histograms;
|
||
* those existed before #281. The "entry_points" array only appears
|
||
* when cbm_store_get_architecture is actually called and its result
|
||
* is serialized — which is exactly what #281 wires up. */
|
||
ASSERT_NOT_NULL(strstr(inner, "entry_points:"));
|
||
ASSERT_NOT_NULL(strstr(inner, "main"));
|
||
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* Distills PR #560 (overview subset): "overview" must expand to a compact
|
||
* subset — every aspect EXCEPT file_tree. Before the fix, "overview" was not
|
||
* registered in either aspect gate (want_aspect in store.c, aspect_wanted in
|
||
* mcp.c), so aspects=["overview"] silently degraded to just
|
||
* {total_nodes,total_edges}. RED on unfixed code: no "entry_points" key. */
|
||
TEST(tool_get_architecture_overview_compact_subset_pr560) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(st);
|
||
|
||
const char *proj = "arch560";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, "/tmp/arch560");
|
||
|
||
cbm_node_t main_fn = {0};
|
||
main_fn.project = proj;
|
||
main_fn.label = "Function";
|
||
main_fn.name = "main";
|
||
main_fn.qualified_name = "arch560.cmd.main";
|
||
main_fn.file_path = "cmd/main.go";
|
||
main_fn.start_line = 1;
|
||
main_fn.end_line = 3;
|
||
main_fn.properties_json = "{\"is_entry_point\":true}";
|
||
ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0);
|
||
|
||
/* A File node so the file_tree aspect has real content — makes the
|
||
* "overview drops file_tree" assertion below non-vacuous. */
|
||
cbm_node_t file_node = {.project = proj,
|
||
.label = "File",
|
||
.name = "main.go",
|
||
.qualified_name = "arch560.cmd.main.go",
|
||
.file_path = "cmd/main.go"};
|
||
ASSERT_GT(cbm_store_upsert_node(st, &file_node), 0);
|
||
|
||
/* Sanity: with "all", both entry_points and file_tree surface. */
|
||
char *resp_all = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":560,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_architecture\","
|
||
"\"arguments\":{\"project\":\"arch560\",\"aspects\":[\"all\"]}}}");
|
||
ASSERT_NOT_NULL(resp_all);
|
||
char *inner_all = extract_text_content(resp_all);
|
||
ASSERT_NOT_NULL(inner_all);
|
||
ASSERT_NOT_NULL(strstr(inner_all, "entry_points:"));
|
||
ASSERT_NOT_NULL(strstr(inner_all, "file_tree:"));
|
||
free(inner_all);
|
||
free(resp_all);
|
||
|
||
/* "overview": substantive content (entry_points, node_labels) but NO
|
||
* file_tree section. */
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":561,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_architecture\","
|
||
"\"arguments\":{\"project\":\"arch560\",\"aspects\":[\"overview\"]}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
ASSERT_NOT_NULL(strstr(inner, "entry_points:"));
|
||
ASSERT_NOT_NULL(strstr(inner, "node_labels:"));
|
||
ASSERT_NULL(strstr(inner, "file_tree:"));
|
||
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* Distills PR #560 (server-side validation): unknown aspect tokens must be
|
||
* rejected with an isError result listing the valid values. Before the fix
|
||
* the JSON-Schema accepted any string and both aspect gates simply never
|
||
* matched, so a typo like "bogus_aspect" produced a silent near-empty payload
|
||
* with isError:false. RED on unfixed code: no isError, no "Unknown aspect". */
|
||
TEST(tool_get_architecture_rejects_unknown_aspect_pr560) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(st);
|
||
|
||
const char *proj = "arch560v";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, "/tmp/arch560v");
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":562,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_architecture\","
|
||
"\"arguments\":{\"project\":\"arch560v\",\"aspects\":[\"bogus_aspect\"]}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"isError\":true"));
|
||
ASSERT_NOT_NULL(strstr(resp, "Unknown aspect 'bogus_aspect'"));
|
||
/* The error must teach the valid vocabulary, including the new token. */
|
||
ASSERT_NOT_NULL(strstr(resp, "overview"));
|
||
ASSERT_NOT_NULL(strstr(resp, "file_tree"));
|
||
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* Reproduce-first for #640: query handlers must accept the `project_name`
|
||
* alias, not only the canonical `project` key. list_projects surfaces the field
|
||
* as "name" and the error hint says "pass the project name", so a caller
|
||
* naturally passes `project_name`. With no alias, the handler reads key
|
||
* "project" -> NULL -> resolve_store bails before opening any .db -> "project
|
||
* not found or not indexed" even though the project is indexed. Mirrors
|
||
* tool_get_architecture_emits_populated_sections but with the alias key. */
|
||
TEST(tool_get_architecture_accepts_project_name_alias_issue640) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(st);
|
||
|
||
const char *proj = "alias640";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, "/tmp/alias640");
|
||
|
||
cbm_node_t main_fn = {0};
|
||
main_fn.project = proj;
|
||
main_fn.label = "Function";
|
||
main_fn.name = "main";
|
||
main_fn.qualified_name = "alias640.cmd.main";
|
||
main_fn.file_path = "cmd/main.go";
|
||
main_fn.start_line = 1;
|
||
main_fn.end_line = 3;
|
||
main_fn.properties_json = "{\"is_entry_point\":true}";
|
||
ASSERT_GT(cbm_store_upsert_node(st, &main_fn), 0);
|
||
|
||
/* Caller passes `project_name` (the natural guess) instead of `project`. */
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":640,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_architecture\","
|
||
"\"arguments\":{\"project_name\":\"alias640\",\"aspects\":[\"all\"]}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
|
||
/* RED before the alias: inner is the "project not found" error.
|
||
* GREEN after: the alias resolves and architecture sections surface. */
|
||
ASSERT_NULL(strstr(inner, "project not found"));
|
||
ASSERT_NOT_NULL(strstr(inner, "entry_points:"));
|
||
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* Reproduce-first for #640: the alias must apply across query handlers, not
|
||
* just get_architecture. search_graph with `project_name` must resolve too. */
|
||
TEST(tool_search_graph_accepts_project_name_alias_issue640) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(st);
|
||
|
||
const char *proj = "alias640b";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, "/tmp/alias640b");
|
||
|
||
cbm_node_t fn = {0};
|
||
fn.project = proj;
|
||
fn.label = "Function";
|
||
fn.name = "WidgetHandler";
|
||
fn.qualified_name = "alias640b.svc.WidgetHandler";
|
||
fn.file_path = "svc/widget.go";
|
||
fn.start_line = 1;
|
||
fn.end_line = 2;
|
||
ASSERT_GT(cbm_store_upsert_node(st, &fn), 0);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":641,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_graph\","
|
||
"\"arguments\":{\"project_name\":\"alias640b\",\"name_pattern\":\"Widget.*\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
|
||
ASSERT_NULL(strstr(inner, "project not found"));
|
||
ASSERT_NOT_NULL(strstr(inner, "WidgetHandler"));
|
||
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* #1025: agents pass the repo FOLDER name ("codebase-memory-mcp"), but
|
||
* indexed project names derive from the full path
|
||
* (E:\project\graph\x -> "E-project-graph-x"), so exact lookup fails with
|
||
* "project not found" while list_projects clearly shows the project. A
|
||
* passed name that matches exactly ONE indexed project as a segment-aligned
|
||
* tail ("-<name>" suffix) must resolve to it; zero or several matches keep
|
||
* the existing error. Runs against real cache-dir .db files (the resolution
|
||
* scans filenames), so this test indexes real fixtures under an overridden
|
||
* CBM_CACHE_DIR. */
|
||
static void i1025_write_repo(const char *dir, const char *fn_name) {
|
||
char path[CBM_SZ_512];
|
||
snprintf(path, sizeof(path), "%s/mod.py", dir);
|
||
FILE *f = fopen(path, "w");
|
||
if (!f)
|
||
return;
|
||
fprintf(f, "def %s(x):\n return x + 1\n", fn_name);
|
||
fclose(f);
|
||
}
|
||
|
||
TEST(tool_project_arg_resolves_unique_tail_issue1025) {
|
||
char repo_a[CBM_SZ_256];
|
||
char repo_b[CBM_SZ_256];
|
||
char repo_c[CBM_SZ_256];
|
||
char cache[CBM_SZ_256];
|
||
snprintf(repo_a, sizeof(repo_a), "/tmp/cbm-i1025a-XXXXXX");
|
||
snprintf(repo_b, sizeof(repo_b), "/tmp/cbm-i1025b-XXXXXX");
|
||
snprintf(repo_c, sizeof(repo_c), "/tmp/cbm-i1025c-XXXXXX");
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-i1025d-XXXXXX");
|
||
if (!cbm_mkdtemp(repo_a) || !cbm_mkdtemp(repo_b) || !cbm_mkdtemp(repo_c) ||
|
||
!cbm_mkdtemp(cache)) {
|
||
FAIL("mkdtemp failed");
|
||
}
|
||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||
char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL;
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1);
|
||
|
||
i1025_write_repo(repo_a, "unique_tail_target");
|
||
i1025_write_repo(repo_b, "amb_one");
|
||
i1025_write_repo(repo_c, "amb_two");
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char args[CBM_SZ_1K];
|
||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"E-project-graph-suffix1025\"}",
|
||
repo_a);
|
||
char *r = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
ASSERT_NOT_NULL(r);
|
||
free(r);
|
||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"F-alpha-amb1025\"}", repo_b);
|
||
r = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
ASSERT_NOT_NULL(r);
|
||
free(r);
|
||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"name\":\"G-beta-amb1025\"}", repo_c);
|
||
r = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
ASSERT_NOT_NULL(r);
|
||
free(r);
|
||
|
||
/* 1. Unique tail resolves (RED today: "project not found"). */
|
||
r = cbm_mcp_handle_tool(srv, "search_graph",
|
||
"{\"project\":\"suffix1025\",\"name_pattern\":\".*target.*\"}");
|
||
ASSERT_NOT_NULL(r);
|
||
if (strstr(r, "project not found")) {
|
||
fprintf(stderr, " [1025] FAIL unique tail did not resolve: %.200s\n", r);
|
||
}
|
||
ASSERT_NULL(strstr(r, "project not found"));
|
||
ASSERT_NOT_NULL(strstr(r, "unique_tail_target"));
|
||
free(r);
|
||
|
||
/* 2. Ambiguous tail stays an error (never guess between projects). */
|
||
r = cbm_mcp_handle_tool(srv, "search_graph",
|
||
"{\"project\":\"amb1025\",\"name_pattern\":\".*\"}");
|
||
ASSERT_NOT_NULL(r);
|
||
ASSERT_NOT_NULL(strstr(r, "project not found"));
|
||
free(r);
|
||
|
||
/* 3. Exact full name keeps working unchanged. */
|
||
r = cbm_mcp_handle_tool(srv, "search_graph",
|
||
"{\"project\":\"E-project-graph-suffix1025\","
|
||
"\"name_pattern\":\".*target.*\"}");
|
||
ASSERT_NOT_NULL(r);
|
||
ASSERT_NULL(strstr(r, "project not found"));
|
||
free(r);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
if (saved_cache_copy) {
|
||
cbm_setenv("CBM_CACHE_DIR", saved_cache_copy, 1);
|
||
free(saved_cache_copy);
|
||
} else {
|
||
cbm_unsetenv("CBM_CACHE_DIR");
|
||
}
|
||
th_rmtree(repo_a);
|
||
th_rmtree(repo_b);
|
||
th_rmtree(repo_c);
|
||
th_rmtree(cache);
|
||
PASS();
|
||
}
|
||
|
||
/* Regression for #604: path scopes architecture totals and content. */
|
||
TEST(tool_get_architecture_path_scoping) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(st);
|
||
|
||
const char *proj = "arch-path";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, "/tmp/arch-path");
|
||
|
||
cbm_node_t pkg_global = {.project = proj,
|
||
.label = "Package",
|
||
.name = "Django",
|
||
.qualified_name = "arch-path.Django",
|
||
.file_path = "vendor/django/__init__.py"};
|
||
cbm_store_upsert_node(st, &pkg_global);
|
||
|
||
cbm_node_t pkg_local = {.project = proj,
|
||
.label = "Package",
|
||
.name = "hoa",
|
||
.qualified_name = "arch-path.hoa",
|
||
.file_path = "apps/hoa/main.go"};
|
||
cbm_store_upsert_node(st, &pkg_local);
|
||
|
||
cbm_node_t f_hoa = {.project = proj,
|
||
.label = "File",
|
||
.name = "main.go",
|
||
.qualified_name = "arch-path.apps.hoa.main.go",
|
||
.file_path = "apps/hoa/main.go"};
|
||
cbm_store_upsert_node(st, &f_hoa);
|
||
|
||
cbm_node_t f_other = {.project = proj,
|
||
.label = "File",
|
||
.name = "other.go",
|
||
.qualified_name = "arch-path.other.go",
|
||
.file_path = "lib/other.go"};
|
||
cbm_store_upsert_node(st, &f_other);
|
||
|
||
char *resp_root = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_architecture\","
|
||
"\"arguments\":{\"project\":\"arch-path\",\"aspects\":[\"packages\"]}}}");
|
||
ASSERT_NOT_NULL(resp_root);
|
||
char *inner_root = extract_text_content(resp_root);
|
||
ASSERT_NOT_NULL(inner_root);
|
||
ASSERT_NOT_NULL(strstr(inner_root, "Django"));
|
||
|
||
char *resp_scoped =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_architecture\","
|
||
"\"arguments\":{\"project\":\"arch-path\",\"path\":\"apps/hoa\","
|
||
"\"aspects\":[\"packages\"]}}}");
|
||
ASSERT_NOT_NULL(resp_scoped);
|
||
char *inner_scoped = extract_text_content(resp_scoped);
|
||
ASSERT_NOT_NULL(inner_scoped);
|
||
|
||
ASSERT_NOT_NULL(strstr(inner_scoped, "root_total_nodes"));
|
||
ASSERT_NOT_NULL(strstr(inner_scoped, "scoped_total_nodes"));
|
||
ASSERT_NOT_NULL(strstr(inner_scoped, "path: "));
|
||
ASSERT_NOT_NULL(strstr(inner_scoped, "hoa"));
|
||
ASSERT_NULL(strstr(inner_scoped, "Django"));
|
||
|
||
int root_nodes = 0;
|
||
int scoped_nodes = 0;
|
||
/* TOON scalar form (`key: N`) with JSON fallback for format:"json". */
|
||
const char *rt = strstr(inner_scoped, "root_total_nodes: ");
|
||
const char *stn = strstr(inner_scoped, "scoped_total_nodes: ");
|
||
if (rt) {
|
||
sscanf(rt, "root_total_nodes: %d", &root_nodes);
|
||
} else if ((rt = strstr(inner_scoped, "\"root_total_nodes\":")) != NULL) {
|
||
sscanf(rt, "\"root_total_nodes\":%d", &root_nodes);
|
||
}
|
||
if (stn) {
|
||
sscanf(stn, "scoped_total_nodes: %d", &scoped_nodes);
|
||
} else if ((stn = strstr(inner_scoped, "\"scoped_total_nodes\":")) != NULL) {
|
||
sscanf(stn, "\"scoped_total_nodes\":%d", &scoped_nodes);
|
||
}
|
||
ASSERT_TRUE(root_nodes > scoped_nodes);
|
||
ASSERT_TRUE(scoped_nodes > 0);
|
||
|
||
free(inner_scoped);
|
||
free(resp_scoped);
|
||
free(inner_root);
|
||
free(resp_root);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_query_graph_missing_query) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":23,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"query_graph\","
|
||
"\"arguments\":{}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
/* Should return error about missing query */
|
||
ASSERT_NOT_NULL(strstr(resp, "required"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* PIPELINE-DEPENDENT TOOL HANDLERS
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(tool_index_repository_missing_path) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":30,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"index_repository\","
|
||
"\"arguments\":{}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "required"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_get_code_snippet_missing_qn) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":31,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_code_snippet\","
|
||
"\"arguments\":{}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "required"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_get_code_snippet_not_found) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":32,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"get_code_snippet\","
|
||
"\"arguments\":{\"qualified_name\":\"nonexistent.func\","
|
||
"\"project\":\"nonexistent\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "not found"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_search_code_missing_pattern) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":33,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "required"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_search_code_no_project) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":34,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{\"pattern\":\"func main\","
|
||
"\"project\":\"nonexistent\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
/* No project indexed → error */
|
||
ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed") ||
|
||
strstr(resp, "required"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(search_code_multi_word) {
|
||
char tmp[512];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* Multi-word query "HandleRequest error" — should find the line
|
||
* "func HandleRequest() error {" via regex conversion. */
|
||
char req[512];
|
||
snprintf(req, sizeof(req),
|
||
"{\"jsonrpc\":\"2.0\",\"id\":90,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{\"pattern\":\"HandleRequest error\","
|
||
"\"project\":\"test-project\"}}}");
|
||
|
||
char *resp = cbm_mcp_server_handle(srv, req);
|
||
ASSERT_NOT_NULL(resp);
|
||
/* Should find at least one result (not zero) */
|
||
ASSERT_TRUE(strstr(resp, "HandleRequest") != NULL);
|
||
/* Should NOT contain an error about "not found" */
|
||
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
|
||
free(resp);
|
||
|
||
cleanup_snippet_dir(tmp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* Reproduce-first (#687): scoped content search over a repo whose ROOT PATH
|
||
* contains a space. write_scoped_filelist emits "<root>/<file>" records that the
|
||
* Unix pipeline pipes to grep via xargs. With plain `xargs` (newline-split) the
|
||
* space splits one path into several bogus args -> grep finds nothing ->
|
||
* total_grep_matches == 0 (RED on the unfixed code). The fix writes NUL-separated
|
||
* records + uses `xargs -0`, so the path stays a single argument -> match found
|
||
* (GREEN). On Windows the scoped path uses PowerShell Get-Content -LiteralPath,
|
||
* which already handles spaces, so this asserts correct behavior there too. */
|
||
TEST(search_code_scoped_path_with_spaces_issue687) {
|
||
char tmp[512];
|
||
snprintf(tmp, sizeof(tmp), "/tmp/cbm_srch_space_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("cbm_mkdtemp failed");
|
||
}
|
||
|
||
/* Project root deliberately contains a space. */
|
||
char proj_dir[640];
|
||
snprintf(proj_dir, sizeof(proj_dir), "%s/my project", tmp);
|
||
cbm_mkdir(proj_dir);
|
||
|
||
char src_path[768];
|
||
snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir);
|
||
FILE *fp = fopen(src_path, "w");
|
||
if (!fp) {
|
||
rmdir(proj_dir);
|
||
rmdir(tmp);
|
||
FAIL("cannot write source file under spaced path");
|
||
}
|
||
fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n");
|
||
fclose(fp);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(st);
|
||
const char *proj = "space-search";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, proj_dir);
|
||
|
||
/* A node so the file is "indexed" (cbm_store_list_files -> scoped grep path)
|
||
* and the grep hit classifies to a result. */
|
||
cbm_node_t n = {.project = proj,
|
||
.label = "Function",
|
||
.name = "HandleRequest",
|
||
.qualified_name = "space-search.main.HandleRequest",
|
||
.file_path = "main.go",
|
||
.start_line = 3,
|
||
.end_line = 5};
|
||
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":94,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"space-search\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
|
||
/* grep must have found the match despite the space in the root path. */
|
||
int grep_matches = -1;
|
||
const char *g = strstr(inner, "\"total_grep_matches\":");
|
||
if (g) {
|
||
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
|
||
} else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) {
|
||
/* TOON scalar form — the search_code compact default. */
|
||
sscanf(g, "total_grep_matches: %d", &grep_matches);
|
||
}
|
||
ASSERT_TRUE(grep_matches > 0);
|
||
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
unlink(src_path);
|
||
rmdir(proj_dir);
|
||
rmdir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
#ifdef _WIN32
|
||
/* Issue #903 follow-up: scoped search_code on Windows writes a UTF-8 filelist
|
||
* containing absolute source paths, then reads it back through PowerShell.
|
||
* Windows PowerShell 5.1 treats UTF-8 without BOM as ANSI unless told
|
||
* otherwise, so a non-ASCII project root can be mojibaked before
|
||
* Select-String sees the LiteralPath. */
|
||
TEST(search_code_scoped_path_with_cjk_root_issue903) {
|
||
char tmp[512];
|
||
snprintf(tmp, sizeof(tmp), "%s/cbm_srch_cjk_XXXXXX", cbm_tmpdir());
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
FAIL("cbm_mkdtemp failed");
|
||
}
|
||
|
||
char proj_dir[640];
|
||
snprintf(proj_dir, sizeof(proj_dir), "%s/%s", tmp,
|
||
"\xE4\xB8\xAD\xE6\x96\x87\xE9\xA1\xB9\xE7\x9B\xAE");
|
||
if (!cbm_mkdir_p(proj_dir, 0755)) {
|
||
cbm_rmdir(tmp);
|
||
FAIL("cannot create CJK project dir");
|
||
}
|
||
|
||
char src_path[768];
|
||
snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir);
|
||
FILE *fp = cbm_fopen(src_path, "wb");
|
||
if (!fp) {
|
||
cbm_rmdir(proj_dir);
|
||
cbm_rmdir(tmp);
|
||
FAIL("cannot write source file under CJK path");
|
||
}
|
||
fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n");
|
||
fclose(fp);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(st);
|
||
const char *proj = "cjk-search";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, proj_dir);
|
||
|
||
cbm_node_t n = {.project = proj,
|
||
.label = "Function",
|
||
.name = "HandleRequest",
|
||
.qualified_name = "cjk-search.main.HandleRequest",
|
||
.file_path = "main.go",
|
||
.start_line = 3,
|
||
.end_line = 5};
|
||
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":903,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"cjk-search\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
|
||
int grep_matches = -1;
|
||
const char *g = strstr(inner, "\"total_grep_matches\":");
|
||
if (g) {
|
||
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
|
||
} else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) {
|
||
/* TOON scalar form — the search_code compact default. */
|
||
sscanf(g, "total_grep_matches: %d", &grep_matches);
|
||
}
|
||
ASSERT_TRUE(grep_matches > 0);
|
||
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
cbm_unlink(src_path);
|
||
cbm_rmdir(proj_dir);
|
||
cbm_rmdir(tmp);
|
||
PASS();
|
||
}
|
||
#endif
|
||
|
||
/* Shared fixture for the path_filter prefilter tests (PR #756 distilled):
|
||
* a project with two indexed files that both contain the search pattern —
|
||
* src/handler.go (inside the filter) and vendor/other.go (outside it). */
|
||
static cbm_mcp_server_t *setup_prefilter_server(char *tmp, size_t tmp_sz, char *src_path,
|
||
size_t src_sz, char *vendor_path,
|
||
size_t vendor_sz) {
|
||
snprintf(tmp, tmp_sz, "/tmp/cbm_srch_pref_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp)) {
|
||
return NULL;
|
||
}
|
||
char dir[640];
|
||
snprintf(dir, sizeof(dir), "%s/src", tmp);
|
||
cbm_mkdir(dir);
|
||
snprintf(dir, sizeof(dir), "%s/vendor", tmp);
|
||
cbm_mkdir(dir);
|
||
|
||
snprintf(src_path, src_sz, "%s/src/handler.go", tmp);
|
||
snprintf(vendor_path, vendor_sz, "%s/vendor/other.go", tmp);
|
||
FILE *fp = fopen(src_path, "w");
|
||
if (!fp) {
|
||
return NULL;
|
||
}
|
||
fprintf(fp, "package main\n\nfunc HandleRequest() error {\n\treturn nil\n}\n");
|
||
fclose(fp);
|
||
fp = fopen(vendor_path, "w");
|
||
if (!fp) {
|
||
return NULL;
|
||
}
|
||
fprintf(fp, "package vendored\n\nfunc HandleRequest() error {\n\treturn nil\n}\n");
|
||
fclose(fp);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
if (!srv) {
|
||
return NULL;
|
||
}
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
const char *proj = "prefilter-search";
|
||
cbm_mcp_server_set_project(srv, proj);
|
||
cbm_store_upsert_project(st, proj, tmp);
|
||
|
||
cbm_node_t n1 = {.project = proj,
|
||
.label = "Function",
|
||
.name = "HandleRequest",
|
||
.qualified_name = "prefilter-search.main.HandleRequest",
|
||
.file_path = "src/handler.go",
|
||
.start_line = 3,
|
||
.end_line = 5};
|
||
cbm_node_t n2 = {.project = proj,
|
||
.label = "Function",
|
||
.name = "HandleRequest",
|
||
.qualified_name = "prefilter-search.vendored.HandleRequest",
|
||
.file_path = "vendor/other.go",
|
||
.start_line = 3,
|
||
.end_line = 5};
|
||
if (cbm_store_upsert_node(st, &n1) <= 0 || cbm_store_upsert_node(st, &n2) <= 0) {
|
||
cbm_mcp_server_free(srv);
|
||
return NULL;
|
||
}
|
||
return srv;
|
||
}
|
||
|
||
static void cleanup_prefilter_dir(const char *tmp, const char *src_path, const char *vendor_path) {
|
||
char dir[640];
|
||
unlink(src_path);
|
||
unlink(vendor_path);
|
||
snprintf(dir, sizeof(dir), "%s/src", tmp);
|
||
rmdir(dir);
|
||
snprintf(dir, sizeof(dir), "%s/vendor", tmp);
|
||
rmdir(dir);
|
||
rmdir(tmp);
|
||
}
|
||
|
||
/* PR #756 (distilled): scoped search_code prefilters the indexed filelist by
|
||
* path_filter before grep runs. POSITIVE invariant guard: a path_filter that
|
||
* matches the file containing the hit must still return that hit (guards
|
||
* against over-filtering — the prefilter predicate must stay IDENTICAL to the
|
||
* post-grep filter in collect_grep_matches), and files outside the filter
|
||
* stay excluded. Green on pre-prefilter main too (the post-grep filter alone
|
||
* produced the same results): the change is results-preserving perf-only. */
|
||
TEST(search_code_path_filter_prefilter_keeps_matches) {
|
||
char tmp[512], src_path[768], vendor_path[768];
|
||
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
|
||
vendor_path, sizeof(vendor_path));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":95,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\","
|
||
"\"path_filter\":\"^src/\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
|
||
/* The in-filter hit is returned; the out-of-filter file is not. */
|
||
ASSERT_NOT_NULL(strstr(inner, "src/handler.go"));
|
||
ASSERT_TRUE(strstr(inner, "vendor/other.go") == NULL);
|
||
|
||
/* Exactly the one in-filter grep match survives (same count before and
|
||
* after the prefilter — predicate identity). */
|
||
int grep_matches = -1;
|
||
const char *g = strstr(inner, "\"total_grep_matches\":");
|
||
if (g) {
|
||
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
|
||
} else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) {
|
||
/* TOON scalar form — the search_code compact default. */
|
||
sscanf(g, "total_grep_matches: %d", &grep_matches);
|
||
}
|
||
ASSERT_EQ(grep_matches, 1);
|
||
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_prefilter_dir(tmp, src_path, vendor_path);
|
||
PASS();
|
||
}
|
||
|
||
/* PR #756 (distilled): path_filter matching ZERO indexed files. With the
|
||
* prefilter the scoped filelist has 0 records, and handle_search_code now
|
||
* skips the grep subprocess entirely (xargs on an empty filelist is
|
||
* platform-dependent: GNU execs grep once with no operands, BSD skips) and
|
||
* returns the empty result directly. Must be a clean zero-result response —
|
||
* no error. Green on pre-prefilter main too (there the full filelist is
|
||
* grepped and the post-grep filter drops every hit — an empty filelist is
|
||
* unreachable on main): guards the edge the prefilter introduces. */
|
||
TEST(search_code_path_filter_matches_nothing) {
|
||
char tmp[512], src_path[768], vendor_path[768];
|
||
cbm_mcp_server_t *srv = setup_prefilter_server(tmp, sizeof(tmp), src_path, sizeof(src_path),
|
||
vendor_path, sizeof(vendor_path));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":96,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{\"pattern\":\"HandleRequest\",\"project\":\"prefilter-search\","
|
||
"\"path_filter\":\"^no_such_dir/\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
|
||
char *inner = extract_text_content(resp);
|
||
ASSERT_NOT_NULL(inner);
|
||
|
||
int grep_matches = -1;
|
||
const char *g = strstr(inner, "\"total_grep_matches\":");
|
||
if (g) {
|
||
sscanf(g, "\"total_grep_matches\":%d", &grep_matches);
|
||
} else if ((g = strstr(inner, "total_grep_matches: ")) != NULL) {
|
||
/* TOON scalar form — the search_code compact default. */
|
||
sscanf(g, "total_grep_matches: %d", &grep_matches);
|
||
}
|
||
ASSERT_EQ(grep_matches, 0);
|
||
int results = -1;
|
||
const char *r = strstr(inner, "\"total_results\":");
|
||
if (r) {
|
||
sscanf(r, "\"total_results\":%d", &results);
|
||
} else if ((r = strstr(inner, "total_results: ")) != NULL) {
|
||
sscanf(r, "total_results: %d", &results);
|
||
}
|
||
ASSERT_EQ(results, 0);
|
||
ASSERT_TRUE(strstr(inner, "handler.go") == NULL);
|
||
ASSERT_TRUE(strstr(inner, "other.go") == NULL);
|
||
|
||
free(inner);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_prefilter_dir(tmp, src_path, vendor_path);
|
||
PASS();
|
||
}
|
||
|
||
/* issue #283: search_code with regex=true and a syntactically invalid pattern
|
||
* must return an explicit error, not an empty result indistinguishable from a
|
||
* legitimate no-match. */
|
||
TEST(search_code_invalid_regex_errors_issue283) {
|
||
char tmp[512];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* Unclosed group under regex=true → must be flagged as an error. */
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{\"pattern\":\"func(\",\"regex\":true,"
|
||
"\"project\":\"test-project\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"isError\":true"));
|
||
ASSERT_NOT_NULL(strstr(resp, "invalid regex"));
|
||
free(resp);
|
||
|
||
/* Same pattern as a literal (regex=false) must NOT error. */
|
||
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{\"pattern\":\"func(\",\"regex\":false,"
|
||
"\"project\":\"test-project\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_TRUE(strstr(resp, "invalid regex") == NULL);
|
||
free(resp);
|
||
|
||
cleanup_snippet_dir(tmp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* issue #282: a literal '|' under regex=false is a silent 0-match trap. It must
|
||
* now be surfaced as a warning (and the result carries elapsed_ms). */
|
||
TEST(search_code_literal_pipe_warns_issue282) {
|
||
char tmp[512];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":93,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{\"pattern\":\"HandleRequest|Nope\","
|
||
"\"regex\":false,\"project\":\"test-project\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "warning")); /* surfaced, not silent */
|
||
ASSERT_NOT_NULL(strstr(resp, "regex=true")); /* the hint names the fix */
|
||
ASSERT_NOT_NULL(strstr(resp, "elapsed_ms")); /* timing is reported */
|
||
free(resp);
|
||
|
||
cleanup_snippet_dir(tmp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* issue #272: '&' in a path / file_pattern is neutralised by the command's
|
||
* quoting and must no longer be rejected as "invalid characters". */
|
||
TEST(search_code_ampersand_accepted_issue272) {
|
||
char tmp[512];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":94,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_code\","
|
||
"\"arguments\":{\"pattern\":\"HandleRequest\","
|
||
"\"file_pattern\":\"*R&D*.go\",\"project\":\"test-project\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_TRUE(strstr(resp, "invalid characters") == NULL);
|
||
free(resp);
|
||
|
||
cleanup_snippet_dir(tmp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_detect_changes_no_project) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":35,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"detect_changes\","
|
||
"\"arguments\":{}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "missing required argument: project"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_manage_adr_no_project) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":36,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"manage_adr\","
|
||
"\"arguments\":{}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "missing required argument: project"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* Regression test for use-after-free in handle_manage_adr (get path).
|
||
* MUST FAIL before fix: free(buf) is called before yy_doc_to_str serializes doc,
|
||
* so result field is missing or contains garbage. MUST PASS after fix. */
|
||
TEST(tool_manage_adr_get_with_existing_adr) {
|
||
/* Create a temp directory with .codebase-memory/adr.md */
|
||
char tmp_dir[256];
|
||
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-adr-test-XXXXXX");
|
||
if (!cbm_mkdtemp(tmp_dir)) {
|
||
PASS(); /* skip if mkdtemp fails */
|
||
}
|
||
|
||
char adr_dir[512];
|
||
snprintf(adr_dir, sizeof(adr_dir), "%s/.codebase-memory", tmp_dir);
|
||
cbm_mkdir(adr_dir);
|
||
|
||
char adr_path[512];
|
||
snprintf(adr_path, sizeof(adr_path), "%s/adr.md", adr_dir);
|
||
FILE *fp = fopen(adr_path, "w");
|
||
ASSERT_NOT_NULL(fp);
|
||
fputs("## PURPOSE\nTest ADR content for regression test.\n\n"
|
||
"## STACK\nC, SQLite.\n\n"
|
||
"## ARCHITECTURE\nMCP server.\n",
|
||
fp);
|
||
fclose(fp);
|
||
|
||
/* Create server and register the project */
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(st);
|
||
cbm_store_upsert_project(st, "test-adr-uaf", tmp_dir);
|
||
cbm_mcp_server_set_project(srv, "test-adr-uaf");
|
||
|
||
/* Call manage_adr via full JSON-RPC path to exercise cbm_jsonrpc_format_response.
|
||
* The bug: free(buf) before yy_doc_to_str causes garbage JSON; format_response
|
||
* then fails to parse the result and omits the "result" field entirely. */
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":99,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"manage_adr\","
|
||
"\"arguments\":{\"project\":\"test-adr-uaf\",\"mode\":\"get\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
/* JSON-RPC response must include a "result" field (absent when use-after-free) */
|
||
ASSERT_NOT_NULL(strstr(resp, "\"result\""));
|
||
/* ADR content must appear in response */
|
||
ASSERT_NOT_NULL(strstr(resp, "PURPOSE"));
|
||
/* Must not be an error */
|
||
ASSERT_NULL(strstr(resp, "\"isError\":true"));
|
||
free(resp);
|
||
|
||
/* Clean up */
|
||
cbm_mcp_server_free(srv);
|
||
remove(adr_path);
|
||
rmdir(adr_dir);
|
||
rmdir(tmp_dir);
|
||
PASS();
|
||
}
|
||
|
||
/* issue #256: manage_adr (MCP) and the UI /api/adr endpoints must share ONE
|
||
* backend. A manage_adr(update) write must be readable via cbm_store_adr_get
|
||
* (the exact API the UI's /api/adr GET uses). */
|
||
TEST(tool_manage_adr_unified_backend_issue256) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(st);
|
||
cbm_store_upsert_project(st, "adr-unify", "/tmp/adr-unify");
|
||
cbm_mcp_server_set_project(srv, "adr-unify");
|
||
|
||
/* Write via the MCP tool. */
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":120,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\","
|
||
"\"mode\":\"update\",\"content\":\"## PURPOSE\\nUnified ADR backend.\\n\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "updated"));
|
||
free(resp);
|
||
|
||
/* Read DIRECTLY via the store API the UI /api/adr uses — must see it. */
|
||
cbm_adr_t adr;
|
||
memset(&adr, 0, sizeof(adr));
|
||
ASSERT_EQ(cbm_store_adr_get(st, "adr-unify", &adr), CBM_STORE_OK);
|
||
ASSERT_NOT_NULL(adr.content);
|
||
ASSERT_NOT_NULL(strstr(adr.content, "Unified ADR backend."));
|
||
cbm_store_adr_free(&adr);
|
||
|
||
/* And manage_adr(get) round-trips the same content. */
|
||
resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":121,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"manage_adr\",\"arguments\":{\"project\":\"adr-unify\","
|
||
"\"mode\":\"get\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "Unified ADR backend."));
|
||
ASSERT_NULL(strstr(resp, "\"isError\":true"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_manage_adr_mutation_guard_balances_success) {
|
||
const char *project = "guard-adr-success";
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(store);
|
||
ASSERT_EQ(cbm_store_upsert_project(store, project, "/tmp/guard-adr-success"), CBM_STORE_OK);
|
||
cbm_mcp_server_set_project(srv, project);
|
||
|
||
mcp_mutation_guard_probe_t probe = {0};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
|
||
char *resp = cbm_mcp_handle_tool(srv, "manage_adr",
|
||
"{\"project\":\"guard-adr-success\",\"mode\":\"update\","
|
||
"\"content\":\"## PURPOSE\\nGuarded ADR.\\n\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "updated"));
|
||
ASSERT_EQ(probe.begin_count, 1);
|
||
ASSERT_EQ(probe.end_count, 1);
|
||
ASSERT_STR_EQ(probe.begin_projects[0], project);
|
||
ASSERT_STR_EQ(probe.end_projects[0], project);
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_manage_adr_mutation_guard_releases_on_missing_store) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-mcp-adr-guard-XXXXXX");
|
||
if (!cbm_mkdtemp(cache)) {
|
||
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);
|
||
|
||
const char *project = "guard-adr-missing";
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
mcp_mutation_guard_probe_t probe = {0};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
|
||
char *resp = cbm_mcp_handle_tool(srv, "manage_adr",
|
||
"{\"project\":\"guard-adr-missing\",\"mode\":\"get\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "not indexed"));
|
||
ASSERT_EQ(probe.begin_count, 1);
|
||
ASSERT_EQ(probe.end_count, 1);
|
||
ASSERT_STR_EQ(probe.begin_projects[0], project);
|
||
ASSERT_STR_EQ(probe.end_projects[0], project);
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_project_db(cache, project);
|
||
cbm_rmdir(cache);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
PASS();
|
||
}
|
||
|
||
/* A raw cbm_mcp_handle_tool() call is still one request lifetime. Cancellation
|
||
* published from inside a non-pipeline handler must therefore be accepted,
|
||
* observed before the write, and retired at completion so the next raw request
|
||
* on the same server is not poisoned. */
|
||
TEST(tool_raw_dispatch_cancel_is_scoped_non_mutating_and_next_request_clean) {
|
||
const char *project = "raw-cancel-adr";
|
||
char root[256];
|
||
snprintf(root, sizeof(root), "%s/cbm-mcp-raw-adr-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(root));
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(store);
|
||
ASSERT_EQ(cbm_store_upsert_project(store, project, root), CBM_STORE_OK);
|
||
cbm_mcp_server_set_project(srv, project);
|
||
|
||
mcp_mutation_guard_probe_t probe = {
|
||
.cancel_on_begin_call = 1,
|
||
.cancel_server = srv,
|
||
};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
|
||
char *cancelled_response =
|
||
cbm_mcp_handle_tool(srv, "manage_adr",
|
||
"{\"project\":\"raw-cancel-adr\",\"mode\":\"update\","
|
||
"\"content\":\"## PURPOSE\\nMUST NOT COMMIT.\\n\"}");
|
||
bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") &&
|
||
strstr(cancelled_response, "\"isError\":true");
|
||
|
||
cbm_adr_t cancelled_adr = {0};
|
||
int cancelled_lookup = cbm_store_adr_get(store, project, &cancelled_adr);
|
||
if (cancelled_lookup == CBM_STORE_OK) {
|
||
cbm_store_adr_free(&cancelled_adr);
|
||
}
|
||
|
||
char *next_response =
|
||
cbm_mcp_handle_tool(srv, "manage_adr",
|
||
"{\"project\":\"raw-cancel-adr\",\"mode\":\"update\","
|
||
"\"content\":\"## PURPOSE\\nClean next request.\\n\"}");
|
||
bool next_response_clean = next_response && strstr(next_response, "updated") &&
|
||
!strstr(next_response, "cancelled") &&
|
||
!strstr(next_response, "\"isError\":true");
|
||
cbm_adr_t next_adr = {0};
|
||
int next_lookup = cbm_store_adr_get(store, project, &next_adr);
|
||
bool next_write_committed = next_lookup == CBM_STORE_OK && next_adr.content &&
|
||
strstr(next_adr.content, "Clean next request") &&
|
||
!strstr(next_adr.content, "MUST NOT COMMIT");
|
||
if (next_lookup == CBM_STORE_OK) {
|
||
cbm_store_adr_free(&next_adr);
|
||
}
|
||
|
||
free(cancelled_response);
|
||
free(next_response);
|
||
cbm_mcp_server_free(srv);
|
||
(void)cbm_rmdir(root);
|
||
|
||
ASSERT_TRUE(probe.cancel_attempted);
|
||
ASSERT_TRUE(probe.cancel_accepted);
|
||
ASSERT_TRUE(cancellation_reported);
|
||
ASSERT_EQ(cancelled_lookup, CBM_STORE_NOT_FOUND);
|
||
ASSERT_TRUE(next_response_clean);
|
||
ASSERT_TRUE(next_write_committed);
|
||
ASSERT_EQ(probe.begin_count, 2);
|
||
ASSERT_EQ(probe.end_count, 2);
|
||
ASSERT_STR_EQ(probe.begin_projects[0], project);
|
||
ASSERT_STR_EQ(probe.end_projects[0], project);
|
||
ASSERT_STR_EQ(probe.begin_projects[1], project);
|
||
ASSERT_STR_EQ(probe.end_projects[1], project);
|
||
PASS();
|
||
}
|
||
|
||
/* The daemon publishes its transport request before entering MCP dispatch. A
|
||
* disconnect in that narrow interval must remain latched through the nested
|
||
* raw tool scope instead of being erased at dispatch entry. */
|
||
TEST(tool_outer_request_scope_preserves_predispatch_cancel) {
|
||
const char *project = "outer-scope-cancel-adr";
|
||
char root[256];
|
||
(void)snprintf(root, sizeof(root), "%s/cbm-mcp-outer-cancel-XXXXXX", cbm_tmpdir());
|
||
bool root_created = cbm_mkdtemp(root) != NULL;
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||
bool project_ready =
|
||
root_created && store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK;
|
||
cbm_mcp_server_set_project(srv, project);
|
||
bool outer_scope = project_ready && cbm_mcp_server_request_scope_begin(srv);
|
||
bool cancel_accepted = outer_scope && cbm_mcp_server_cancel_active(srv);
|
||
char *cancelled_response =
|
||
cancel_accepted
|
||
? cbm_mcp_handle_tool(srv, "manage_adr",
|
||
"{\"project\":\"outer-scope-cancel-adr\","
|
||
"\"mode\":\"update\",\"content\":\"MUST NOT COMMIT\"}")
|
||
: NULL;
|
||
bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") &&
|
||
strstr(cancelled_response, "\"isError\":true");
|
||
cbm_mcp_server_request_scope_end(srv);
|
||
|
||
char *next_response = srv ? cbm_mcp_handle_tool(srv, "ingest_traces", "{\"traces\":[]}") : NULL;
|
||
bool next_response_clean = next_response && strstr(next_response, "accepted") &&
|
||
!strstr(next_response, "cancelled") &&
|
||
!strstr(next_response, "\"isError\":true");
|
||
|
||
free(cancelled_response);
|
||
free(next_response);
|
||
cbm_mcp_server_free(srv);
|
||
(void)cbm_rmdir(root);
|
||
|
||
ASSERT_TRUE(root_created);
|
||
ASSERT_NOT_NULL(srv);
|
||
ASSERT_TRUE(project_ready);
|
||
ASSERT_TRUE(outer_scope);
|
||
ASSERT_TRUE(cancel_accepted);
|
||
ASSERT_TRUE(cancellation_reported);
|
||
ASSERT_TRUE(next_response_clean);
|
||
PASS();
|
||
}
|
||
|
||
/* Publish cancellation from the local index mutation guard: the request scope
|
||
* must already be active, and the cancellation must either stop before
|
||
* pipeline admission or remain set through pipeline binding. No project DB may
|
||
* be published, and the following request must start with a clean token. */
|
||
TEST(tool_index_repository_early_raw_cancel_survives_index_entry) {
|
||
char cache[256];
|
||
char repo[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-raw-index-cache-XXXXXX", cbm_tmpdir());
|
||
snprintf(repo, sizeof(repo), "%s/cbm-mcp-raw-index-repo-XXXXXX", cbm_tmpdir());
|
||
bool cache_created = cbm_mkdtemp(cache) != NULL;
|
||
bool repo_created = cbm_mkdtemp(repo) != NULL;
|
||
|
||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||
if (cache_created) {
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
}
|
||
|
||
char *project = repo_created ? cbm_project_name_from_path(repo) : NULL;
|
||
cbm_mcp_server_t *srv =
|
||
cache_created && repo_created && project ? cbm_mcp_server_new(NULL) : NULL;
|
||
mcp_mutation_guard_probe_t probe = {
|
||
.cancel_on_begin_call = 1,
|
||
.cancel_server = srv,
|
||
};
|
||
if (srv) {
|
||
cbm_mcp_server_set_background_tasks(srv, false);
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
}
|
||
|
||
char args[CBM_SZ_1K];
|
||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo);
|
||
char *cancelled_response = srv ? cbm_mcp_handle_tool(srv, "index_repository", args) : NULL;
|
||
bool cancellation_reported = cancelled_response && strstr(cancelled_response, "cancelled") &&
|
||
strstr(cancelled_response, "\"isError\":true");
|
||
|
||
char db_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project ? project : "missing-project");
|
||
bool no_project_published = !cbm_file_exists(db_path);
|
||
|
||
char *next_response = srv ? cbm_mcp_handle_tool(srv, "ingest_traces", "{\"traces\":[]}") : NULL;
|
||
bool next_response_clean = next_response && strstr(next_response, "accepted") &&
|
||
!strstr(next_response, "cancelled") &&
|
||
!strstr(next_response, "\"isError\":true");
|
||
|
||
free(cancelled_response);
|
||
free(next_response);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_project_db(cache, project);
|
||
if (cache_created) {
|
||
(void)cbm_rmdir(cache);
|
||
}
|
||
if (repo_created) {
|
||
(void)cbm_rmdir(repo);
|
||
}
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
free(project);
|
||
|
||
ASSERT_TRUE(cache_created);
|
||
ASSERT_TRUE(repo_created);
|
||
ASSERT_NOT_NULL(srv);
|
||
ASSERT_TRUE(probe.cancel_attempted);
|
||
ASSERT_TRUE(probe.cancel_accepted);
|
||
ASSERT_TRUE(cancellation_reported);
|
||
ASSERT_EQ(probe.begin_count, 1);
|
||
ASSERT_EQ(probe.end_count, 1);
|
||
ASSERT_TRUE(no_project_published);
|
||
ASSERT_TRUE(next_response_clean);
|
||
PASS();
|
||
}
|
||
|
||
static bool mcp_cross_repo_create_project_store(const char *cache, const char *project,
|
||
const char *root_path) {
|
||
char db_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
cbm_store_t *store = cbm_store_open_path(db_path);
|
||
if (!store) {
|
||
return false;
|
||
}
|
||
bool created = cbm_store_upsert_project(store, project, root_path) == CBM_STORE_OK;
|
||
cbm_store_close(store);
|
||
return created;
|
||
}
|
||
|
||
/* Seed exactly one HTTP route match without invoking the indexing pipeline.
|
||
* This keeps the duplicate-target regression fast and makes a doubled result
|
||
* count observable instead of relying on an empty (zero-edge) scan. */
|
||
static bool mcp_cross_repo_seed_http_match(const char *cache, const char *source_project,
|
||
const char *target_project, const char *root_path) {
|
||
char source_path[CBM_SZ_1K];
|
||
char target_path[CBM_SZ_1K];
|
||
snprintf(source_path, sizeof(source_path), "%s/%s.db", cache, source_project);
|
||
snprintf(target_path, sizeof(target_path), "%s/%s.db", cache, target_project);
|
||
|
||
cbm_store_t *source = cbm_store_open_path(source_path);
|
||
cbm_store_t *target = cbm_store_open_path(target_path);
|
||
if (!source || !target) {
|
||
cbm_store_close(source);
|
||
cbm_store_close(target);
|
||
return false;
|
||
}
|
||
|
||
bool ok = cbm_store_upsert_project(source, source_project, root_path) == CBM_STORE_OK &&
|
||
cbm_store_upsert_project(target, target_project, root_path) == CBM_STORE_OK;
|
||
|
||
cbm_node_t caller = {.project = source_project,
|
||
.label = "Function",
|
||
.name = "call_once",
|
||
.qualified_name = "cross.source.call_once",
|
||
.file_path = "client.c",
|
||
.start_line = 1,
|
||
.end_line = 2};
|
||
cbm_node_t local_route = {.project = source_project,
|
||
.label = "Route",
|
||
.name = "GET /dedupe",
|
||
.qualified_name = "__route__GET__/dedupe",
|
||
.file_path = "client.c",
|
||
.start_line = 3,
|
||
.end_line = 3};
|
||
int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0;
|
||
int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0;
|
||
cbm_edge_t http_call = {.project = source_project,
|
||
.source_id = caller_id,
|
||
.target_id = local_route_id,
|
||
.type = "HTTP_CALLS",
|
||
.properties_json = "{\"url_path\":\"/dedupe\",\"method\":\"GET\"}"};
|
||
ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0;
|
||
|
||
cbm_node_t target_route = {.project = target_project,
|
||
.label = "Route",
|
||
.name = "GET /dedupe",
|
||
.qualified_name = "__route__GET__/dedupe",
|
||
.file_path = "server.c",
|
||
.start_line = 3,
|
||
.end_line = 3};
|
||
cbm_node_t handler = {.project = target_project,
|
||
.label = "Function",
|
||
.name = "handle_once",
|
||
.qualified_name = "cross.target.handle_once",
|
||
.file_path = "server.c",
|
||
.start_line = 1,
|
||
.end_line = 2};
|
||
int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0;
|
||
int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0;
|
||
cbm_edge_t handles = {.project = target_project,
|
||
.source_id = handler_id,
|
||
.target_id = target_route_id,
|
||
.type = "HANDLES"};
|
||
ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0;
|
||
|
||
cbm_store_close(source);
|
||
cbm_store_close(target);
|
||
return ok;
|
||
}
|
||
|
||
TEST(tool_cross_repo_mutation_guard_sorts_dedupes_and_unwinds) {
|
||
char repo[256];
|
||
snprintf(repo, sizeof(repo), "/tmp/cbm-mcp-cross-guard-XXXXXX");
|
||
if (!cbm_mkdtemp(repo)) {
|
||
PASS();
|
||
}
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, NULL));
|
||
|
||
mcp_mutation_guard_probe_t probe = {.deny_begin_call = 3};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
|
||
char args[CBM_SZ_2K];
|
||
snprintf(args, sizeof(args),
|
||
"{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\","
|
||
"\"target_projects\":[\"zzz-target\",\"000-target\",\"zzz-target\"]}",
|
||
repo);
|
||
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "blocked"));
|
||
|
||
/* The source plus two unique targets are acquired in lexical order. The
|
||
* third acquisition is denied, so only the first two are unwound. */
|
||
ASSERT_EQ(probe.begin_count, 3);
|
||
ASSERT_TRUE(strcmp(probe.begin_projects[0], probe.begin_projects[1]) < 0);
|
||
ASSERT_TRUE(strcmp(probe.begin_projects[1], probe.begin_projects[2]) < 0);
|
||
int low_target_count = 0;
|
||
int high_target_count = 0;
|
||
for (int i = 0; i < probe.begin_count; i++) {
|
||
low_target_count += strcmp(probe.begin_projects[i], "000-target") == 0;
|
||
high_target_count += strcmp(probe.begin_projects[i], "zzz-target") == 0;
|
||
}
|
||
ASSERT_EQ(low_target_count, 1);
|
||
ASSERT_EQ(high_target_count, 1);
|
||
ASSERT_EQ(probe.end_count, 2);
|
||
ASSERT_STR_EQ(probe.end_projects[0], probe.begin_projects[1]);
|
||
ASSERT_STR_EQ(probe.end_projects[1], probe.begin_projects[0]);
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cbm_rmdir(repo);
|
||
PASS();
|
||
}
|
||
|
||
static unsigned char mcp_test_ascii_casefold(unsigned char ch) {
|
||
return ch >= 'A' && ch <= 'Z' ? (unsigned char)(ch + ('a' - 'A')) : ch;
|
||
}
|
||
|
||
static bool mcp_test_project_keys_equivalent(const char *left, const char *right) {
|
||
if (!left || !right) {
|
||
return left == right;
|
||
}
|
||
while (*left && *right) {
|
||
if (mcp_test_ascii_casefold((unsigned char)*left) !=
|
||
mcp_test_ascii_casefold((unsigned char)*right)) {
|
||
return false;
|
||
}
|
||
left++;
|
||
right++;
|
||
}
|
||
return *left == *right;
|
||
}
|
||
|
||
/* Project-lock keys ASCII-fold A-Z, so case aliases must be one lease here too.
|
||
* Otherwise Foo + foo self-deadlocks, and two requests whose raw strcmp order
|
||
* differs can acquire the same OS locks in opposite (ABBA) order. Keep the
|
||
* original spellings: folding is only the comparison key, not a lookup value. */
|
||
TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order) {
|
||
char repo[256];
|
||
snprintf(repo, sizeof(repo), "/tmp/cbm-mcp-cross-case-guard-XXXXXX");
|
||
if (!cbm_mkdtemp(repo)) {
|
||
PASS();
|
||
}
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, repo, NULL));
|
||
|
||
mcp_mutation_guard_probe_t first = {0};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &first);
|
||
char first_args[CBM_SZ_2K];
|
||
snprintf(first_args, sizeof(first_args),
|
||
"{\"repo_path\":\"%s\",\"name\":\"Zulu\","
|
||
"\"mode\":\"cross-repo-intelligence\","
|
||
"\"target_projects\":[\"Foo\",\"foo\",\"Alpha\"]}",
|
||
repo);
|
||
char *first_resp = cbm_mcp_handle_tool(srv, "index_repository", first_args);
|
||
ASSERT_NOT_NULL(first_resp);
|
||
free(first_resp);
|
||
|
||
mcp_mutation_guard_probe_t second = {0};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &second);
|
||
char second_args[CBM_SZ_2K];
|
||
snprintf(second_args, sizeof(second_args),
|
||
"{\"repo_path\":\"%s\",\"name\":\"zULU\","
|
||
"\"mode\":\"cross-repo-intelligence\","
|
||
"\"target_projects\":[\"foo\",\"ALPHA\",\"FOO\"]}",
|
||
repo);
|
||
char *second_resp = cbm_mcp_handle_tool(srv, "index_repository", second_args);
|
||
ASSERT_NOT_NULL(second_resp);
|
||
free(second_resp);
|
||
|
||
ASSERT_EQ(first.begin_count, 3);
|
||
ASSERT_EQ(first.end_count, 3);
|
||
ASSERT_EQ(second.begin_count, 3);
|
||
ASSERT_EQ(second.end_count, 3);
|
||
for (int i = 0; i < 3; i++) {
|
||
ASSERT_TRUE(
|
||
mcp_test_project_keys_equivalent(first.begin_projects[i], second.begin_projects[i]));
|
||
ASSERT_TRUE(
|
||
mcp_test_project_keys_equivalent(first.end_projects[i], first.begin_projects[2 - i]));
|
||
ASSERT_TRUE(
|
||
mcp_test_project_keys_equivalent(second.end_projects[i], second.begin_projects[2 - i]));
|
||
}
|
||
ASSERT_STR_EQ(first.begin_projects[0], "Alpha");
|
||
ASSERT_STR_EQ(first.begin_projects[1], "Foo");
|
||
ASSERT_STR_EQ(first.begin_projects[2], "Zulu");
|
||
ASSERT_STR_EQ(second.begin_projects[0], "ALPHA");
|
||
ASSERT_STR_EQ(second.begin_projects[1], "FOO");
|
||
ASSERT_STR_EQ(second.begin_projects[2], "zULU");
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cbm_rmdir(repo);
|
||
PASS();
|
||
}
|
||
|
||
/* A wildcard means "all projects" and therefore cannot be combined with a
|
||
* named target. Accepting the mixed form both obscures caller intent and lets
|
||
* the cross-repo pass create/use a literal "*.db" target on POSIX. Validation
|
||
* must happen before any project mutation lease is acquired. */
|
||
TEST(tool_cross_repo_rejects_wildcard_mixed_with_named_targets) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-wildcard-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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 *project = cbm_project_name_from_path(cache);
|
||
ASSERT_NOT_NULL(project);
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL));
|
||
|
||
mcp_mutation_guard_probe_t probe = {0};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
|
||
char args[CBM_SZ_2K];
|
||
snprintf(args, sizeof(args),
|
||
"{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\","
|
||
"\"target_projects\":[\"*\",\"named-target\"]}",
|
||
cache);
|
||
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
bool rejected = resp && strstr(resp, "\"isError\":true") != NULL;
|
||
bool explained = resp && strstr(resp, "target_projects") && strstr(resp, "*") &&
|
||
(strstr(resp, "only") || strstr(resp, "combin"));
|
||
int begin_count = probe.begin_count;
|
||
int end_count = probe.end_count;
|
||
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_project_db(cache, project);
|
||
cleanup_project_db(cache, "*");
|
||
cleanup_project_db(cache, "named-target");
|
||
free(project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_TRUE(rejected);
|
||
ASSERT_TRUE(explained);
|
||
ASSERT_EQ(begin_count, 0);
|
||
ASSERT_EQ(end_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* Cancellation can arrive while the final mutation lease is being acquired.
|
||
* The cross-repo operation must advertise itself through cancel_active(),
|
||
* observe the pending cancellation before doing cross-project writes, and
|
||
* unwind every lease it acquired. */
|
||
TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-cancel-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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 *project = cbm_project_name_from_path(cache);
|
||
ASSERT_NOT_NULL(project);
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL));
|
||
|
||
mcp_mutation_guard_probe_t probe = {
|
||
.cancel_on_begin_call = 3,
|
||
.cancel_server = srv,
|
||
};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
|
||
char args[CBM_SZ_2K];
|
||
snprintf(args, sizeof(args),
|
||
"{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\","
|
||
"\"target_projects\":[\"000-cancel-target\",\"zzz-cancel-target\"]}",
|
||
cache);
|
||
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
bool response_cancelled = resp && strstr(resp, "cancelled") != NULL;
|
||
bool cancel_attempted = probe.cancel_attempted;
|
||
bool cancel_accepted = probe.cancel_accepted;
|
||
int begin_count = probe.begin_count;
|
||
int end_count = probe.end_count;
|
||
bool reverse_unwind = begin_count == 3 && end_count == 3 &&
|
||
strcmp(probe.end_projects[0], probe.begin_projects[2]) == 0 &&
|
||
strcmp(probe.end_projects[1], probe.begin_projects[1]) == 0 &&
|
||
strcmp(probe.end_projects[2], probe.begin_projects[0]) == 0;
|
||
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_project_db(cache, project);
|
||
cleanup_project_db(cache, "000-cancel-target");
|
||
cleanup_project_db(cache, "zzz-cancel-target");
|
||
free(project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_TRUE(cancel_attempted);
|
||
ASSERT_TRUE(cancel_accepted);
|
||
ASSERT_TRUE(response_cancelled);
|
||
ASSERT_EQ(begin_count, 3);
|
||
ASSERT_EQ(end_count, 3);
|
||
ASSERT_TRUE(reverse_unwind);
|
||
PASS();
|
||
}
|
||
|
||
/* cbm_store_open_path() creates its path. Cross-repo validation must therefore
|
||
* reject an absent source or named target before the matcher opens either one;
|
||
* otherwise a typo silently becomes a valid-looking empty project database. */
|
||
TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-missing-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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 *source_project = cbm_project_name_from_path(cache);
|
||
ASSERT_NOT_NULL(source_project);
|
||
const char *existing_target = "existing-cross-target";
|
||
const char *missing_target = "missing-cross-target";
|
||
ASSERT_TRUE(mcp_cross_repo_create_project_store(cache, existing_target, cache));
|
||
|
||
char source_db_path[CBM_SZ_1K];
|
||
char missing_target_db_path[CBM_SZ_1K];
|
||
snprintf(source_db_path, sizeof(source_db_path), "%s/%s.db", cache, source_project);
|
||
snprintf(missing_target_db_path, sizeof(missing_target_db_path), "%s/%s.db", cache,
|
||
missing_target);
|
||
ASSERT_FALSE(cbm_file_exists(source_db_path));
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL));
|
||
|
||
char args[CBM_SZ_2K];
|
||
snprintf(args, sizeof(args),
|
||
"{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\","
|
||
"\"target_projects\":[\"%s\"]}",
|
||
cache, existing_target);
|
||
char *source_resp = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
bool source_failed = source_resp && strstr(source_resp, "\"isError\":true");
|
||
bool source_reported =
|
||
source_resp && (strstr(source_resp, "not indexed") || strstr(source_resp, "not found") ||
|
||
strstr(source_resp, "missing"));
|
||
bool source_ghost_created = cbm_file_exists(source_db_path);
|
||
free(source_resp);
|
||
|
||
cleanup_project_db(cache, source_project);
|
||
ASSERT_TRUE(mcp_cross_repo_create_project_store(cache, source_project, cache));
|
||
ASSERT_FALSE(cbm_file_exists(missing_target_db_path));
|
||
|
||
snprintf(args, sizeof(args),
|
||
"{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\","
|
||
"\"target_projects\":[\"%s\"]}",
|
||
cache, missing_target);
|
||
char *target_resp = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
bool target_failed = target_resp && strstr(target_resp, "\"isError\":true");
|
||
bool target_reported =
|
||
target_resp && (strstr(target_resp, "not indexed") || strstr(target_resp, "not found") ||
|
||
strstr(target_resp, "missing"));
|
||
bool target_ghost_created = cbm_file_exists(missing_target_db_path);
|
||
free(target_resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_project_db(cache, source_project);
|
||
cleanup_project_db(cache, existing_target);
|
||
cleanup_project_db(cache, missing_target);
|
||
free(source_project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_TRUE(source_failed);
|
||
ASSERT_TRUE(source_reported);
|
||
ASSERT_FALSE(source_ghost_created);
|
||
ASSERT_TRUE(target_failed);
|
||
ASSERT_TRUE(target_reported);
|
||
ASSERT_FALSE(target_ghost_created);
|
||
PASS();
|
||
}
|
||
|
||
/* Named targets are a set, not a work list. A duplicate must be leased,
|
||
* scanned, and counted once; the fixture provides one real edge so the result
|
||
* counters cannot pass vacuously at zero. */
|
||
TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-dedupe-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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 *source_project = cbm_project_name_from_path(cache);
|
||
ASSERT_NOT_NULL(source_project);
|
||
const char *target_project = "cross-dedupe-target";
|
||
ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache));
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL));
|
||
|
||
char args[CBM_SZ_2K];
|
||
snprintf(args, sizeof(args),
|
||
"{\"repo_path\":\"%s\",\"mode\":\"cross-repo-intelligence\","
|
||
"\"target_projects\":[\"%s\",\"%s\"]}",
|
||
cache, target_project, target_project);
|
||
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
bool succeeded = resp && strstr(resp, "\"isError\":true") == NULL;
|
||
bool scanned_once = response_contains_json_fragment(resp, "\"projects_scanned\":1");
|
||
bool counted_once = response_contains_json_fragment(resp, "\"cross_http_calls\":1") &&
|
||
response_contains_json_fragment(resp, "\"total_cross_edges\":1");
|
||
|
||
char source_db_path[CBM_SZ_1K];
|
||
char target_db_path[CBM_SZ_1K];
|
||
snprintf(source_db_path, sizeof(source_db_path), "%s/%s.db", cache, source_project);
|
||
snprintf(target_db_path, sizeof(target_db_path), "%s/%s.db", cache, target_project);
|
||
cbm_store_t *source = cbm_store_open_path_query(source_db_path);
|
||
cbm_store_t *target = cbm_store_open_path_query(target_db_path);
|
||
int source_cross_edges =
|
||
source ? cbm_store_count_edges_by_type(source, source_project, "CROSS_HTTP_CALLS") : -1;
|
||
int target_cross_edges =
|
||
target ? cbm_store_count_edges_by_type(target, target_project, "CROSS_HTTP_CALLS") : -1;
|
||
cbm_store_close(source);
|
||
cbm_store_close(target);
|
||
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_project_db(cache, source_project);
|
||
cleanup_project_db(cache, target_project);
|
||
free(source_project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_TRUE(succeeded);
|
||
ASSERT_TRUE(scanned_once);
|
||
ASSERT_TRUE(counted_once);
|
||
ASSERT_EQ(source_cross_edges, 1);
|
||
ASSERT_EQ(target_cross_edges, 1);
|
||
PASS();
|
||
}
|
||
|
||
/* `name` is the documented index project-name override and must identify the
|
||
* cross-repo source too. Deriving from repo_path here makes custom-named
|
||
* projects impossible to rescan even though ordinary indexing created them. */
|
||
TEST(tool_cross_repo_honors_source_name_override) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-cross-name-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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);
|
||
|
||
const char *source_project = "cross-custom-source";
|
||
const char *target_project = "cross-custom-target";
|
||
ASSERT_TRUE(mcp_cross_repo_seed_http_match(cache, source_project, target_project, cache));
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
ASSERT_TRUE(cbm_mcp_server_set_session_context(srv, cache, NULL));
|
||
char args[CBM_SZ_2K];
|
||
snprintf(args, sizeof(args),
|
||
"{\"repo_path\":\"%s\",\"name\":\"%s\","
|
||
"\"mode\":\"cross-repo-intelligence\","
|
||
"\"target_projects\":[\"%s\"]}",
|
||
cache, source_project, target_project);
|
||
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
bool succeeded = resp && !response_contains_json_fragment(resp, "\"isError\":true") &&
|
||
response_contains_json_fragment(resp, "\"cross_http_calls\":1");
|
||
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_project_db(cache, source_project);
|
||
cleanup_project_db(cache, target_project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_TRUE(succeeded);
|
||
PASS();
|
||
}
|
||
|
||
/* Corrupt-store quarantine renames/unlinks the project DB and sidecars, so it
|
||
* is a mutation even when resolve_store() was reached by a query tool. The
|
||
* query path needs one balanced lease; manage_adr already owns that project
|
||
* lease and must not acquire a nested second lease during the same cleanup. */
|
||
TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-guard-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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);
|
||
|
||
const char *project = "guard-corrupt-project";
|
||
char db_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
|
||
ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project));
|
||
cbm_mcp_server_t *query_srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(query_srv);
|
||
mcp_mutation_guard_probe_t query_probe = {
|
||
.observed_db_path = db_path,
|
||
};
|
||
cbm_mcp_server_set_project_mutation_guard(query_srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &query_probe);
|
||
|
||
char *resp =
|
||
cbm_mcp_handle_tool(query_srv, "search_graph",
|
||
"{\"project\":\"guard-corrupt-project\",\"name_pattern\":\".*\"}");
|
||
free(resp);
|
||
cbm_mcp_server_free(query_srv);
|
||
char query_backup_path[CBM_SZ_1K];
|
||
int query_backup_count =
|
||
mcp_find_corrupt_backups(cache, project, query_backup_path, sizeof(query_backup_path));
|
||
bool query_quarantined =
|
||
!cbm_file_exists(db_path) && query_backup_count == 1 && query_backup_path[0] != '\0';
|
||
|
||
/* Replant the same deterministic corruption to exercise manage_adr's
|
||
* already-held lease independently from the query server above. */
|
||
mcp_cleanup_corrupt_backups(cache, project);
|
||
ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project));
|
||
cbm_mcp_server_t *adr_srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(adr_srv);
|
||
mcp_mutation_guard_probe_t adr_probe = {
|
||
.observed_db_path = db_path,
|
||
};
|
||
cbm_mcp_server_set_project_mutation_guard(adr_srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &adr_probe);
|
||
resp = cbm_mcp_handle_tool(adr_srv, "manage_adr",
|
||
"{\"project\":\"guard-corrupt-project\",\"mode\":\"get\"}");
|
||
free(resp);
|
||
cbm_mcp_server_free(adr_srv);
|
||
char adr_backup_path[CBM_SZ_1K];
|
||
int adr_backup_count =
|
||
mcp_find_corrupt_backups(cache, project, adr_backup_path, sizeof(adr_backup_path));
|
||
bool adr_quarantined =
|
||
!cbm_file_exists(db_path) && adr_backup_count == 1 && adr_backup_path[0] != '\0';
|
||
|
||
mcp_cleanup_corrupt_backups(cache, project);
|
||
cleanup_project_db(cache, project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_TRUE(query_quarantined);
|
||
ASSERT_EQ(query_probe.begin_count, 1);
|
||
ASSERT_EQ(query_probe.end_count, 1);
|
||
ASSERT_STR_EQ(query_probe.begin_projects[0], project);
|
||
ASSERT_STR_EQ(query_probe.end_projects[0], project);
|
||
ASSERT_TRUE(query_probe.db_exists_at_begin);
|
||
ASSERT_FALSE(query_probe.db_exists_at_end);
|
||
ASSERT_TRUE(adr_quarantined);
|
||
ASSERT_EQ(adr_probe.begin_count, 1);
|
||
ASSERT_EQ(adr_probe.end_count, 1);
|
||
ASSERT_STR_EQ(adr_probe.begin_projects[0], project);
|
||
ASSERT_STR_EQ(adr_probe.end_projects[0], project);
|
||
ASSERT_TRUE(adr_probe.db_exists_at_begin);
|
||
ASSERT_FALSE(adr_probe.db_exists_at_end);
|
||
PASS();
|
||
}
|
||
|
||
/* Integrity is checked before the lease is requested, but quarantine itself
|
||
* must fail closed when that lease is denied. In particular, a rejected query
|
||
* may not remove either a recoverable DB generation or its committed WAL. */
|
||
TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-denied-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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);
|
||
|
||
const char *project = "guard-corrupt-denied";
|
||
char db_path[CBM_SZ_1K];
|
||
char wal_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path);
|
||
cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project);
|
||
ASSERT_NOT_NULL(writer);
|
||
ASSERT_TRUE(cbm_file_exists(db_path));
|
||
ASSERT_TRUE(cbm_file_exists(wal_path));
|
||
|
||
long db_len = 0;
|
||
long wal_len = 0;
|
||
unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len);
|
||
unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len);
|
||
ASSERT_NOT_NULL(db_before);
|
||
ASSERT_NOT_NULL(wal_before);
|
||
ASSERT_TRUE(db_len > 0);
|
||
ASSERT_TRUE(wal_len > 0);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
mcp_mutation_guard_probe_t probe = {.deny_begin_call = 1};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
char *resp = cbm_mcp_handle_tool(
|
||
srv, "search_graph", "{\"project\":\"guard-corrupt-denied\",\"name_pattern\":\".*\"}");
|
||
|
||
bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len);
|
||
bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len);
|
||
char unexpected_backup[CBM_SZ_1K];
|
||
int backup_count =
|
||
mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup));
|
||
int artifact_count = mcp_count_corrupt_artifacts(cache, project);
|
||
int begin_count = probe.begin_count;
|
||
int end_count = probe.end_count;
|
||
bool guarded_project = begin_count == 1 && strcmp(probe.begin_projects[0], project) == 0;
|
||
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
free(db_before);
|
||
free(wal_before);
|
||
cbm_store_close(writer);
|
||
mcp_cleanup_corrupt_backups(cache, project);
|
||
cleanup_project_db(cache, project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_EQ(begin_count, 1);
|
||
ASSERT_EQ(end_count, 0);
|
||
ASSERT_TRUE(guarded_project);
|
||
ASSERT_TRUE(db_unchanged);
|
||
ASSERT_TRUE(wal_unchanged);
|
||
ASSERT_EQ(backup_count, 0);
|
||
ASSERT_EQ(artifact_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* Another session may publish a good generation while this query waits for
|
||
* the mutation lease. Cleanup must re-open and re-check the path after lease
|
||
* acquisition; quarantining based on the stale pre-wait handle loses the new
|
||
* generation and returns a false "not indexed" result. */
|
||
TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-recheck-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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);
|
||
|
||
const char *project = "guard-corrupt-recheck";
|
||
const char *replacement_root = "/tmp/guard-corrupt-replacement";
|
||
char db_path[CBM_SZ_1K];
|
||
char replacement_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
snprintf(replacement_path, sizeof(replacement_path), "%s/%s.replacement.db", cache, project);
|
||
ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project));
|
||
ASSERT_TRUE(mcp_make_valid_project_store_at(replacement_path, project, replacement_root));
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
mcp_replacing_mutation_guard_t replacement = {
|
||
.replacement_path = replacement_path,
|
||
.live_path = db_path,
|
||
};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_replacing_mutation_guard_begin,
|
||
mcp_replacing_mutation_guard_end, &replacement);
|
||
char *resp = cbm_mcp_handle_tool(
|
||
srv, "search_graph", "{\"project\":\"guard-corrupt-recheck\",\"name_pattern\":\".*\"}");
|
||
bool response_used_replacement =
|
||
resp && !response_contains_json_fragment(resp, "\"isError\":true");
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
|
||
cbm_store_t *check = cbm_store_open_path_query(db_path);
|
||
bool valid_generation = check && cbm_store_check_integrity(check);
|
||
cbm_project_t stored_project = {0};
|
||
bool replacement_root_visible =
|
||
check && cbm_store_get_project(check, project, &stored_project) == CBM_STORE_OK &&
|
||
stored_project.root_path && strcmp(stored_project.root_path, replacement_root) == 0;
|
||
cbm_project_free_fields(&stored_project);
|
||
cbm_store_close(check);
|
||
char unexpected_backup[CBM_SZ_1K];
|
||
int backup_count =
|
||
mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup));
|
||
bool live_exists = cbm_file_exists(db_path);
|
||
bool replacement_consumed = !cbm_file_exists(replacement_path);
|
||
int begin_count = replacement.guard.begin_count;
|
||
int end_count = replacement.guard.end_count;
|
||
bool guarded_project = begin_count == 1 && end_count == 1 &&
|
||
strcmp(replacement.guard.begin_projects[0], project) == 0 &&
|
||
strcmp(replacement.guard.end_projects[0], project) == 0;
|
||
bool replacement_attempted = replacement.replacement_attempted;
|
||
bool replacement_succeeded = replacement.replacement_succeeded;
|
||
|
||
mcp_cleanup_corrupt_backups(cache, project);
|
||
cleanup_project_db(cache, project);
|
||
cbm_unlink(replacement_path);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_TRUE(replacement_attempted);
|
||
ASSERT_TRUE(replacement_succeeded);
|
||
ASSERT_TRUE(guarded_project);
|
||
ASSERT_TRUE(response_used_replacement);
|
||
ASSERT_TRUE(live_exists);
|
||
ASSERT_TRUE(replacement_consumed);
|
||
ASSERT_TRUE(valid_generation);
|
||
ASSERT_TRUE(replacement_root_visible);
|
||
ASSERT_EQ(backup_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* A fixed `.corrupt` destination is itself user recovery data. A later
|
||
* quarantine must retain it byte-for-byte and choose a distinct backup name
|
||
* rather than unlinking the previous incident before rename. */
|
||
TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-unique-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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);
|
||
|
||
const char *project = "guard-corrupt-unique";
|
||
char db_path[CBM_SZ_1K];
|
||
char existing_backup_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
snprintf(existing_backup_path, sizeof(existing_backup_path), "%s.corrupt", db_path);
|
||
ASSERT_TRUE(mcp_make_corrupt_project_store(cache, project));
|
||
ASSERT_EQ(th_write_file(existing_backup_path, "previous-backup-must-survive\n"), 0);
|
||
|
||
long existing_len = 0;
|
||
unsigned char *existing_before = mcp_read_file_bytes(existing_backup_path, &existing_len);
|
||
ASSERT_NOT_NULL(existing_before);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
mcp_mutation_guard_probe_t probe = {0};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &probe);
|
||
char *resp = cbm_mcp_handle_tool(
|
||
srv, "search_graph", "{\"project\":\"guard-corrupt-unique\",\"name_pattern\":\".*\"}");
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
|
||
bool existing_unchanged =
|
||
mcp_file_matches_snapshot(existing_backup_path, existing_before, existing_len);
|
||
free(existing_before);
|
||
char unique_backup_path[CBM_SZ_1K];
|
||
int backup_count =
|
||
mcp_find_corrupt_backups(cache, project, unique_backup_path, sizeof(unique_backup_path));
|
||
cbm_store_t *quarantined =
|
||
unique_backup_path[0] ? cbm_store_open_path_query(unique_backup_path) : NULL;
|
||
bool unique_backup_is_corrupt = quarantined && !cbm_store_check_integrity(quarantined);
|
||
cbm_store_close(quarantined);
|
||
bool live_removed = !cbm_file_exists(db_path);
|
||
int begin_count = probe.begin_count;
|
||
int end_count = probe.end_count;
|
||
bool guarded_project = begin_count == 1 && end_count == 1 &&
|
||
strcmp(probe.begin_projects[0], project) == 0 &&
|
||
strcmp(probe.end_projects[0], project) == 0;
|
||
|
||
mcp_cleanup_corrupt_backups(cache, project);
|
||
cleanup_project_db(cache, project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_TRUE(guarded_project);
|
||
ASSERT_TRUE(existing_unchanged);
|
||
ASSERT_EQ(backup_count, 2);
|
||
ASSERT_TRUE(unique_backup_path[0] != '\0');
|
||
ASSERT_TRUE(unique_backup_is_corrupt);
|
||
ASSERT_TRUE(live_removed);
|
||
PASS();
|
||
}
|
||
|
||
/* Deterministically fail immediately before atomic snapshot publication on
|
||
* every platform. The incomplete pending copy must be removed while the live
|
||
* DB and its committed WAL remain byte-for-byte untouched. */
|
||
TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-publish-fail-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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);
|
||
|
||
const char *project = "guard-corrupt-publish-fail";
|
||
char db_path[CBM_SZ_1K];
|
||
char wal_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path);
|
||
cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project);
|
||
ASSERT_NOT_NULL(writer);
|
||
ASSERT_TRUE(cbm_file_exists(wal_path));
|
||
|
||
long db_len = 0;
|
||
long wal_len = 0;
|
||
unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len);
|
||
unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len);
|
||
ASSERT_NOT_NULL(db_before);
|
||
ASSERT_NOT_NULL(wal_before);
|
||
ASSERT_TRUE(db_len > 0);
|
||
ASSERT_TRUE(wal_len > 0);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
mcp_mutation_guard_probe_t guard = {0};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &guard);
|
||
mcp_quarantine_hook_probe_t hook = {.deny_step = "before_snapshot_publish"};
|
||
cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook);
|
||
char *resp =
|
||
cbm_mcp_handle_tool(srv, "search_graph",
|
||
"{\"project\":\"guard-corrupt-publish-fail\",\"name_pattern\":\".*\"}");
|
||
|
||
bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len);
|
||
bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len);
|
||
char unexpected_backup[CBM_SZ_1K];
|
||
int backup_count =
|
||
mcp_find_corrupt_backups(cache, project, unexpected_backup, sizeof(unexpected_backup));
|
||
int artifact_count = mcp_count_corrupt_artifacts(cache, project);
|
||
int begin_count = guard.begin_count;
|
||
int end_count = guard.end_count;
|
||
bool guarded_project = begin_count == 1 && end_count == 1 &&
|
||
strcmp(guard.begin_projects[0], project) == 0 &&
|
||
strcmp(guard.end_projects[0], project) == 0;
|
||
bool failed_at_publish =
|
||
hook.call_count == 1 && strcmp(hook.steps[0], "before_snapshot_publish") == 0;
|
||
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
free(db_before);
|
||
free(wal_before);
|
||
cbm_store_close(writer);
|
||
mcp_cleanup_corrupt_backups(cache, project);
|
||
cleanup_project_db(cache, project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_TRUE(failed_at_publish);
|
||
ASSERT_TRUE(guarded_project);
|
||
ASSERT_TRUE(db_unchanged);
|
||
ASSERT_TRUE(wal_unchanged);
|
||
ASSERT_EQ(backup_count, 0);
|
||
ASSERT_EQ(artifact_count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* Once the recovery snapshot is atomically visible, a crash/failure before
|
||
* deleting the live generation may leave both copies. The live DB/WAL must be
|
||
* unchanged, and the published backup must already contain committed WAL data
|
||
* as one self-contained SQLite database. */
|
||
TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-mcp-corrupt-after-publish-XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||
|
||
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);
|
||
|
||
const char *project = "guard-corrupt-after-publish";
|
||
char db_path[CBM_SZ_1K];
|
||
char wal_path[CBM_SZ_1K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
snprintf(wal_path, sizeof(wal_path), "%s-wal", db_path);
|
||
cbm_store_t *writer = mcp_open_corrupt_project_store_with_wal(cache, project);
|
||
ASSERT_NOT_NULL(writer);
|
||
ASSERT_TRUE(cbm_file_exists(wal_path));
|
||
|
||
long db_len = 0;
|
||
long wal_len = 0;
|
||
unsigned char *db_before = mcp_read_file_bytes(db_path, &db_len);
|
||
unsigned char *wal_before = mcp_read_file_bytes(wal_path, &wal_len);
|
||
ASSERT_NOT_NULL(db_before);
|
||
ASSERT_NOT_NULL(wal_before);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
mcp_mutation_guard_probe_t guard = {0};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &guard);
|
||
mcp_quarantine_hook_probe_t hook = {.deny_step = "after_snapshot_publish"};
|
||
cbm_mcp_server_set_quarantine_test_hook(srv, mcp_quarantine_hook_probe, &hook);
|
||
char *resp = cbm_mcp_handle_tool(
|
||
srv, "search_graph",
|
||
"{\"project\":\"guard-corrupt-after-publish\",\"name_pattern\":\".*\"}");
|
||
|
||
bool db_unchanged = mcp_file_matches_snapshot(db_path, db_before, db_len);
|
||
bool wal_unchanged = mcp_file_matches_snapshot(wal_path, wal_before, wal_len);
|
||
char backup_path[CBM_SZ_1K];
|
||
int backup_count = mcp_find_corrupt_backups(cache, project, backup_path, sizeof(backup_path));
|
||
int artifact_count = mcp_count_corrupt_artifacts(cache, project);
|
||
cbm_store_t *snapshot = backup_path[0] ? cbm_store_open_path_query(backup_path) : NULL;
|
||
cbm_project_t recovered = {0};
|
||
bool recovered_wal_project =
|
||
snapshot && cbm_store_get_project(snapshot, project, &recovered) == CBM_STORE_OK &&
|
||
recovered.root_path && strcmp(recovered.root_path, "826") == 0;
|
||
cbm_project_free_fields(&recovered);
|
||
cbm_store_close(snapshot);
|
||
char backup_wal[CBM_SZ_2K];
|
||
char backup_shm[CBM_SZ_2K];
|
||
snprintf(backup_wal, sizeof(backup_wal), "%s-wal", backup_path);
|
||
snprintf(backup_shm, sizeof(backup_shm), "%s-shm", backup_path);
|
||
bool snapshot_self_contained = !cbm_file_exists(backup_wal) && !cbm_file_exists(backup_shm);
|
||
bool hook_order = hook.call_count == 2 &&
|
||
strcmp(hook.steps[0], "before_snapshot_publish") == 0 &&
|
||
strcmp(hook.steps[1], "after_snapshot_publish") == 0;
|
||
bool guard_balanced = guard.begin_count == 1 && guard.end_count == 1 &&
|
||
strcmp(guard.begin_projects[0], project) == 0 &&
|
||
strcmp(guard.end_projects[0], project) == 0;
|
||
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
free(db_before);
|
||
free(wal_before);
|
||
cbm_store_close(writer);
|
||
mcp_cleanup_corrupt_backups(cache, project);
|
||
cleanup_project_db(cache, project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
cbm_rmdir(cache);
|
||
|
||
ASSERT_TRUE(hook_order);
|
||
ASSERT_TRUE(guard_balanced);
|
||
ASSERT_TRUE(db_unchanged);
|
||
ASSERT_TRUE(wal_unchanged);
|
||
ASSERT_EQ(backup_count, 1);
|
||
ASSERT_EQ(artifact_count, 1);
|
||
ASSERT_TRUE(recovered_wal_project);
|
||
ASSERT_TRUE(snapshot_self_contained);
|
||
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();
|
||
}
|
||
|
||
/* detect_changes owns shell output through regular temporary files. An error
|
||
* after opening that file must use fclose + unlink. The command hook then
|
||
* rejects merge-base only when it reaches the contained subprocess helper, so
|
||
* a raw popen regression bypasses the hook and fails this test. */
|
||
TEST(tool_detect_changes_contained_commands_clean_up_error_and_success) {
|
||
char cache[512];
|
||
(void)snprintf(cache, sizeof(cache), "%s/cbm-detect-contained-XXXXXX", cbm_tmpdir());
|
||
bool cache_created = cbm_mkdtemp(cache) != NULL;
|
||
char repo[CBM_SZ_4K];
|
||
(void)snprintf(repo, sizeof(repo), "%s/cbm-detect-repo-XXXXXX", cbm_tmpdir());
|
||
bool repo_created = cbm_mkdtemp(repo) != NULL;
|
||
char empty_template[CBM_SZ_4K];
|
||
char empty_hooks[CBM_SZ_4K];
|
||
char template_argument[CBM_SZ_4K];
|
||
char hooks_config[CBM_SZ_4K];
|
||
char hostile_template[CBM_SZ_4K];
|
||
char hostile_template_hooks[CBM_SZ_4K];
|
||
char hostile_hooks[CBM_SZ_4K];
|
||
char hostile_hook[CBM_SZ_4K];
|
||
char hostile_config[CBM_SZ_4K];
|
||
int template_length =
|
||
snprintf(empty_template, sizeof(empty_template), "%s/.cbm-empty-template", repo);
|
||
int hooks_length = snprintf(empty_hooks, sizeof(empty_hooks), "%s/.cbm-empty-hooks", repo);
|
||
int template_argument_length =
|
||
snprintf(template_argument, sizeof(template_argument), "--template=%s", empty_template);
|
||
int hooks_config_length =
|
||
snprintf(hooks_config, sizeof(hooks_config), "core.hooksPath=%s", empty_hooks);
|
||
int hostile_template_length =
|
||
snprintf(hostile_template, sizeof(hostile_template), "%s/.cbm-hostile-template", repo);
|
||
int hostile_template_hooks_length = snprintf(
|
||
hostile_template_hooks, sizeof(hostile_template_hooks), "%s/hooks", hostile_template);
|
||
int hostile_hooks_length =
|
||
snprintf(hostile_hooks, sizeof(hostile_hooks), "%s/.cbm-hostile-hooks", repo);
|
||
int hostile_hook_length =
|
||
snprintf(hostile_hook, sizeof(hostile_hook), "%s/pre-commit", hostile_hooks);
|
||
int hostile_config_length =
|
||
snprintf(hostile_config, sizeof(hostile_config), "%s/.cbm-hostile-gitconfig", repo);
|
||
bool git_isolation_ready =
|
||
repo_created && template_length > 0 && (size_t)template_length < sizeof(empty_template) &&
|
||
hooks_length > 0 && (size_t)hooks_length < sizeof(empty_hooks) &&
|
||
template_argument_length > 0 &&
|
||
(size_t)template_argument_length < sizeof(template_argument) && hooks_config_length > 0 &&
|
||
(size_t)hooks_config_length < sizeof(hooks_config) && cbm_mkdir(empty_template) == 0 &&
|
||
cbm_mkdir(empty_hooks) == 0;
|
||
bool hostile_paths_ready =
|
||
git_isolation_ready && hostile_template_length > 0 &&
|
||
(size_t)hostile_template_length < sizeof(hostile_template) &&
|
||
hostile_template_hooks_length > 0 &&
|
||
(size_t)hostile_template_hooks_length < sizeof(hostile_template_hooks) &&
|
||
hostile_hooks_length > 0 && (size_t)hostile_hooks_length < sizeof(hostile_hooks) &&
|
||
hostile_hook_length > 0 && (size_t)hostile_hook_length < sizeof(hostile_hook) &&
|
||
hostile_config_length > 0 && (size_t)hostile_config_length < sizeof(hostile_config) &&
|
||
cbm_mkdir(hostile_template) == 0 && cbm_mkdir(hostile_template_hooks) == 0 &&
|
||
cbm_mkdir(hostile_hooks) == 0;
|
||
FILE *hostile_hook_file = hostile_paths_ready ? cbm_fopen(hostile_hook, "wb") : NULL;
|
||
bool hostile_hook_ready = false;
|
||
if (hostile_hook_file) {
|
||
bool hook_written = fputs("#!/bin/sh\nexit 91\n", hostile_hook_file) >= 0;
|
||
bool hook_closed = fclose(hostile_hook_file) == 0;
|
||
hostile_hook_ready = hook_written && hook_closed && chmod(hostile_hook, 0700) == 0;
|
||
}
|
||
FILE *hostile_config_file = hostile_hook_ready ? cbm_fopen(hostile_config, "wb") : NULL;
|
||
bool hostile_config_ready = false;
|
||
if (hostile_config_file) {
|
||
bool config_written =
|
||
fprintf(hostile_config_file, "[init]\n\ttemplateDir = %s\n[core]\n\thooksPath = %s\n",
|
||
hostile_template, hostile_hooks) > 0;
|
||
bool config_closed = fclose(hostile_config_file) == 0;
|
||
hostile_config_ready = config_written && config_closed;
|
||
}
|
||
mcp_test_env_backup_t ambient_git = {.name = "GIT_CONFIG_GLOBAL"};
|
||
const char *ambient_git_value = getenv(ambient_git.name);
|
||
ambient_git.present = ambient_git_value != NULL;
|
||
ambient_git.value = ambient_git_value ? strdup(ambient_git_value) : NULL;
|
||
bool ambient_git_saved = !ambient_git_value || ambient_git.value;
|
||
bool hostile_environment_ready = hostile_config_ready && ambient_git_saved &&
|
||
cbm_setenv("GIT_CONFIG_GLOBAL", hostile_config, 1) == 0;
|
||
const char *const init_args[] = {"init", "-q", template_argument, NULL};
|
||
const char *const commit_args[] = {
|
||
"-c", "user.name=cbm-test",
|
||
"-c", "user.email=cbm-test@example.invalid",
|
||
"-c", "commit.gpgsign=false",
|
||
"-c", hooks_config,
|
||
"commit", "--allow-empty",
|
||
"-q", "-m",
|
||
"fixture", NULL,
|
||
};
|
||
bool repo_ready = hostile_environment_ready && mcp_test_git(repo, init_args) == 0 &&
|
||
mcp_test_git(repo, commit_args) == 0;
|
||
if (ambient_git_saved) {
|
||
mcp_test_restore_env(&ambient_git, 1U);
|
||
}
|
||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||
bool environment_ready = cache_created && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0;
|
||
|
||
const char *project = "detect-contained-project";
|
||
cbm_mcp_server_t *srv = environment_ready && repo_ready ? cbm_mcp_server_new(NULL) : NULL;
|
||
bool server_ready = srv != NULL;
|
||
cbm_store_t *store = srv ? cbm_mcp_server_store(srv) : NULL;
|
||
bool project_ready = store && cbm_store_upsert_project(store, project, repo) == CBM_STORE_OK;
|
||
mcp_command_hook_probe_t command_probe = {.reject_merge_base = true};
|
||
if (project_ready) {
|
||
cbm_mcp_server_set_project(srv, project);
|
||
cbm_mcp_server_set_command_test_hook(srv, mcp_command_hook_probe, &command_probe);
|
||
}
|
||
|
||
char *invalid_response =
|
||
project_ready ? cbm_mcp_handle_tool(srv, "detect_changes",
|
||
"{\"project\":\"detect-contained-project\","
|
||
"\"base_branch\":\"HEAD\",\"scope\":\"files\","
|
||
"\"direction\":\"sideways\"}")
|
||
: NULL;
|
||
bool invalid_rejected = invalid_response && strstr(invalid_response, "invalid direction");
|
||
char logs[640];
|
||
(void)snprintf(logs, sizeof(logs), "%s/logs", cache);
|
||
int artifacts_after_error =
|
||
invalid_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1;
|
||
|
||
char *rejected_response =
|
||
project_ready ? cbm_mcp_handle_tool(srv, "detect_changes",
|
||
"{\"project\":\"detect-contained-project\","
|
||
"\"base_branch\":\"HEAD\",\"scope\":\"files\"}")
|
||
: NULL;
|
||
bool containment_rejected =
|
||
rejected_response && strstr(rejected_response, "contained command could not complete");
|
||
int artifacts_after_rejection =
|
||
rejected_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1;
|
||
|
||
command_probe.reject_merge_base = false;
|
||
char *success_response =
|
||
project_ready ? cbm_mcp_handle_tool(srv, "detect_changes",
|
||
"{\"project\":\"detect-contained-project\","
|
||
"\"base_branch\":\"HEAD\",\"scope\":\"files\"}")
|
||
: NULL;
|
||
bool merge_base_reported = success_response && strstr(success_response, "merge_base");
|
||
int artifacts_after_success =
|
||
success_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1;
|
||
|
||
free(invalid_response);
|
||
free(rejected_response);
|
||
free(success_response);
|
||
cbm_mcp_server_free(srv);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
bool cleaned = !cache_created || th_rmtree(cache) == 0;
|
||
/* Git for Windows makes loose objects read-only; the shared test cleanup
|
||
* must still remove the entire self-contained fixture. */
|
||
bool repo_cleaned = !repo_created || th_rmtree(repo) == 0;
|
||
|
||
ASSERT_TRUE(cache_created);
|
||
ASSERT_TRUE(repo_created);
|
||
ASSERT_TRUE(repo_ready);
|
||
ASSERT_TRUE(environment_ready);
|
||
ASSERT_TRUE(server_ready);
|
||
ASSERT_TRUE(project_ready);
|
||
ASSERT_TRUE(invalid_rejected);
|
||
ASSERT_EQ(artifacts_after_error, 0);
|
||
ASSERT_TRUE(containment_rejected);
|
||
ASSERT_EQ(artifacts_after_rejection, 0);
|
||
ASSERT_TRUE(merge_base_reported);
|
||
ASSERT_EQ(artifacts_after_success, 0);
|
||
ASSERT_EQ(command_probe.diff_calls, 3);
|
||
ASSERT_EQ(command_probe.merge_base_calls, 2);
|
||
ASSERT_TRUE(cleaned);
|
||
ASSERT_TRUE(repo_cleaned);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_ingest_traces_basic) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":37,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"ingest_traces\","
|
||
"\"arguments\":{\"traces\":[{\"caller\":\"a\",\"callee\":\"b\"}]}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "accepted"));
|
||
ASSERT_NOT_NULL(strstr(resp, "traces_received"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(tool_ingest_traces_empty) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":38,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"ingest_traces\","
|
||
"\"arguments\":{\"traces\":[]}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "accepted"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* IDLE STORE EVICTION
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(store_idle_eviction) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
cbm_mcp_server_set_project(srv, "test-evict");
|
||
|
||
/* Trigger resolve_store via a tool call to set store_last_used */
|
||
char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}");
|
||
free(resp);
|
||
|
||
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
|
||
|
||
/* Evict with 0s timeout → should evict immediately */
|
||
cbm_mcp_server_evict_idle(srv, 0);
|
||
ASSERT_FALSE(cbm_mcp_server_has_cached_store(srv));
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(store_idle_no_eviction_within_timeout) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
cbm_mcp_server_set_project(srv, "test-evict");
|
||
|
||
char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}");
|
||
free(resp);
|
||
|
||
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
|
||
|
||
/* Evict with large timeout → should NOT evict */
|
||
cbm_mcp_server_evict_idle(srv, 99999);
|
||
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(store_idle_evict_protects_initial_store) {
|
||
/* Evicting with NULL server should not crash */
|
||
cbm_mcp_server_evict_idle(NULL, 0);
|
||
|
||
/* Evicting server whose store was never accessed via a named project
|
||
* should NOT evict the initial in-memory store (store_last_used == 0). */
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
|
||
cbm_mcp_server_evict_idle(srv, 0);
|
||
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(store_idle_evict_access_resets_timer) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
cbm_mcp_server_set_project(srv, "test-evict");
|
||
|
||
/* First access */
|
||
char *resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}");
|
||
free(resp);
|
||
|
||
/* Second access (resets timer) */
|
||
resp = cbm_mcp_handle_tool(srv, "get_graph_schema", "{\"project\":\"test-evict\"}");
|
||
free(resp);
|
||
|
||
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
|
||
|
||
/* With large timeout, store should survive */
|
||
cbm_mcp_server_evict_idle(srv, 99999);
|
||
ASSERT_TRUE(cbm_mcp_server_has_cached_store(srv));
|
||
|
||
/* With 0 timeout, store should be evicted */
|
||
cbm_mcp_server_evict_idle(srv, 0);
|
||
ASSERT_FALSE(cbm_mcp_server_has_cached_store(srv));
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* URI HELPERS
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(parse_file_uri_unix) {
|
||
char path[256];
|
||
ASSERT_TRUE(cbm_parse_file_uri("file:///home/user/project", path, sizeof(path)));
|
||
ASSERT_STR_EQ(path, "/home/user/project");
|
||
|
||
ASSERT_TRUE(cbm_parse_file_uri("file:///tmp/test", path, sizeof(path)));
|
||
ASSERT_STR_EQ(path, "/tmp/test");
|
||
|
||
ASSERT_TRUE(cbm_parse_file_uri("file:///", path, sizeof(path)));
|
||
ASSERT_STR_EQ(path, "/");
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_file_uri_windows) {
|
||
char path[256];
|
||
/* Windows drive letter — leading / stripped */
|
||
ASSERT_TRUE(cbm_parse_file_uri("file:///C:/Users/project", path, sizeof(path)));
|
||
ASSERT_STR_EQ(path, "C:/Users/project");
|
||
|
||
ASSERT_TRUE(cbm_parse_file_uri("file:///D:/Projects/myapp", path, sizeof(path)));
|
||
ASSERT_STR_EQ(path, "D:/Projects/myapp");
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_file_uri_invalid) {
|
||
char path[256];
|
||
/* Non-file URI */
|
||
ASSERT_FALSE(cbm_parse_file_uri("https://example.com", path, sizeof(path)));
|
||
ASSERT_STR_EQ(path, "");
|
||
|
||
/* Empty string */
|
||
ASSERT_FALSE(cbm_parse_file_uri("", path, sizeof(path)));
|
||
ASSERT_STR_EQ(path, "");
|
||
|
||
/* NULL */
|
||
ASSERT_FALSE(cbm_parse_file_uri(NULL, path, sizeof(path)));
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* SNIPPET TESTS — Port of internal/tools/snippet_test.go
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
#include <stdio.h>
|
||
#include <sys/stat.h>
|
||
#include <unistd.h>
|
||
|
||
/* Create an MCP server pre-populated with nodes/edges matching Go testSnippetServer.
|
||
* Writes a source file to tmp_dir/project/main.go.
|
||
* Caller must free the server with cbm_mcp_server_free and
|
||
* unlink the source file + rmdir manually. */
|
||
static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz) {
|
||
/* Create temp dir */
|
||
snprintf(tmp_dir, tmp_sz, "/tmp/cbm_snippet_test_XXXXXX");
|
||
if (!cbm_mkdtemp(tmp_dir))
|
||
return NULL;
|
||
|
||
char proj_dir[512];
|
||
snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp_dir);
|
||
cbm_mkdir(proj_dir);
|
||
|
||
/* Write sample source file */
|
||
char src_path[512];
|
||
snprintf(src_path, sizeof(src_path), "%s/main.go", proj_dir);
|
||
FILE *fp = fopen(src_path, "w");
|
||
if (!fp)
|
||
return NULL;
|
||
fprintf(fp, "package main\n"
|
||
"\n"
|
||
"func HandleRequest() error {\n"
|
||
"\treturn nil\n"
|
||
"}\n"
|
||
"\n"
|
||
"func ProcessOrder(id int) {\n"
|
||
"\t// process\n"
|
||
"}\n"
|
||
"\n"
|
||
"func Run() {\n"
|
||
"\t// server\n"
|
||
"}\n");
|
||
fclose(fp);
|
||
|
||
/* Create server with in-memory store */
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
if (!srv)
|
||
return NULL;
|
||
|
||
cbm_store_t *st = cbm_mcp_server_store(srv);
|
||
if (!st) {
|
||
cbm_mcp_server_free(srv);
|
||
return NULL;
|
||
}
|
||
|
||
const char *proj_name = "test-project";
|
||
cbm_mcp_server_set_project(srv, proj_name);
|
||
cbm_store_upsert_project(st, proj_name, proj_dir);
|
||
|
||
/* Create nodes */
|
||
cbm_node_t n_hr = {0};
|
||
n_hr.project = proj_name;
|
||
n_hr.label = "Function";
|
||
n_hr.name = "HandleRequest";
|
||
n_hr.qualified_name = "test-project.cmd.server.main.HandleRequest";
|
||
n_hr.file_path = "main.go";
|
||
n_hr.start_line = 3;
|
||
n_hr.end_line = 5;
|
||
n_hr.properties_json = "{\"signature\":\"func HandleRequest() error\","
|
||
"\"return_type\":\"error\","
|
||
"\"is_exported\":true}";
|
||
int64_t id_hr = cbm_store_upsert_node(st, &n_hr);
|
||
|
||
cbm_node_t n_po = {0};
|
||
n_po.project = proj_name;
|
||
n_po.label = "Function";
|
||
n_po.name = "ProcessOrder";
|
||
n_po.qualified_name = "test-project.cmd.server.main.ProcessOrder";
|
||
n_po.file_path = "main.go";
|
||
n_po.start_line = 7;
|
||
n_po.end_line = 9;
|
||
n_po.properties_json = "{\"signature\":\"func ProcessOrder(id int)\"}";
|
||
int64_t id_po = cbm_store_upsert_node(st, &n_po);
|
||
|
||
cbm_node_t n_run1 = {0};
|
||
n_run1.project = proj_name;
|
||
n_run1.label = "Function";
|
||
n_run1.name = "Run";
|
||
n_run1.qualified_name = "test-project.cmd.server.Run";
|
||
n_run1.file_path = "main.go";
|
||
n_run1.start_line = 11;
|
||
n_run1.end_line = 13;
|
||
int64_t id_run1 = cbm_store_upsert_node(st, &n_run1);
|
||
|
||
cbm_node_t n_run2 = {0};
|
||
n_run2.project = proj_name;
|
||
n_run2.label = "Function";
|
||
n_run2.name = "Run";
|
||
n_run2.qualified_name = "test-project.cmd.worker.Run";
|
||
n_run2.file_path = "main.go";
|
||
n_run2.start_line = 11;
|
||
n_run2.end_line = 13;
|
||
cbm_store_upsert_node(st, &n_run2);
|
||
|
||
/* Create edges: HandleRequest -> ProcessOrder, HandleRequest -> Run1 */
|
||
cbm_edge_t e1 = {.project = proj_name, .source_id = id_hr, .target_id = id_po, .type = "CALLS"};
|
||
cbm_store_insert_edge(st, &e1);
|
||
|
||
cbm_edge_t e2 = {
|
||
.project = proj_name, .source_id = id_hr, .target_id = id_run1, .type = "CALLS"};
|
||
cbm_store_insert_edge(st, &e2);
|
||
(void)id_run1; /* run1 used for edge above */
|
||
|
||
return srv;
|
||
}
|
||
|
||
/* Cleanup temp files created by setup_snippet_server */
|
||
static void cleanup_snippet_dir(const char *tmp_dir) {
|
||
char path[512];
|
||
snprintf(path, sizeof(path), "%s/project/main.go", tmp_dir);
|
||
unlink(path);
|
||
snprintf(path, sizeof(path), "%s/project", tmp_dir);
|
||
rmdir(path);
|
||
rmdir(tmp_dir);
|
||
}
|
||
|
||
/* Extract the inner "text" value from an MCP tool result JSON.
|
||
* The MCP envelope is: {"content":[{"type":"text","text":"<inner json>"}]}
|
||
* This returns the unescaped inner JSON. Caller must free. */
|
||
static char *extract_text_content(const char *mcp_result) {
|
||
if (!mcp_result)
|
||
return NULL;
|
||
yyjson_doc *doc = yyjson_read(mcp_result, strlen(mcp_result), 0);
|
||
if (!doc)
|
||
return strdup(mcp_result); /* fallback */
|
||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||
yyjson_val *content = yyjson_obj_get(root, "content");
|
||
if (!content) {
|
||
/* Handle JSON-RPC wrapper: {"jsonrpc":...,"result":{"content":[...]}} */
|
||
yyjson_val *rpc_result = yyjson_obj_get(root, "result");
|
||
if (rpc_result) {
|
||
content = yyjson_obj_get(rpc_result, "content");
|
||
}
|
||
}
|
||
if (!content || !yyjson_is_arr(content)) {
|
||
yyjson_doc_free(doc);
|
||
return strdup(mcp_result);
|
||
}
|
||
yyjson_val *item = yyjson_arr_get(content, 0);
|
||
if (!item) {
|
||
yyjson_doc_free(doc);
|
||
return strdup(mcp_result);
|
||
}
|
||
yyjson_val *text = yyjson_obj_get(item, "text");
|
||
const char *str = yyjson_get_str(text);
|
||
char *result = str ? strdup(str) : strdup(mcp_result);
|
||
yyjson_doc_free(doc);
|
||
return result;
|
||
}
|
||
|
||
/* Call get_code_snippet and extract inner text content.
|
||
* Caller must free returned string. */
|
||
static char *call_snippet(cbm_mcp_server_t *srv, const char *args_json) {
|
||
char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", args_json);
|
||
char *text = extract_text_content(raw);
|
||
free(raw);
|
||
return text;
|
||
}
|
||
|
||
static bool is_valid_json_response(const char *json) {
|
||
if (!json) {
|
||
return false;
|
||
}
|
||
yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
|
||
if (!doc) {
|
||
return false;
|
||
}
|
||
yyjson_doc_free(doc);
|
||
return true;
|
||
}
|
||
|
||
static bool snippet_source_has_replacement(const char *json) {
|
||
yyjson_doc *doc = yyjson_read(json, strlen(json), 0);
|
||
if (!doc) {
|
||
return false;
|
||
}
|
||
yyjson_val *root = yyjson_doc_get_root(doc);
|
||
yyjson_val *source = yyjson_obj_get(root, "source");
|
||
const char *source_str = yyjson_get_str(source);
|
||
bool found = source_str && strstr(source_str, "\xEF\xBF\xBD");
|
||
yyjson_doc_free(doc);
|
||
return found;
|
||
}
|
||
|
||
/* ── TestSnippet_ExactQN ──────────────────────────────────────── */
|
||
|
||
TEST(snippet_exact_qn) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char *resp =
|
||
call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
|
||
/* Exact match should NOT have match_method */
|
||
ASSERT_NULL(strstr(resp, "\"match_method\""));
|
||
/* No property-blob spill: the source IS the payload (signature and
|
||
* docstring are literally in it); metrics live behind search_graph
|
||
* fields=[...]. */
|
||
ASSERT_NULL(strstr(resp, "\"signature\""));
|
||
ASSERT_NULL(strstr(resp, "\"return_type\""));
|
||
/* Caller/callee counts: 0 callers, 2 callees */
|
||
ASSERT_NOT_NULL(strstr(resp, "\"callers\":0"));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"callees\":2"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_QNSuffix ─────────────────────────────────────── */
|
||
|
||
TEST(snippet_qn_suffix) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char *resp = call_snippet(srv, "{\"qualified_name\":\"main.HandleRequest\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_UniqueShortName ──────────────────────────────── */
|
||
|
||
TEST(snippet_unique_short_name) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* "ProcessOrder" is unique — suffix tier matches (QN ends with .ProcessOrder) */
|
||
char *resp = call_snippet(srv, "{\"qualified_name\":\"ProcessOrder\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"ProcessOrder\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_NameTier ─────────────────────────────────────── */
|
||
|
||
TEST(snippet_name_tier) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* "HandleRequest" — suffix tier finds it (QN ends with .HandleRequest) */
|
||
char *resp = call_snippet(srv, "{\"qualified_name\":\"HandleRequest\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\""));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_AmbiguousShortName ───────────────────────────── */
|
||
|
||
TEST(snippet_ambiguous_short_name) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* "Run" matches 2 nodes — should return suggestions */
|
||
char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"status\":\"ambiguous\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"message\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"suggestions\""));
|
||
/* Must NOT have "error" key */
|
||
ASSERT_NULL(strstr(resp, "\"error\""));
|
||
/* Must NOT have "source" */
|
||
ASSERT_NULL(strstr(resp, "\"source\""));
|
||
/* Should have at least 2 suggestions with qualified_name */
|
||
ASSERT_NOT_NULL(strstr(resp, "test-project.cmd.server.Run"));
|
||
ASSERT_NOT_NULL(strstr(resp, "test-project.cmd.worker.Run"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_NotFound ─────────────────────────────────────── */
|
||
|
||
TEST(snippet_not_found) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char *resp = call_snippet(srv, "{\"qualified_name\":\"CompletelyNonexistentFunctionXYZ123\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
/* Should return error or suggestions */
|
||
ASSERT_TRUE(strstr(resp, "not found") || strstr(resp, "suggestions"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_FuzzySuggestions ─────────────────────────────── */
|
||
|
||
TEST(snippet_fuzzy_suggestions) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* "Handle" is not an exact QN or suffix — should get not-found guidance */
|
||
char *resp = call_snippet(srv, "{\"qualified_name\":\"Handle\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
/* Should guide user to search_graph */
|
||
ASSERT_NOT_NULL(strstr(resp, "search_graph"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_EnrichedProperties ───────────────────────────── */
|
||
|
||
TEST(snippet_enriched_properties) {
|
||
/* GUARD (inverted since the compact-output change): the snippet response
|
||
* carries the verbatim source plus location/degree/coverage metadata and
|
||
* NOTHING from the node's property blob — no signature/return_type/
|
||
* is_exported duplication, and never the fp/sp/bt similarity internals
|
||
* (41% of the legacy response). */
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char *resp =
|
||
call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
|
||
ASSERT_NULL(strstr(resp, "\"signature\""));
|
||
ASSERT_NULL(strstr(resp, "\"return_type\""));
|
||
ASSERT_NULL(strstr(resp, "\"is_exported\""));
|
||
ASSERT_NULL(strstr(resp, "\"fp\""));
|
||
ASSERT_NULL(strstr(resp, "\"bt\""));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_FuzzyLastSegment ─────────────────────────────── */
|
||
|
||
TEST(snippet_fuzzy_last_segment) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* "auth.handlers.HandleRequest" — suffix match should find HandleRequest */
|
||
char *resp = call_snippet(srv, "{\"qualified_name\":\"auth.handlers.HandleRequest\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
/* Should either find it via suffix or guide to search_graph */
|
||
ASSERT_TRUE(strstr(resp, "HandleRequest") != NULL || strstr(resp, "search_graph") != NULL);
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_AutoResolve_Default ──────────────────────────── */
|
||
|
||
TEST(snippet_auto_resolve_default) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* "Run" is ambiguous (2 candidates). Without auto_resolve → suggestions */
|
||
char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"status\":\"ambiguous\""));
|
||
ASSERT_NULL(strstr(resp, "\"source\""));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_AutoResolve_Enabled ──────────────────────────── */
|
||
|
||
TEST(snippet_auto_resolve_enabled) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* "Run" — suffix match should find candidates or guide to search */
|
||
char *resp = call_snippet(srv, "{\"qualified_name\":\"Run\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
/* "Run" matches multiple nodes via suffix → should get suggestions or source */
|
||
ASSERT_TRUE(strstr(resp, "Run") != NULL);
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_IncludeNeighbors_Default ─────────────────────── */
|
||
|
||
TEST(snippet_include_neighbors_default) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char *resp =
|
||
call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
/* Without include_neighbors → NO caller_names/callee_names */
|
||
ASSERT_NULL(strstr(resp, "\"caller_names\""));
|
||
ASSERT_NULL(strstr(resp, "\"callee_names\""));
|
||
/* But should still have counts */
|
||
ASSERT_NOT_NULL(strstr(resp, "\"callers\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "\"callees\""));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_IncludeNeighbors_Enabled ─────────────────────── */
|
||
|
||
TEST(snippet_include_neighbors_enabled) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char *resp =
|
||
call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
|
||
"\"include_neighbors\":true,\"project\":\"test-project\"}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
|
||
/* HandleRequest has 0 callers → no caller_names array */
|
||
ASSERT_NULL(strstr(resp, "\"caller_names\""));
|
||
/* HandleRequest has 2 callees: ProcessOrder and Run */
|
||
ASSERT_NOT_NULL(strstr(resp, "\"callee_names\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "ProcessOrder"));
|
||
ASSERT_NOT_NULL(strstr(resp, "Run"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ── TestSnippet_SourceInvalidUtf8 ────────────────────────────── */
|
||
|
||
TEST(snippet_source_invalid_utf8) {
|
||
char tmp[256];
|
||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
char src_path[512];
|
||
snprintf(src_path, sizeof(src_path), "%s/project/main.go", tmp);
|
||
FILE *fp = fopen(src_path, "wb");
|
||
ASSERT_NOT_NULL(fp);
|
||
const unsigned char source[] = {
|
||
'p', 'a', 'c', 'k', 'a', 'g', 'e', ' ', 'm', 'a', 'i', 'n', '\n', '\n',
|
||
'f', 'u', 'n', 'c', ' ', 'H', 'a', 'n', 'd', 'l', 'e', 'R', 'e', 'q',
|
||
'u', 'e', 's', 't', '(', ')', ' ', 'e', 'r', 'r', 'o', 'r', ' ', '{',
|
||
'\n', '\t', '/', '/', ' ', 0xC0, 0xD4, 0xB7, 0xC2, '\n', '\t', 'r', 'e', 't',
|
||
'u', 'r', 'n', ' ', 'n', 'i', 'l', '\n', '}', '\n'};
|
||
ASSERT_EQ(fwrite(source, 1, sizeof(source), fp), sizeof(source));
|
||
ASSERT_EQ(fclose(fp), 0);
|
||
|
||
char *raw =
|
||
cbm_mcp_handle_tool(srv, "get_code_snippet",
|
||
"{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
|
||
"\"project\":\"test-project\"}");
|
||
ASSERT_TRUE(is_valid_json_response(raw));
|
||
char *resp = extract_text_content(raw);
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_TRUE(is_valid_json_response(resp));
|
||
ASSERT_NULL(strstr(resp, "\xC0\xD4"));
|
||
ASSERT_NOT_NULL(strstr(resp, "HandleRequest"));
|
||
ASSERT_NOT_NULL(strstr(resp, "return nil"));
|
||
ASSERT_TRUE(snippet_source_has_replacement(resp));
|
||
|
||
free(resp);
|
||
free(raw);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_snippet_dir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* JSON-RPC PARSING — EDGE CASES
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(jsonrpc_parse_empty_string) {
|
||
cbm_jsonrpc_request_t req = {0};
|
||
int rc = cbm_jsonrpc_parse("", &req);
|
||
ASSERT_EQ(rc, -1);
|
||
cbm_jsonrpc_request_free(&req);
|
||
PASS();
|
||
}
|
||
|
||
TEST(jsonrpc_parse_missing_jsonrpc_field) {
|
||
/* jsonrpc field absent — parser defaults to "2.0" if method present */
|
||
const char *line = "{\"id\":1,\"method\":\"initialize\",\"params\":{}}";
|
||
cbm_jsonrpc_request_t req = {0};
|
||
int rc = cbm_jsonrpc_parse(line, &req);
|
||
ASSERT_EQ(rc, 0);
|
||
ASSERT_STR_EQ(req.jsonrpc, "2.0");
|
||
ASSERT_STR_EQ(req.method, "initialize");
|
||
ASSERT_TRUE(req.has_id);
|
||
cbm_jsonrpc_request_free(&req);
|
||
PASS();
|
||
}
|
||
|
||
TEST(jsonrpc_parse_missing_method) {
|
||
/* method is required — should fail */
|
||
const char *line = "{\"jsonrpc\":\"2.0\",\"id\":1,\"params\":{}}";
|
||
cbm_jsonrpc_request_t req = {0};
|
||
int rc = cbm_jsonrpc_parse(line, &req);
|
||
ASSERT_EQ(rc, -1);
|
||
cbm_jsonrpc_request_free(&req);
|
||
PASS();
|
||
}
|
||
|
||
TEST(jsonrpc_parse_string_id) {
|
||
/* JSON-RPC §4: string and numeric ids are distinct. A string id is
|
||
* preserved verbatim (issue #253), never coerced to a number. */
|
||
const char *line = "{\"jsonrpc\":\"2.0\",\"id\":\"99\",\"method\":\"tools/list\"}";
|
||
cbm_jsonrpc_request_t req = {0};
|
||
int rc = cbm_jsonrpc_parse(line, &req);
|
||
ASSERT_EQ(rc, 0);
|
||
ASSERT_TRUE(req.has_id);
|
||
ASSERT_NOT_NULL(req.id_str);
|
||
ASSERT_STR_EQ(req.id_str, "99");
|
||
ASSERT_STR_EQ(req.method, "tools/list");
|
||
cbm_jsonrpc_request_free(&req);
|
||
PASS();
|
||
}
|
||
|
||
TEST(jsonrpc_parse_no_params) {
|
||
/* Request with no params field — params_raw should be NULL */
|
||
const char *line = "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/list\"}";
|
||
cbm_jsonrpc_request_t req = {0};
|
||
int rc = cbm_jsonrpc_parse(line, &req);
|
||
ASSERT_EQ(rc, 0);
|
||
ASSERT_NULL(req.params_raw);
|
||
ASSERT_EQ(req.id, 5);
|
||
cbm_jsonrpc_request_free(&req);
|
||
PASS();
|
||
}
|
||
|
||
TEST(jsonrpc_parse_extra_whitespace) {
|
||
/* Leading/trailing whitespace and internal spacing in JSON */
|
||
const char *line = " { \"jsonrpc\" : \"2.0\" , \"id\" : 7 , \"method\" : \"ping\" } ";
|
||
cbm_jsonrpc_request_t req = {0};
|
||
int rc = cbm_jsonrpc_parse(line, &req);
|
||
ASSERT_EQ(rc, 0);
|
||
ASSERT_EQ(req.id, 7);
|
||
ASSERT_STR_EQ(req.method, "ping");
|
||
cbm_jsonrpc_request_free(&req);
|
||
PASS();
|
||
}
|
||
|
||
TEST(jsonrpc_parse_array_not_object) {
|
||
/* JSON array at root — not a valid JSON-RPC request */
|
||
cbm_jsonrpc_request_t req = {0};
|
||
int rc = cbm_jsonrpc_parse("[1,2,3]", &req);
|
||
ASSERT_EQ(rc, -1);
|
||
cbm_jsonrpc_request_free(&req);
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* ARGUMENT EXTRACTION — EDGE CASES
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(mcp_get_string_arg_empty_json) {
|
||
/* Empty JSON string — yyjson_read fails → NULL */
|
||
char *val = cbm_mcp_get_string_arg("", "key");
|
||
ASSERT_NULL(val);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_string_arg_empty_object) {
|
||
/* Valid JSON with no keys → NULL for any key */
|
||
char *val = cbm_mcp_get_string_arg("{}", "key");
|
||
ASSERT_NULL(val);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_string_arg_nested_value) {
|
||
/* Value is an object, not a string → should return NULL */
|
||
const char *args = "{\"config\":{\"nested\":true},\"name\":\"hello\"}";
|
||
char *val = cbm_mcp_get_string_arg(args, "config");
|
||
ASSERT_NULL(val); /* not a string type */
|
||
val = cbm_mcp_get_string_arg(args, "name");
|
||
ASSERT_NOT_NULL(val);
|
||
ASSERT_STR_EQ(val, "hello");
|
||
free(val);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_string_arg_int_value) {
|
||
/* Value is an integer, not a string → NULL */
|
||
char *val = cbm_mcp_get_string_arg("{\"count\":42}", "count");
|
||
ASSERT_NULL(val);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_int_arg_empty_json) {
|
||
int val = cbm_mcp_get_int_arg("", "key", 99);
|
||
ASSERT_EQ(val, 99);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_int_arg_string_value) {
|
||
/* Value is a string, not int → should return default */
|
||
int val = cbm_mcp_get_int_arg("{\"limit\":\"ten\"}", "limit", 5);
|
||
ASSERT_EQ(val, 5);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_int_arg_bool_value) {
|
||
/* Value is a bool, not int → default */
|
||
int val = cbm_mcp_get_int_arg("{\"flag\":true}", "flag", -1);
|
||
ASSERT_EQ(val, -1);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_bool_arg_empty_json) {
|
||
bool val = cbm_mcp_get_bool_arg("", "key");
|
||
ASSERT_FALSE(val);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_bool_arg_int_value) {
|
||
/* Value is int 1, not bool → should return false */
|
||
bool val = cbm_mcp_get_bool_arg("{\"flag\":1}", "flag");
|
||
ASSERT_FALSE(val);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_tool_name_empty_json) {
|
||
char *name = cbm_mcp_get_tool_name("");
|
||
ASSERT_NULL(name);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_tool_name_missing_name) {
|
||
char *name = cbm_mcp_get_tool_name("{\"arguments\":{}}");
|
||
ASSERT_NULL(name);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_arguments_empty_json) {
|
||
char *args = cbm_mcp_get_arguments("");
|
||
ASSERT_NULL(args);
|
||
PASS();
|
||
}
|
||
|
||
TEST(mcp_get_arguments_no_arguments_key) {
|
||
/* No "arguments" key → returns "{}" */
|
||
char *args = cbm_mcp_get_arguments("{\"name\":\"tool\"}");
|
||
ASSERT_NOT_NULL(args);
|
||
ASSERT_STR_EQ(args, "{}");
|
||
free(args);
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* FILE URI PARSING — EDGE CASES
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(parse_file_uri_http_scheme) {
|
||
char path[256];
|
||
ASSERT_FALSE(cbm_parse_file_uri("http://example.com/path", path, sizeof(path)));
|
||
ASSERT_STR_EQ(path, "");
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_file_uri_ftp_scheme) {
|
||
char path[256];
|
||
ASSERT_FALSE(cbm_parse_file_uri("ftp://server/file.txt", path, sizeof(path)));
|
||
ASSERT_STR_EQ(path, "");
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_file_uri_buffer_too_small) {
|
||
char path[5]; /* only 5 bytes — path gets truncated */
|
||
ASSERT_TRUE(cbm_parse_file_uri("file:///usr/local/bin", path, sizeof(path)));
|
||
/* snprintf truncates to 4 chars + NUL */
|
||
ASSERT_EQ(strlen(path), 4);
|
||
ASSERT_STR_EQ(path, "/usr");
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_file_uri_spaces_in_path) {
|
||
char path[256];
|
||
ASSERT_TRUE(cbm_parse_file_uri("file:///home/user/my%20project", path, sizeof(path)));
|
||
/* Raw percent-encoding is preserved (not decoded) */
|
||
ASSERT_STR_EQ(path, "/home/user/my%20project");
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_file_uri_null_out_path) {
|
||
/* NULL out_path — should not crash */
|
||
ASSERT_FALSE(cbm_parse_file_uri("file:///tmp", NULL, 256));
|
||
PASS();
|
||
}
|
||
|
||
TEST(parse_file_uri_zero_size) {
|
||
char path[256] = "garbage";
|
||
/* out_size=0 → should fail safely */
|
||
ASSERT_FALSE(cbm_parse_file_uri("file:///tmp", path, 0));
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* SERVER HANDLE — EDGE CASES
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(server_handle_invalid_json) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
char *resp = cbm_mcp_server_handle(srv, "this is not json at all");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"error\""));
|
||
ASSERT_NOT_NULL(strstr(resp, "-32700")); /* Parse error */
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(server_handle_empty_object) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
/* Valid JSON but no method field → parse error */
|
||
char *resp = cbm_mcp_server_handle(srv, "{}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "\"error\""));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(server_handle_tools_call_missing_name) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
|
||
/* tools/call with no tool name in params */
|
||
char *resp =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\","
|
||
"\"params\":{\"arguments\":{}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
/* Should return error about unknown/missing tool */
|
||
ASSERT_NOT_NULL(strstr(resp, "\"id\":50"));
|
||
ASSERT_TRUE(strstr(resp, "error") || strstr(resp, "isError") || strstr(resp, "unknown"));
|
||
free(resp);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* POLL/GETLINE FILE* BUFFERING FIX
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
#ifndef _WIN32
|
||
#include <unistd.h>
|
||
#include <signal.h>
|
||
|
||
/* Signal handler used by alarm() to abort the test if it hangs */
|
||
static void alarm_handler(int sig) {
|
||
(void)sig;
|
||
/* Writing to stderr is async-signal-safe */
|
||
const char msg[] = "FAIL: mcp_server_run_rapid_messages timed out (>5s)\n";
|
||
write(STDERR_FILENO, msg, sizeof(msg) - 1);
|
||
_exit(1);
|
||
}
|
||
|
||
TEST(mcp_server_run_rapid_messages) {
|
||
/* Simulate a client sending initialize + notifications/initialized +
|
||
* tools/list all at once (no delays), which exercises the FILE*
|
||
* buffering fix: the first getline() over-reads kernel data into the
|
||
* libc buffer; without the fix, subsequent poll() calls block for 60s.
|
||
*
|
||
* We use alarm(5) to abort the test process if the server hangs. */
|
||
int fds[2];
|
||
ASSERT_EQ(pipe(fds), 0);
|
||
|
||
/* Write all 3 messages to the write end in one shot */
|
||
const char *msgs = "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\","
|
||
"\"params\":{\"protocolVersion\":\"2025-11-25\",\"capabilities\":{}}}\n"
|
||
"{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}\n"
|
||
"{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\",\"params\":{}}\n";
|
||
ssize_t written = write(fds[1], msgs, strlen(msgs));
|
||
ASSERT_TRUE(written > 0);
|
||
close(fds[1]); /* EOF signals end of input to the server */
|
||
|
||
FILE *in_fp = fdopen(fds[0], "r");
|
||
ASSERT_NOT_NULL(in_fp);
|
||
|
||
FILE *out_fp = tmpfile();
|
||
ASSERT_NOT_NULL(out_fp);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* Install alarm to fail the test if cbm_mcp_server_run blocks */
|
||
signal(SIGALRM, alarm_handler);
|
||
alarm(5);
|
||
|
||
int rc = cbm_mcp_server_run(srv, in_fp, out_fp);
|
||
|
||
alarm(0); /* cancel alarm */
|
||
signal(SIGALRM, SIG_DFL);
|
||
|
||
ASSERT_EQ(rc, 0);
|
||
|
||
/* Verify both responses are present:
|
||
* id:1 — initialize response
|
||
* id:2 — tools/list response (notifications/initialized produces none)
|
||
* and that the tools list payload is included. */
|
||
rewind(out_fp);
|
||
char buf[4096] = {0};
|
||
size_t nread = fread(buf, 1, sizeof(buf) - 1, out_fp);
|
||
ASSERT_TRUE(nread > 0);
|
||
ASSERT_NOT_NULL(strstr(buf, "\"id\":1"));
|
||
ASSERT_NOT_NULL(strstr(buf, "\"id\":2"));
|
||
ASSERT_NOT_NULL(strstr(buf, "tools"));
|
||
|
||
cbm_mcp_server_free(srv);
|
||
fclose(out_fp);
|
||
/* in_fp already EOF; fclose cleans up */
|
||
fclose(in_fp);
|
||
PASS();
|
||
}
|
||
#endif /* !_WIN32 */
|
||
|
||
/* Issue #235: passing an unrecognised project name to a tool crashed the
|
||
* binary with a buffer overflow while building the "available_projects"
|
||
* error list — collect_db_project_names overflowed projects[CBM_SZ_4K] via
|
||
* an unsigned underflow on (out_sz - offset) once the listed names exceeded
|
||
* the buffer. Fill a temp cache dir with enough long-named .db files to
|
||
* exceed 4 KB, then hit the bad-project path. Under ASan a regression aborts
|
||
* here; the fixed bounds-check keeps it clean and returns a normal error. */
|
||
#define ISSUE235_DBNAME(buf, dir, i) \
|
||
snprintf((buf), sizeof(buf), \
|
||
"%s/proj_%02d_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \
|
||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.db", \
|
||
(dir), (i))
|
||
TEST(tool_bad_project_name_no_overflow_issue235) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-badproj-XXXXXX");
|
||
if (!cbm_mkdtemp(cache)) {
|
||
PASS(); /* skip if mkdtemp fails */
|
||
}
|
||
|
||
const char *saved = getenv("CBM_CACHE_DIR");
|
||
char *saved_copy = saved ? strdup(saved) : NULL;
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
|
||
/* 40 * ~120-char names overflows the 4 KB available-projects buffer.
|
||
* collect_db_project_names advertises each db's INTERNAL project name
|
||
* (#704), so the fixture must hold valid dbs with long internal names —
|
||
* not stub files — for the bounds-check path to actually be exercised. */
|
||
enum { ISSUE235_N = 40 };
|
||
for (int i = 0; i < ISSUE235_N; i++) {
|
||
char name[512];
|
||
ISSUE235_DBNAME(name, cache, i);
|
||
char iname[256];
|
||
snprintf(iname, sizeof(iname),
|
||
"proj_%02d_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||
i);
|
||
cbm_store_t *st = cbm_store_open_path(name);
|
||
if (st) {
|
||
cbm_store_upsert_project(st, iname, cache);
|
||
cbm_store_close(st);
|
||
}
|
||
}
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":"
|
||
"\"search_graph\",\"arguments\":{\"label\":\"Function\","
|
||
"\"project\":\"definitely-not-a-real-project-xyz\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "not found"));
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
|
||
if (saved_copy) {
|
||
cbm_setenv("CBM_CACHE_DIR", saved_copy, 1);
|
||
free(saved_copy);
|
||
} else {
|
||
cbm_unsetenv("CBM_CACHE_DIR");
|
||
}
|
||
for (int i = 0; i < ISSUE235_N; i++) {
|
||
char name[512];
|
||
ISSUE235_DBNAME(name, cache, i);
|
||
cbm_unlink(name);
|
||
char side[540];
|
||
snprintf(side, sizeof(side), "%s-wal", name);
|
||
cbm_unlink(side);
|
||
snprintf(side, sizeof(side), "%s-shm", name);
|
||
cbm_unlink(side);
|
||
}
|
||
cbm_rmdir(cache);
|
||
PASS();
|
||
}
|
||
#undef ISSUE235_DBNAME
|
||
|
||
/* Issue #235 (follow-up): with many long-named projects indexed,
|
||
* collect_db_project_names overflowed projects[CBM_SZ_4K] and truncated the
|
||
* LAST name MID-TOKEN, then clamped offset to out_sz-1 — emitting malformed,
|
||
* unterminated JSON like
|
||
* ...,"available_projects":["a",...,"vjson_49_bbb],"count":50}
|
||
* (unclosed string + unclosed array). build_project_list_error wrapped that
|
||
* invalid body into the tool error, so a "project not found" reply was NOT
|
||
* valid JSON once enough projects were indexed.
|
||
*
|
||
* Reproduce-first: fill an isolated cache dir with enough long INTERNAL-named
|
||
* dbs to overflow the 4 KB buffer, hit the bad-project path, then assert the
|
||
* ERROR BODY (the inner MCP text content) parses as valid JSON and that
|
||
* available_projects is a JSON array whose length == count. RED on the
|
||
* truncating code (yyjson_read returns NULL on the mid-token cut); GREEN after
|
||
* the element-boundary fix, which only ever writes whole "name" tokens. */
|
||
#define BADPROJ_JSON_DBNAME(buf, dir, i) \
|
||
snprintf((buf), sizeof(buf), \
|
||
"%s/vjson_%02d_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" \
|
||
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.db", \
|
||
(dir), (i))
|
||
TEST(tool_bad_project_error_valid_json_issue235) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-badproj-vjson-XXXXXX");
|
||
if (!cbm_mkdtemp(cache)) {
|
||
PASS(); /* skip if mkdtemp fails */
|
||
}
|
||
|
||
const char *saved = getenv("CBM_CACHE_DIR");
|
||
char *saved_copy = saved ? strdup(saved) : NULL;
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
|
||
/* 50 * ~120-char INTERNAL names >> 4 KB → the available_projects buffer
|
||
* overflows and the last name is cut mid-token on the unfixed code. */
|
||
enum { BADPROJ_N = 50 };
|
||
for (int i = 0; i < BADPROJ_N; i++) {
|
||
char name[512];
|
||
BADPROJ_JSON_DBNAME(name, cache, i);
|
||
char iname[256];
|
||
snprintf(iname, sizeof(iname),
|
||
"vjson_%02d_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
||
"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
|
||
i);
|
||
cbm_store_t *st = cbm_store_open_path(name);
|
||
if (st) {
|
||
cbm_store_upsert_project(st, iname, cache);
|
||
cbm_store_close(st);
|
||
}
|
||
}
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":"
|
||
"\"search_graph\",\"arguments\":{\"label\":\"Function\","
|
||
"\"project\":\"definitely-not-a-real-project-xyz\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "not found"));
|
||
|
||
/* The inner MCP text content is the error body built by
|
||
* build_project_list_error. Capture its validity BEFORE cleanup so a RED
|
||
* failure still restores the environment. */
|
||
char *body = extract_text_content(resp);
|
||
bool body_valid = false;
|
||
bool aps_ok = false; /* available_projects is an array whose len == count */
|
||
if (body) {
|
||
yyjson_doc *bdoc = yyjson_read(body, strlen(body), 0);
|
||
if (bdoc) {
|
||
body_valid = true;
|
||
yyjson_val *broot = yyjson_doc_get_root(bdoc);
|
||
yyjson_val *aps = yyjson_obj_get(broot, "available_projects");
|
||
yyjson_val *cnt = yyjson_obj_get(broot, "count");
|
||
if (aps && yyjson_is_arr(aps) && cnt && yyjson_is_int(cnt)) {
|
||
aps_ok = (yyjson_arr_size(aps) == (size_t)yyjson_get_int(cnt));
|
||
}
|
||
yyjson_doc_free(bdoc);
|
||
}
|
||
}
|
||
free(body);
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
|
||
if (saved_copy) {
|
||
cbm_setenv("CBM_CACHE_DIR", saved_copy, 1);
|
||
free(saved_copy);
|
||
} else {
|
||
cbm_unsetenv("CBM_CACHE_DIR");
|
||
}
|
||
for (int i = 0; i < BADPROJ_N; i++) {
|
||
char name[512];
|
||
BADPROJ_JSON_DBNAME(name, cache, i);
|
||
cbm_unlink(name);
|
||
char side[540];
|
||
snprintf(side, sizeof(side), "%s-wal", name);
|
||
cbm_unlink(side);
|
||
snprintf(side, sizeof(side), "%s-shm", name);
|
||
cbm_unlink(side);
|
||
}
|
||
cbm_rmdir(cache);
|
||
|
||
/* RED on the unfixed code: mid-token truncation → invalid JSON body. */
|
||
ASSERT_TRUE(body_valid);
|
||
ASSERT_TRUE(aps_ok);
|
||
PASS();
|
||
}
|
||
#undef BADPROJ_JSON_DBNAME
|
||
|
||
/* ── #704: project resolution must key on the db's INTERNAL project name ──
|
||
*
|
||
* Issue #704: project resolution is registry-less and filename-addressed.
|
||
* resolve_store() opens <cache>/<passed>.db and then requires the internal
|
||
* `projects.name` row to equal the passed name; list_projects /
|
||
* collect_db_project_names derive the advertised name from the .db FILENAME.
|
||
* When a db's filename != its internal name (a legacy '.'-vs-'-' username
|
||
* twin, or a copied/renamed file) it shows up in list_projects under the
|
||
* filename, but every query returns "project not found" — node rows are
|
||
* tagged with the INTERNAL name, so neither the filename nor the resolve
|
||
* path lines up. The fix makes list + resolve both key on the INTERNAL name.
|
||
*
|
||
* Reproduce-first fixture in an isolated CBM_CACHE_DIR:
|
||
* - alpha704.db : filename == internal name "alpha704" (control / fast path)
|
||
* - gamma704.db : internal name "beta704" (DRIFT: built as
|
||
* beta704.db then renamed → filename != internal name)
|
||
* - ghost704.db : 0-byte file (ghost / unresolvable)
|
||
*
|
||
* RED on buggy code / GREEN on the fix:
|
||
* A. list_projects advertises "beta704" (internal), NOT "gamma704" (filename),
|
||
* and NOT "ghost704" (0-byte filtered).
|
||
* B. search_graph(project="beta704") resolves via the cache-dir scan and
|
||
* returns the node — not the "project not found" error.
|
||
* C. control project "alpha704" still resolves on the fast path.
|
||
* D. the 0-byte ghost is not resolvable.
|
||
* E. addressing the drifted db by its FILENAME ("gamma704") stays not-found
|
||
* (we key on the internal name, never the file on disk).
|
||
*/
|
||
|
||
/* Create a file-backed project db at <dir>/<filename> whose INTERNAL project
|
||
* name is `internal` (which may differ from the filename), holding one
|
||
* Function node named `fn`. Returns true on success. */
|
||
static bool issue704_make_db(const char *dir, const char *filename, const char *internal,
|
||
const char *fn) {
|
||
char path[700];
|
||
snprintf(path, sizeof(path), "%s/%s", dir, filename);
|
||
cbm_store_t *st = cbm_store_open_path(path);
|
||
if (!st) {
|
||
return false;
|
||
}
|
||
bool ok = (cbm_store_upsert_project(st, internal, dir) == CBM_STORE_OK);
|
||
if (ok) {
|
||
char qn[256];
|
||
snprintf(qn, sizeof(qn), "%s.%s", internal, fn);
|
||
cbm_node_t n = {0};
|
||
n.project = internal;
|
||
n.label = "Function";
|
||
n.name = fn;
|
||
n.qualified_name = qn;
|
||
n.file_path = "main.go";
|
||
n.start_line = 1;
|
||
n.end_line = 2;
|
||
ok = (cbm_store_upsert_node(st, &n) > 0);
|
||
}
|
||
cbm_store_close(st);
|
||
return ok;
|
||
}
|
||
|
||
TEST(tool_resolve_store_by_internal_name_issue704) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-issue704-XXXXXX");
|
||
if (!cbm_mkdtemp(cache)) {
|
||
PASS(); /* skip if mkdtemp fails — not a #704 signal */
|
||
}
|
||
|
||
const char *saved = getenv("CBM_CACHE_DIR");
|
||
char *saved_copy = saved ? strdup(saved) : NULL;
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
|
||
/* (1) control: filename == internal name */
|
||
ASSERT_TRUE(issue704_make_db(cache, "alpha704.db", "alpha704", "alphaFunc704"));
|
||
|
||
/* (2) DRIFT: build beta704.db (internal "beta704") then rename the file to
|
||
* gamma704.db, so filename "gamma704" != internal "beta704". */
|
||
ASSERT_TRUE(issue704_make_db(cache, "beta704.db", "beta704", "betaFunc704"));
|
||
char beta_path[700];
|
||
char gamma_path[700];
|
||
snprintf(beta_path, sizeof(beta_path), "%s/beta704.db", cache);
|
||
snprintf(gamma_path, sizeof(gamma_path), "%s/gamma704.db", cache);
|
||
ASSERT_EQ(rename(beta_path, gamma_path), 0);
|
||
|
||
/* (3) ghost: 0-byte db file */
|
||
char ghost_path[700];
|
||
snprintf(ghost_path, sizeof(ghost_path), "%s/ghost704.db", cache);
|
||
FILE *gp = fopen(ghost_path, "w");
|
||
ASSERT_NOT_NULL(gp);
|
||
fclose(gp);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* ── A: list_projects reports INTERNAL names; filters the ghost ── */
|
||
char *list =
|
||
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"list_projects\",\"arguments\":{}}}");
|
||
ASSERT_NOT_NULL(list);
|
||
ASSERT_NOT_NULL(strstr(list, "alpha704")); /* control */
|
||
ASSERT_NOT_NULL(strstr(list, "beta704")); /* internal name of drifted db (RED before) */
|
||
ASSERT_NULL(strstr(list, "gamma704")); /* filename must NOT be advertised (RED before) */
|
||
ASSERT_NULL(strstr(list, "ghost704")); /* 0-byte ghost filtered (RED before) */
|
||
free(list);
|
||
|
||
/* ── B: the drifted project resolves by its INTERNAL name ──────── */
|
||
char *q_beta = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
|
||
"\"project\":\"beta704\",\"name_pattern\":\"betaFunc704\",\"limit\":5}}}");
|
||
ASSERT_NOT_NULL(q_beta);
|
||
ASSERT_NOT_NULL(strstr(q_beta, "betaFunc704")); /* resolved + returned node (RED before) */
|
||
ASSERT_NULL(strstr(q_beta, "not found")); /* not the not-found error */
|
||
free(q_beta);
|
||
|
||
/* ── C: control project still resolves on the fast path ────────── */
|
||
char *q_alpha = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
|
||
"\"project\":\"alpha704\",\"name_pattern\":\"alphaFunc704\",\"limit\":5}}}");
|
||
ASSERT_NOT_NULL(q_alpha);
|
||
ASSERT_NOT_NULL(strstr(q_alpha, "alphaFunc704"));
|
||
free(q_alpha);
|
||
|
||
/* ── D: the 0-byte ghost is NOT resolvable ─────────────────────── */
|
||
char *q_ghost = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
|
||
"\"project\":\"ghost704\",\"name_pattern\":\".*\",\"limit\":5}}}");
|
||
ASSERT_NOT_NULL(q_ghost);
|
||
ASSERT_NOT_NULL(strstr(q_ghost, "not found"));
|
||
free(q_ghost);
|
||
|
||
/* ── E: addressing the drifted db by its FILENAME stays not-found ── */
|
||
char *q_gamma = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
|
||
"\"project\":\"gamma704\",\"name_pattern\":\".*\",\"limit\":5}}}");
|
||
ASSERT_NOT_NULL(q_gamma);
|
||
ASSERT_NOT_NULL(strstr(q_gamma, "not found"));
|
||
free(q_gamma);
|
||
|
||
cbm_mcp_server_free(srv);
|
||
|
||
/* ── cleanup ───────────────────────────────────────────────────── */
|
||
if (saved_copy) {
|
||
cbm_setenv("CBM_CACHE_DIR", saved_copy, 1);
|
||
free(saved_copy);
|
||
} else {
|
||
cbm_unsetenv("CBM_CACHE_DIR");
|
||
}
|
||
char a_path[700];
|
||
snprintf(a_path, sizeof(a_path), "%s/alpha704.db", cache);
|
||
cbm_unlink(a_path);
|
||
cbm_unlink(gamma_path);
|
||
cbm_unlink(ghost_path);
|
||
mcp_cleanup_corrupt_backups(cache, "ghost704");
|
||
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();
|
||
}
|
||
|
||
/* ── #1044: a "<name>::missed" shadow row must not hide the project ──
|
||
*
|
||
* The miss-graph pass inserts a second `projects` row ("<name>::missed") so
|
||
* its nodes satisfy the FK on nodes.project. db_internal_project_name
|
||
* required the projects table to hold EXACTLY ONE row, so any project with
|
||
* a miss graph vanished from list_projects and the graph UI, and the
|
||
* fallback-scan resolve path failed.
|
||
*
|
||
* RED on buggy code / GREEN on the fix:
|
||
* A. list_projects still advertises "delta1044" while the shadow row exists.
|
||
* B. the shadow name itself is never advertised.
|
||
* C. search_graph(project="delta1044") still resolves and returns the node.
|
||
*/
|
||
TEST(tool_list_projects_ignores_missed_shadow_issue1044) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-issue1044-XXXXXX");
|
||
if (!cbm_mkdtemp(cache)) {
|
||
PASS(); /* skip if mkdtemp fails — not a #1044 signal */
|
||
}
|
||
|
||
const char *saved = getenv("CBM_CACHE_DIR");
|
||
char *saved_copy = saved ? strdup(saved) : NULL;
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
|
||
ASSERT_TRUE(issue704_make_db(cache, "delta1044.db", "delta1044", "deltaFunc1044"));
|
||
|
||
/* Add the shadow row exactly the way the miss-graph pass does. */
|
||
char db_path[700];
|
||
snprintf(db_path, sizeof(db_path), "%s/delta1044.db", cache);
|
||
cbm_store_t *st = cbm_store_open_path(db_path);
|
||
ASSERT_NOT_NULL(st);
|
||
ASSERT_EQ(cbm_store_upsert_project(st, "delta1044::missed", ""), CBM_STORE_OK);
|
||
cbm_store_close(st);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
|
||
/* ── A + B: primary advertised, shadow hidden ─────────────────── */
|
||
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, "delta1044")); /* RED before: db skipped as ghost */
|
||
ASSERT_NULL(strstr(list, "::missed")); /* shadow never advertised */
|
||
free(list);
|
||
|
||
/* ── C: the project still resolves and returns its node ───────── */
|
||
char *q = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"search_graph\",\"arguments\":{"
|
||
"\"project\":\"delta1044\",\"name_pattern\":\"deltaFunc1044\",\"limit\":5}}}");
|
||
ASSERT_NOT_NULL(q);
|
||
ASSERT_NOT_NULL(strstr(q, "deltaFunc1044"));
|
||
ASSERT_NULL(strstr(q, "not found"));
|
||
free(q);
|
||
|
||
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");
|
||
}
|
||
cbm_unlink(db_path);
|
||
char side1044[740];
|
||
snprintf(side1044, sizeof(side1044), "%s-wal", db_path);
|
||
cbm_unlink(side1044);
|
||
snprintf(side1044, sizeof(side1044), "%s-shm", db_path);
|
||
cbm_unlink(side1044);
|
||
cbm_rmdir(cache);
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* QUERY STORE COHERENCE + 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 read-only tests below are written reproduce-first and are RED on the
|
||
* unfixed code, GREEN once query opens are READONLY with read-only
|
||
* pragmas.
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
/* Reproduce-first: one MCP session caches a query connection to generation A,
|
||
* then the fixture models an independent writer publishing generation B by
|
||
* atomically replacing the project DB at the same cache path. Because
|
||
* resolve_store() keys its cache only by project name, the next query can reuse
|
||
* stale generation A. It must instead return generation B. */
|
||
TEST(query_store_reopens_after_database_replacement) {
|
||
static const char project[] = "cbm-store-generation-refresh";
|
||
static const char active_filename[] = "cbm-store-generation-refresh.db";
|
||
static const char staged_filename[] = "cbm-store-generation-next.db";
|
||
|
||
char cache[512];
|
||
snprintf(cache, sizeof(cache), "%s/cbm-store-generation-XXXXXX", cbm_tmpdir());
|
||
bool cache_ready = cbm_mkdtemp(cache) != NULL;
|
||
const char *saved = getenv("CBM_CACHE_DIR");
|
||
char *saved_copy = saved ? strdup(saved) : NULL;
|
||
if (cache_ready) {
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
}
|
||
|
||
bool generation_a_ready =
|
||
cache_ready && issue704_make_db(cache, active_filename, project, "GenerationA");
|
||
cbm_mcp_server_t *srv = generation_a_ready ? cbm_mcp_server_new(NULL) : NULL;
|
||
bool server_ready = srv != NULL;
|
||
|
||
char args[512];
|
||
snprintf(args, sizeof(args),
|
||
"{\"project\":\"%s\",\"name_pattern\":\".*Generation.*\",\"limit\":10}", project);
|
||
char *before = srv ? cbm_mcp_handle_tool(srv, "search_graph", args) : NULL;
|
||
bool saw_generation_a = before && strstr(before, "GenerationA") != NULL;
|
||
|
||
bool generation_b_ready =
|
||
cache_ready && issue704_make_db(cache, staged_filename, project, "GenerationB");
|
||
char active_path[700];
|
||
char staged_path[700];
|
||
snprintf(active_path, sizeof(active_path), "%s/%s", cache, active_filename);
|
||
snprintf(staged_path, sizeof(staged_path), "%s/%s", cache, staged_filename);
|
||
bool replaced = generation_b_ready && cbm_rename_replace(staged_path, active_path) == 0;
|
||
|
||
char *after = (srv && replaced) ? cbm_mcp_handle_tool(srv, "search_graph", args) : NULL;
|
||
bool saw_generation_b = after && strstr(after, "GenerationB") != NULL;
|
||
bool retained_generation_a = after && strstr(after, "GenerationA") != NULL;
|
||
|
||
free(before);
|
||
free(after);
|
||
if (srv) {
|
||
cbm_mcp_server_free(srv);
|
||
}
|
||
if (cache_ready) {
|
||
cleanup_project_db(cache, project);
|
||
cleanup_project_db(cache, "cbm-store-generation-next");
|
||
cbm_rmdir(cache);
|
||
}
|
||
restore_cache_dir(saved_copy);
|
||
free(saved_copy);
|
||
|
||
ASSERT_TRUE(cache_ready);
|
||
ASSERT_TRUE(generation_a_ready);
|
||
ASSERT_TRUE(server_ready);
|
||
ASSERT_TRUE(saw_generation_a);
|
||
ASSERT_TRUE(generation_b_ready);
|
||
ASSERT_TRUE(replaced);
|
||
ASSERT_TRUE(saw_generation_b);
|
||
ASSERT_FALSE(retained_generation_a);
|
||
PASS();
|
||
}
|
||
|
||
#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,
|
||
IDX823_PARENT_GUARD_USED = 67,
|
||
};
|
||
|
||
#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;
|
||
}
|
||
/* A supervised local index transfers project-lock ownership to the worker.
|
||
* Denying the parent guard is therefore harmless and proves the parent did
|
||
* not acquire a lease before spawning. RED on the former ordering, which
|
||
* returned "blocked" without ever starting the worker. */
|
||
mcp_mutation_guard_probe_t parent_guard = {.deny_begin_call = 1};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &parent_guard);
|
||
|
||
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 (parent_guard.begin_count != 0 || parent_guard.end_count != 0) {
|
||
code = IDX823_PARENT_GUARD_USED;
|
||
} else 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, 67=parent guard used)\n",
|
||
code);
|
||
}
|
||
ASSERT_FALSE(signalled);
|
||
ASSERT_EQ(code, IDX823_OK);
|
||
PASS();
|
||
#endif
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* #845 — supervisor gate must not wrap embedders of cbm_mcp_handle_tool
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
TEST(index_supervisor_unsafe_clean_is_never_fallback_or_recovery) {
|
||
char response[] = "{\"status\":\"indexed\"}";
|
||
cbm_index_worker_result_t result = {
|
||
.outcome = CBM_PROC_CLEAN,
|
||
.exit_code = 0,
|
||
.tree_quiesced = true,
|
||
.response = response,
|
||
};
|
||
ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result), CBM_MCP_SUPERVISED_RESULT_SUCCESS);
|
||
|
||
result.cancellation_requested = true;
|
||
ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result),
|
||
CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL);
|
||
result.cancellation_requested = false;
|
||
result.tree_quiesced = false;
|
||
ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result),
|
||
CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL);
|
||
result.tree_quiesced = true;
|
||
result.supervision_failed = true;
|
||
ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result),
|
||
CBM_MCP_SUPERVISED_RESULT_UNSAFE_TERMINAL);
|
||
|
||
result.supervision_failed = false;
|
||
result.outcome = CBM_PROC_CRASH;
|
||
result.response = NULL;
|
||
ASSERT_EQ(cbm_mcp_supervised_result_disposition(0, &result),
|
||
CBM_MCP_SUPERVISED_RESULT_CONTAINED_FAILURE);
|
||
ASSERT_EQ(cbm_mcp_supervised_result_disposition(-1, &result),
|
||
CBM_MCP_SUPERVISED_RESULT_FALLBACK);
|
||
PASS();
|
||
}
|
||
|
||
/* Child-side check: index a tiny fixture and verify it ran IN-PROCESS.
|
||
* Distinct exit codes so the parent can report the exact failure mode. */
|
||
enum {
|
||
IDX845_OK = 0,
|
||
IDX845_SPAWNED = 41, /* a worker subprocess was spawned — the #845 bug */
|
||
IDX845_NO_RESULT = 42, /* handle_tool returned NULL */
|
||
IDX845_NOT_INDEXED = 43, /* response lacks status=indexed */
|
||
};
|
||
|
||
static int idx845_index_inprocess_check(const char *repo_dir) {
|
||
int spawns_before = cbm_index_supervisor_spawn_count();
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
if (!srv) {
|
||
return IDX845_NO_RESULT;
|
||
}
|
||
char args[1024];
|
||
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo_dir);
|
||
char *resp = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
|
||
int code = IDX845_OK;
|
||
if (cbm_index_supervisor_spawn_count() != spawns_before) {
|
||
code = IDX845_SPAWNED;
|
||
} else if (!resp) {
|
||
code = IDX845_NO_RESULT;
|
||
} else if (!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) {
|
||
code = IDX845_NOT_INDEXED;
|
||
}
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
return code;
|
||
}
|
||
|
||
TEST(index_supervisor_gate_requires_marked_host_issue845) {
|
||
/* #845: index_repository via cbm_mcp_handle_tool from an EMBEDDER (this test
|
||
* binary) must index IN-PROCESS even with CBM_INDEX_SUPERVISOR unset. The
|
||
* supervisor gate may only wrap a process that called
|
||
* cbm_index_supervisor_mark_host() — i.e. the real binary's main(). Before
|
||
* the fix, should_wrap() was true for ANY embedder: the gate resolved the
|
||
* CURRENT binary (this test runner!) and spawned
|
||
* '<test-runner> cli --index-worker --index-worker-build …', 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();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* Mandatory supervision must fail closed in real CBM hosts
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
/* A real CBM host must never turn a supervisor refusal into permission to run
|
||
* the native index pipeline in its own long-lived process. The legacy
|
||
* CBM_INDEX_SUPERVISOR=0 switch is a deterministic start-failure seam here: on
|
||
* the buggy path should_wrap() returned false, the parent mutation guard ran,
|
||
* and the project DB was written in-process. The fixed path keeps supervision
|
||
* mandatory, returns an error, and leaves both the guard and filesystem
|
||
* untouched. Host marking is process-lifetime state, so isolate it in a clean
|
||
* re-exec. posix_spawn stays reliable after earlier tests created threads,
|
||
* whereas a late raw fork can fail transiently under sanitizers on macOS. */
|
||
enum {
|
||
IDXFAILCLOSED_OK = 0,
|
||
IDXFAILCLOSED_NO_SERVER = 81,
|
||
IDXFAILCLOSED_PARENT_MUTATED = 82,
|
||
IDXFAILCLOSED_NO_RESPONSE = 83,
|
||
IDXFAILCLOSED_INDEXED = 84,
|
||
IDXFAILCLOSED_NOT_ERROR = 85,
|
||
};
|
||
|
||
#ifndef _WIN32
|
||
int mcp_test_idxfailclosed_supervisor_start_check(const char *repo_dir, const char *cache_dir) {
|
||
(void)cbm_setenv("CBM_CACHE_DIR", cache_dir, 1);
|
||
cbm_index_supervisor_mark_host();
|
||
(void)cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
if (!srv) {
|
||
return IDXFAILCLOSED_NO_SERVER;
|
||
}
|
||
mcp_mutation_guard_probe_t parent_guard = {0};
|
||
cbm_mcp_server_set_project_mutation_guard(srv, mcp_mutation_guard_probe_begin,
|
||
mcp_mutation_guard_probe_end, &parent_guard);
|
||
|
||
char args[CBM_SZ_4K];
|
||
(void)snprintf(args, sizeof(args), "{\"repo_path\":\"%s\",\"mode\":\"fast\"}", repo_dir);
|
||
char *response = cbm_mcp_handle_tool(srv, "index_repository", args);
|
||
|
||
int result = IDXFAILCLOSED_OK;
|
||
if (parent_guard.begin_count != 0 || parent_guard.end_count != 0) {
|
||
result = IDXFAILCLOSED_PARENT_MUTATED;
|
||
} else if (!response) {
|
||
result = IDXFAILCLOSED_NO_RESPONSE;
|
||
} else if (response_contains_json_fragment(response, "\"status\":\"indexed\"")) {
|
||
result = IDXFAILCLOSED_INDEXED;
|
||
} else if (!response_contains_json_fragment(response, "\"status\":\"error\"") ||
|
||
!response_contains_json_fragment(response, "\"outcome\":\"spawn_failed\"")) {
|
||
result = IDXFAILCLOSED_NOT_ERROR;
|
||
}
|
||
|
||
free(response);
|
||
cbm_mcp_server_free(srv);
|
||
return result;
|
||
}
|
||
|
||
static bool idxfailclosed_self_path(char out[CBM_SZ_4K]) {
|
||
#ifdef __APPLE__
|
||
int length = proc_pidpath(getpid(), out, CBM_SZ_4K);
|
||
bool resolved = length > 0 && length < CBM_SZ_4K;
|
||
if (resolved) {
|
||
out[length] = '\0';
|
||
}
|
||
return resolved;
|
||
#elif defined(__linux__)
|
||
ssize_t length = readlink("/proc/self/exe", out, CBM_SZ_4K - 1);
|
||
bool resolved = length > 0 && length < (ssize_t)CBM_SZ_4K - 1;
|
||
if (resolved) {
|
||
out[length] = '\0';
|
||
}
|
||
return resolved;
|
||
#else
|
||
(void)out;
|
||
return false;
|
||
#endif
|
||
}
|
||
#endif
|
||
|
||
TEST(index_supervisor_start_failure_is_fail_closed_in_real_host) {
|
||
#ifdef _WIN32
|
||
SKIP_PLATFORM("immutable host mark needs fork isolation (POSIX-only)");
|
||
#else
|
||
char repo_dir[CBM_SZ_1K];
|
||
char cache_dir[CBM_SZ_1K];
|
||
(void)snprintf(repo_dir, sizeof(repo_dir), "%s/cbm-idx-failclosed-repo-XXXXXX", cbm_tmpdir());
|
||
(void)snprintf(cache_dir, sizeof(cache_dir), "%s/cbm-idx-failclosed-cache-XXXXXX",
|
||
cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(repo_dir));
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(cache_dir));
|
||
|
||
char source_path[CBM_SZ_4K];
|
||
(void)snprintf(source_path, sizeof(source_path), "%s/should_not_index.py", repo_dir);
|
||
FILE *source = cbm_fopen(source_path, "wb");
|
||
ASSERT_NOT_NULL(source);
|
||
ASSERT_TRUE(fputs("def should_not_index():\n return True\n", source) >= 0);
|
||
ASSERT_EQ(fclose(source), 0);
|
||
|
||
char *project = cbm_project_name_from_path(repo_dir);
|
||
ASSERT_NOT_NULL(project);
|
||
char db_path[CBM_SZ_4K];
|
||
(void)snprintf(db_path, sizeof(db_path), "%s/%s.db", cache_dir, project);
|
||
|
||
char self_path[CBM_SZ_4K] = {0};
|
||
ASSERT_TRUE(idxfailclosed_self_path(self_path));
|
||
char *const child_argv[] = {
|
||
self_path, "__cbm_mcp_idxfailclosed_probe", repo_dir, cache_dir, NULL,
|
||
};
|
||
(void)fflush(NULL);
|
||
pid_t child = -1;
|
||
ASSERT_EQ(posix_spawn(&child, self_path, NULL, NULL, child_argv, environ), 0);
|
||
ASSERT_TRUE(child > 0);
|
||
int status = 0;
|
||
ASSERT_EQ(waitpid(child, &status, 0), child);
|
||
bool exited = WIFEXITED(status);
|
||
int child_result = exited ? WEXITSTATUS(status) : -1;
|
||
bool database_absent = !cbm_file_exists(db_path);
|
||
|
||
cleanup_project_db(cache_dir, project);
|
||
free(project);
|
||
(void)cbm_unlink(source_path);
|
||
(void)th_rmtree(repo_dir);
|
||
(void)th_rmtree(cache_dir);
|
||
|
||
ASSERT_TRUE(exited);
|
||
ASSERT_EQ(child_result, IDXFAILCLOSED_OK);
|
||
ASSERT_TRUE(database_absent);
|
||
PASS();
|
||
#endif
|
||
}
|
||
|
||
/* ═══════════════════════════════════════════════════════════
|
||
* #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 cache[CBM_SZ_256];
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm_seq898_cache_XXXXXX");
|
||
if (!cbm_mkdtemp(cache)) {
|
||
cbm_rmdir(tmp);
|
||
FAIL("cache 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);
|
||
|
||
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);
|
||
|
||
/* File-backed MCP stores are deliberately request-scoped so a sibling
|
||
* process can atomically replace the DB generation (and so Windows does
|
||
* not retain a replacement-blocking handle). Inspect the published DB
|
||
* through an independent query handle instead of relying on srv->store. */
|
||
char *project = cbm_project_name_from_path(tmp);
|
||
ASSERT_NOT_NULL(project);
|
||
char db_path[CBM_SZ_512];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
cbm_store_t *store = cbm_store_open_path_query(db_path);
|
||
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_store_close(store);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_project_db(cache, project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
free(project);
|
||
th_rmtree(cache);
|
||
cbm_unlink(src_path);
|
||
cbm_rmdir(tmp);
|
||
PASS();
|
||
}
|
||
|
||
TEST(index_second_inprocess_run_survives_issue773) {
|
||
#ifdef _WIN32
|
||
SKIP_PLATFORM("fork-isolated crash guard (POSIX-only)");
|
||
#else
|
||
char dir_a[CBM_SZ_256];
|
||
char dir_b[CBM_SZ_256];
|
||
char cache[CBM_SZ_256];
|
||
snprintf(dir_a, sizeof(dir_a), "/tmp/cbm-idx773a-XXXXXX");
|
||
snprintf(dir_b, sizeof(dir_b), "/tmp/cbm-idx773b-XXXXXX");
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-idx773c-XXXXXX");
|
||
if (!cbm_mkdtemp(dir_a) || !cbm_mkdtemp(dir_b) || !cbm_mkdtemp(cache)) {
|
||
FAIL("mkdtemp failed");
|
||
}
|
||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||
char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL;
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
|
||
/* Trigger shape: run 1 small enough for the SEQUENTIAL path (parses on
|
||
* the calling thread, mimalloc epoch), run 2 large enough for the
|
||
* PARALLEL path (switches the global ts allocator to the slab). */
|
||
idx773_write_py_repo(dir_a, 5, 0);
|
||
idx773_write_py_repo(dir_b, 60, 1);
|
||
|
||
int code = -1;
|
||
bool signalled = false;
|
||
int sig = 0;
|
||
fflush(NULL);
|
||
pid_t pid = fork();
|
||
if (pid == 0) {
|
||
alarm(180); /* generous: two full parallel indexes */
|
||
_exit(idx773_double_index_check(dir_a, dir_b));
|
||
}
|
||
ASSERT_TRUE(pid > 0);
|
||
int status = 0;
|
||
(void)waitpid(pid, &status, 0);
|
||
if (WIFEXITED(status)) {
|
||
code = WEXITSTATUS(status);
|
||
} else if (WIFSIGNALED(status)) {
|
||
signalled = true;
|
||
sig = WTERMSIG(status);
|
||
}
|
||
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
|
||
if (signalled) {
|
||
printf(" child killed by signal %d (SIGABRT = the #773 invalid free)\n", sig);
|
||
} else if (code != IDX773_OK) {
|
||
printf(" child exit code %d (71=first index failed, 72=second failed)\n", code);
|
||
}
|
||
ASSERT_FALSE(signalled);
|
||
ASSERT_EQ(code, IDX773_OK);
|
||
PASS();
|
||
#endif
|
||
}
|
||
|
||
TEST(index_recovery_parallel_quarantines_crasher) {
|
||
#ifdef _WIN32
|
||
SKIP_PLATFORM("parallel-recovery guard needs fork isolation (POSIX-only)");
|
||
#else
|
||
char tmp_dir[CBM_SZ_256];
|
||
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idxpar-XXXXXX");
|
||
if (!cbm_mkdtemp(tmp_dir)) {
|
||
FAIL("mkdtemp failed");
|
||
}
|
||
char cache[CBM_SZ_256];
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-idxpar-cache-XXXXXX");
|
||
if (!cbm_mkdtemp(cache)) {
|
||
FAIL("mkdtemp cache failed");
|
||
}
|
||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||
char *saved_cache_copy = saved_cache ? cbm_strdup(saved_cache) : NULL;
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
|
||
char p1[CBM_SZ_512];
|
||
char p2[CBM_SZ_512];
|
||
char pc[CBM_SZ_512];
|
||
snprintf(p1, sizeof(p1), "%s/idxpar_good_a.py", tmp_dir);
|
||
snprintf(p2, sizeof(p2), "%s/idxpar_good_b.py", tmp_dir);
|
||
snprintf(pc, sizeof(pc), "%s/idxpar_crasher.py", tmp_dir);
|
||
FILE *f = fopen(p1, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fputs("def idxpar_good_fn():\n return 'ok'\n", f);
|
||
fclose(f);
|
||
f = fopen(p2, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fputs("def idxpar_good_fn_b():\n return 'ok'\n", f);
|
||
fclose(f);
|
||
f = fopen(pc, "w");
|
||
ASSERT_NOT_NULL(f);
|
||
fputs("def idxpar_crash_fn():\n return 'boom'\n", f);
|
||
fclose(f);
|
||
|
||
int code = -1;
|
||
bool signalled = false;
|
||
int sig = 0;
|
||
fflush(NULL);
|
||
pid_t pid = fork();
|
||
if (pid == 0) {
|
||
alarm(120); /* generous: three supervised rounds + clean run */
|
||
_exit(idxpar_recovery_check(tmp_dir));
|
||
}
|
||
ASSERT_TRUE(pid > 0);
|
||
int status = 0;
|
||
(void)waitpid(pid, &status, 0);
|
||
if (WIFEXITED(status)) {
|
||
code = WEXITSTATUS(status);
|
||
} else if (WIFSIGNALED(status)) {
|
||
signalled = true;
|
||
sig = WTERMSIG(status);
|
||
}
|
||
|
||
char *project = cbm_project_name_from_path(tmp_dir);
|
||
cleanup_project_db(cache, project);
|
||
free(project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
remove(p1);
|
||
remove(p2);
|
||
remove(pc);
|
||
cbm_rmdir(cache);
|
||
cbm_rmdir(tmp_dir);
|
||
|
||
if (signalled) {
|
||
printf(" child killed by signal %d (alarm => recovery loop hang)\n", sig);
|
||
} else if (code != IDXPAR_OK) {
|
||
printf(" child exit code %d (61=ST spawn/RED, 62=null resp, 63=not indexed, "
|
||
"64=no quarantine, 65=innocent hit, 66=good missing)\n",
|
||
code);
|
||
}
|
||
ASSERT_FALSE(signalled);
|
||
ASSERT_EQ(code, IDXPAR_OK);
|
||
PASS();
|
||
#endif
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* AUTO_WATCH GATE (distilled from PR #625)
|
||
*
|
||
* Background watcher registration on session connect is gated by the
|
||
* `auto_watch` config key (default TRUE = existing behavior).
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
/* Drive the already-indexed connect path (initialize → maybe_auto_index →
|
||
* watcher registration) and return the resulting watch count.
|
||
* auto_watch_value: NULL leaves the key unset (exercises the default),
|
||
* otherwise the key is set to that value before initialize.
|
||
* Returns a negative code on fixture setup failure. */
|
||
static int auto_watch_connect_watch_count(const char *auto_watch_value) {
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-autowatch-cache-XXXXXX");
|
||
if (!cbm_mkdtemp(cache)) {
|
||
return -1;
|
||
}
|
||
|
||
char repodir[512];
|
||
snprintf(repodir, sizeof(repodir), "%s/repo", cache);
|
||
if (th_mkdir_p(repodir) != 0) {
|
||
th_rmtree(cache);
|
||
return -2;
|
||
}
|
||
|
||
/* Same derivation detect_session uses on the cwd — realpath-based, so
|
||
* the name matches even where /tmp is a symlink (macOS). */
|
||
char *project = cbm_project_name_from_path(repodir);
|
||
if (!project) {
|
||
th_rmtree(cache);
|
||
return -3;
|
||
}
|
||
|
||
/* Pre-create <cache>/<project>.db so maybe_auto_index takes the
|
||
* "already indexed" branch — the watcher-registration site under test. */
|
||
char db_path[1024];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project);
|
||
if (th_write_file(db_path, "") != 0) {
|
||
free(project);
|
||
th_rmtree(cache);
|
||
return -4;
|
||
}
|
||
free(project);
|
||
|
||
const char *saved = getenv("CBM_CACHE_DIR");
|
||
char *saved_copy = saved ? strdup(saved) : NULL;
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
|
||
char old_cwd[1024];
|
||
if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(repodir) != 0) {
|
||
restore_cache_dir(saved_copy);
|
||
free(saved_copy);
|
||
th_rmtree(cache);
|
||
return -5;
|
||
}
|
||
|
||
int count = -6;
|
||
cbm_config_t *cfg = cbm_config_open(cache);
|
||
cbm_store_t *wstore = cbm_store_open_memory();
|
||
cbm_watcher_t *watcher = wstore ? cbm_watcher_new(wstore, NULL, NULL) : NULL;
|
||
if (cfg && watcher) {
|
||
if (auto_watch_value) {
|
||
cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, auto_watch_value);
|
||
}
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
if (srv) {
|
||
cbm_mcp_server_set_watcher(srv, watcher);
|
||
cbm_mcp_server_set_config(srv, cfg);
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}");
|
||
free(resp);
|
||
count = cbm_watcher_watch_count(watcher);
|
||
cbm_mcp_server_free(srv);
|
||
}
|
||
}
|
||
|
||
if (watcher) {
|
||
cbm_watcher_free(watcher);
|
||
}
|
||
if (wstore) {
|
||
cbm_store_close(wstore);
|
||
}
|
||
if (cfg) {
|
||
cbm_config_close(cfg);
|
||
}
|
||
|
||
(void)cbm_chdir(old_cwd);
|
||
restore_cache_dir(saved_copy);
|
||
free(saved_copy);
|
||
th_rmtree(cache);
|
||
return count;
|
||
}
|
||
|
||
/* Default (key unset) → watcher registered on connect. Guards the
|
||
* no-behavior-change promise of the auto_watch gate: existing users keep
|
||
* background auto-sync without touching config. */
|
||
TEST(mcp_auto_watch_default_registers_watcher_on_connect) {
|
||
int count = auto_watch_connect_watch_count(NULL);
|
||
if (count < 0) {
|
||
PASS(); /* fixture setup failed (tmpdir/cwd unavailable) — skip */
|
||
}
|
||
ASSERT_EQ(count, 1);
|
||
PASS();
|
||
}
|
||
|
||
/* auto_watch=false → NO watcher registered on connect. RED on pre-gate code
|
||
* (registration was unconditional and the key did not exist). */
|
||
TEST(mcp_auto_watch_false_skips_watcher_on_connect) {
|
||
int count = auto_watch_connect_watch_count("false");
|
||
if (count < 0) {
|
||
PASS(); /* fixture setup failed (tmpdir/cwd unavailable) — skip */
|
||
}
|
||
ASSERT_EQ(count, 0);
|
||
PASS();
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* #853 — auto_watch=false must ALSO gate the SUPERVISED fresh-index
|
||
* watcher registration (keystone × #849 merge interaction)
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
/* #849 routed ALL watcher registration through register_watcher_if_enabled()
|
||
* (auto_watch gate). The #832 keystone then added a SECOND registration site in
|
||
* autoindex_thread's supervised-success branch, but wired it as a DIRECT
|
||
* cbm_watcher_watch() guarded only by `if (srv->watcher)` — srv->watcher is set
|
||
* unconditionally, so that guard does NOT honour `config set auto_watch false`.
|
||
* The above tests only cover the already-indexed on-connect path
|
||
* (register_watcher_if_enabled); this guard covers the fresh-index SUPERVISED
|
||
* autoindex_thread branch that #832 introduced.
|
||
*
|
||
* Drive the real public entry initialize → maybe_auto_index → autoindex_thread on
|
||
* a supervisor-marked host (kill switch off) with a FRESH project (no prior .db)
|
||
* and auto_watch=false. cbm_mcp_server_free() joins the autoindex thread, so the
|
||
* (buggy or gated) registration decision has run before we read the watch count.
|
||
*
|
||
* RED on the unfixed ungated block: the supervised success branch calls
|
||
* cbm_watcher_watch() unconditionally → watch_count == 1 → IDX853_WATCHER_REGISTERED.
|
||
* GREEN once it calls register_watcher_if_enabled() → auto_watch_off skip → 0.
|
||
* spawn_count is asserted to have advanced so the assertion cannot pass vacuously
|
||
* (i.e. green only because the supervised branch was never entered). */
|
||
enum {
|
||
IDX853_OK = 0, /* watch_count==0, supervised branch ran → GREEN */
|
||
IDX853_WATCHER_REGISTERED = 61, /* watch_count==1 → RED: ungated cbm_watcher_watch */
|
||
IDX853_NO_SPAWN = 62, /* spawn_count unchanged → supervised path not exercised */
|
||
IDX853_SETUP_FAIL = 63, /* config/watcher/server/cwd setup failed */
|
||
IDX853_BAD_COUNT = 64, /* unexpected watch_count (<0 or >1) */
|
||
};
|
||
|
||
#ifndef _WIN32 /* helper used only by the POSIX fork harness below */
|
||
static int idx853_supervised_autowatch_check(const char *repo_dir, const char *cache_dir) {
|
||
/* Become a supervisor host with the kill switch OFF — the real prod MCP
|
||
* server's state. Done in the FORKED CHILD only (see harness) so the parent
|
||
* test-runner's process-wide host mark stays clear (#845 invariant). Bound the
|
||
* worker so a stuck spawn cannot run long under the fork+alarm net. */
|
||
cbm_index_supervisor_mark_host();
|
||
cbm_unsetenv("CBM_INDEX_SUPERVISOR");
|
||
cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1);
|
||
cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1);
|
||
|
||
cbm_config_t *cfg = cbm_config_open(cache_dir);
|
||
cbm_store_t *wstore = cbm_store_open_memory();
|
||
cbm_watcher_t *watcher = wstore ? cbm_watcher_new(wstore, NULL, NULL) : NULL;
|
||
if (!cfg || !watcher) {
|
||
if (watcher) {
|
||
cbm_watcher_free(watcher);
|
||
}
|
||
if (wstore) {
|
||
cbm_store_close(wstore);
|
||
}
|
||
if (cfg) {
|
||
cbm_config_close(cfg);
|
||
}
|
||
return IDX853_SETUP_FAIL;
|
||
}
|
||
/* auto_index=true → maybe_auto_index launches autoindex_thread for the fresh
|
||
* project; auto_watch=false → the gate this guard exercises. */
|
||
cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true");
|
||
cbm_config_set(cfg, CBM_CONFIG_AUTO_WATCH, "false");
|
||
|
||
/* detect_session derives session_root/session_project from the cwd. */
|
||
char old_cwd[1024];
|
||
if (!cbm_getcwd(old_cwd, sizeof(old_cwd)) || cbm_chdir(repo_dir) != 0) {
|
||
cbm_watcher_free(watcher);
|
||
cbm_store_close(wstore);
|
||
cbm_config_close(cfg);
|
||
return IDX853_SETUP_FAIL;
|
||
}
|
||
|
||
int spawns_before = cbm_index_supervisor_spawn_count();
|
||
int code = IDX853_SETUP_FAIL;
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
if (srv) {
|
||
cbm_mcp_server_set_watcher(srv, watcher);
|
||
cbm_mcp_server_set_config(srv, cfg);
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\",\"params\":{}}");
|
||
free(resp);
|
||
/* free() joins the autoindex thread → the supervised worker has finished
|
||
* and the registration decision (buggy or gated) has executed. */
|
||
cbm_mcp_server_free(srv);
|
||
|
||
int spawns_after = cbm_index_supervisor_spawn_count();
|
||
int watch_count = cbm_watcher_watch_count(watcher);
|
||
|
||
if (spawns_after == spawns_before) {
|
||
code = IDX853_NO_SPAWN; /* supervised branch never ran — not a valid probe */
|
||
} else if (watch_count == 1) {
|
||
code = IDX853_WATCHER_REGISTERED; /* the discriminating RED assertion */
|
||
} else if (watch_count == 0) {
|
||
code = IDX853_OK;
|
||
} else {
|
||
code = IDX853_BAD_COUNT;
|
||
}
|
||
}
|
||
|
||
(void)cbm_chdir(old_cwd);
|
||
cbm_watcher_free(watcher);
|
||
cbm_store_close(wstore);
|
||
cbm_config_close(cfg);
|
||
return code;
|
||
}
|
||
#endif /* !_WIN32 */
|
||
|
||
TEST(mcp_auto_watch_false_skips_supervised_autoindex_issue853) {
|
||
#ifdef _WIN32
|
||
/* Marks the process as a supervisor host (irreversible); POSIX isolates that
|
||
* in a forked child. The gate logic is platform-independent and covered on
|
||
* POSIX CI. */
|
||
SKIP_PLATFORM("supervisor-host guard needs fork isolation (POSIX-only)");
|
||
#else
|
||
char tmp_dir[256];
|
||
snprintf(tmp_dir, sizeof(tmp_dir), "/tmp/cbm-idx853-repo-XXXXXX");
|
||
if (!cbm_mkdtemp(tmp_dir)) {
|
||
PASS();
|
||
}
|
||
char cache[256];
|
||
snprintf(cache, sizeof(cache), "/tmp/cbm-idx853-cache-XXXXXX");
|
||
if (!cbm_mkdtemp(cache)) {
|
||
cbm_rmdir(tmp_dir);
|
||
PASS();
|
||
}
|
||
|
||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1); /* inherited by the worker child */
|
||
|
||
char src_path[512];
|
||
snprintf(src_path, sizeof(src_path), "%s/main.py", tmp_dir);
|
||
FILE *fp = fopen(src_path, "w");
|
||
ASSERT_NOT_NULL(fp);
|
||
fputs("def idx853_fn():\n return 'ok'\n", fp);
|
||
fclose(fp);
|
||
|
||
int code = -1;
|
||
bool signalled = false;
|
||
int sig = 0;
|
||
fflush(NULL);
|
||
pid_t pid = fork();
|
||
if (pid == 0) {
|
||
alarm(60); /* a stuck worker dies here instead of hanging the runner */
|
||
_exit(idx853_supervised_autowatch_check(tmp_dir, cache));
|
||
}
|
||
ASSERT_TRUE(pid > 0);
|
||
int status = 0;
|
||
(void)waitpid(pid, &status, 0);
|
||
if (WIFEXITED(status)) {
|
||
code = WEXITSTATUS(status);
|
||
} else if (WIFSIGNALED(status)) {
|
||
signalled = true;
|
||
sig = WTERMSIG(status);
|
||
}
|
||
|
||
char *project = cbm_project_name_from_path(tmp_dir);
|
||
cleanup_project_db(cache, project);
|
||
free(project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
remove(src_path);
|
||
cbm_rmdir(cache);
|
||
cbm_rmdir(tmp_dir);
|
||
|
||
if (signalled) {
|
||
printf(" child killed by signal %d (alarm => worker hang)\n", sig);
|
||
} else if (code != IDX853_OK) {
|
||
printf(" child exit code %d (61=watcher registered under auto_watch=false=RED, "
|
||
"62=no spawn, 63=setup fail, 64=bad count)\n",
|
||
code);
|
||
}
|
||
ASSERT_FALSE(signalled);
|
||
ASSERT_EQ(code, IDX853_OK);
|
||
PASS();
|
||
#endif
|
||
}
|
||
|
||
/* The containment guard both MCP file-read sinks route through
|
||
* (resolve_snippet_source for get_code_snippet, attach_result_source for
|
||
* search_code). A result path that resolves outside the indexed project root
|
||
* — via a `..` segment or a followed symlink/junction — must be rejected so
|
||
* its contents never reach a tool response. */
|
||
extern bool cbm_path_within_root(const char *root_path, const char *abs_path);
|
||
|
||
TEST(mcp_path_within_root_rejects_escape) {
|
||
#ifdef _WIN32
|
||
char root[512];
|
||
char outside[512];
|
||
snprintf(root, sizeof(root), "%s/cbm_pwr_root_XXXXXX", cbm_tmpdir());
|
||
snprintf(outside, sizeof(outside), "%s/cbm_pwr_outside_XXXXXX", cbm_tmpdir());
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(root));
|
||
ASSERT_NOT_NULL(cbm_mkdtemp(outside));
|
||
|
||
char inside[700];
|
||
char target[700];
|
||
char junction[700];
|
||
char linked_target[900];
|
||
snprintf(inside, sizeof(inside), "%s/inside.c", root);
|
||
snprintf(target, sizeof(target), "%s/outside.c", outside);
|
||
snprintf(junction, sizeof(junction), "%s/escape", root);
|
||
snprintf(linked_target, sizeof(linked_target), "%s/outside.c", junction);
|
||
FILE *fp = cbm_fopen(inside, "w");
|
||
ASSERT_NOT_NULL(fp);
|
||
fputs("int inside;\n", fp);
|
||
fclose(fp);
|
||
fp = cbm_fopen(target, "w");
|
||
ASSERT_NOT_NULL(fp);
|
||
fputs("int outside;\n", fp);
|
||
fclose(fp);
|
||
|
||
/* cbm_tmpdir() can expose the MSYS spelling C:/msys64/...; cmd's mklink
|
||
* builtin treats the slash before "msys64" as another option delimiter.
|
||
* Native backslashes are required only at this cmd.exe fixture boundary. */
|
||
char junction_native[sizeof(junction)];
|
||
char outside_native[sizeof(outside)];
|
||
snprintf(junction_native, sizeof(junction_native), "%s", junction);
|
||
snprintf(outside_native, sizeof(outside_native), "%s", outside);
|
||
for (char *cursor = junction_native; *cursor; cursor++) {
|
||
if (*cursor == '/') {
|
||
*cursor = '\\';
|
||
}
|
||
}
|
||
for (char *cursor = outside_native; *cursor; cursor++) {
|
||
if (*cursor == '/') {
|
||
*cursor = '\\';
|
||
}
|
||
}
|
||
const char *junction_argv[] = {"cmd.exe", "/d", "/c", "mklink", "/J",
|
||
junction_native, outside_native, NULL};
|
||
bool linked = cbm_exec_no_shell(junction_argv) == 0;
|
||
|
||
ASSERT_TRUE(linked);
|
||
ASSERT_TRUE(cbm_path_within_root(root, inside));
|
||
ASSERT_FALSE(cbm_path_within_root(root, target));
|
||
ASSERT_FALSE(cbm_path_within_root(root, linked_target));
|
||
|
||
char case_alias[sizeof(root)];
|
||
snprintf(case_alias, sizeof(case_alias), "%s", root);
|
||
char *leaf = strrchr(case_alias, '/');
|
||
char *backslash_leaf = strrchr(case_alias, '\\');
|
||
if (!leaf || (backslash_leaf && backslash_leaf > leaf)) {
|
||
leaf = backslash_leaf;
|
||
}
|
||
leaf = leaf ? leaf + 1 : case_alias;
|
||
if (*leaf >= 'a' && *leaf <= 'z') {
|
||
*leaf = (char)(*leaf - 'a' + 'A');
|
||
} else if (*leaf >= 'A' && *leaf <= 'Z') {
|
||
*leaf = (char)(*leaf - 'A' + 'a');
|
||
}
|
||
ASSERT_TRUE(cbm_path_within_root(case_alias, inside));
|
||
|
||
char drive_root[] = {root[0], ':', '\\', '\0'};
|
||
ASSERT_TRUE(((root[0] >= 'A' && root[0] <= 'Z') || (root[0] >= 'a' && root[0] <= 'z')) &&
|
||
root[1] == ':');
|
||
ASSERT_TRUE(cbm_path_within_root(drive_root, inside));
|
||
|
||
cbm_rmdir(junction);
|
||
cbm_unlink(inside);
|
||
cbm_unlink(target);
|
||
cbm_rmdir(root);
|
||
cbm_rmdir(outside);
|
||
PASS();
|
||
#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"));
|
||
ASSERT_TRUE(cbm_path_within_root("/", "/etc/hosts"));
|
||
|
||
remove(inside);
|
||
cbm_rmdir(root);
|
||
PASS();
|
||
#endif
|
||
}
|
||
|
||
/* base_branch is spliced into a `git diff --name-only "<base>"...HEAD` command;
|
||
* a value starting with '-' would be taken by git as an option (e.g.
|
||
* --output=<path> writes the diff to an arbitrary file) rather than a ref. It
|
||
* must be rejected up front, alongside the shell-metacharacter check. */
|
||
TEST(detect_changes_rejects_option_like_base_branch) {
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
char *resp = cbm_mcp_server_handle(
|
||
srv, "{\"jsonrpc\":\"2.0\",\"id\":77,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"detect_changes\","
|
||
"\"arguments\":{\"project\":\"p\",\"base_branch\":\"--output=/tmp/cbm_pwn\"}}}");
|
||
ASSERT_NOT_NULL(resp);
|
||
ASSERT_NOT_NULL(strstr(resp, "invalid characters"));
|
||
free(resp);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
}
|
||
|
||
TEST(detect_changes_rejects_windows_cmd_metacharacters_in_base_branch) {
|
||
#ifdef _WIN32
|
||
const char *const branches[] = {"topic%PATH%", "topic!name!", "topic^name"};
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
for (size_t i = 0; i < sizeof(branches) / sizeof(branches[0]); i++) {
|
||
char request[512];
|
||
snprintf(request, sizeof(request),
|
||
"{\"jsonrpc\":\"2.0\",\"id\":78,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"detect_changes\","
|
||
"\"arguments\":{\"project\":\"p\",\"base_branch\":\"%s\"}}}",
|
||
branches[i]);
|
||
char *response = cbm_mcp_server_handle(srv, request);
|
||
ASSERT_NOT_NULL(response);
|
||
ASSERT_NOT_NULL(strstr(response, "base_branch contains invalid characters"));
|
||
free(response);
|
||
}
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
#else
|
||
SKIP_PLATFORM("cmd.exe interpolation validation runs on Windows");
|
||
#endif
|
||
}
|
||
|
||
TEST(detect_changes_rejects_windows_cmd_metacharacters_in_project_root) {
|
||
#ifdef _WIN32
|
||
const char *const roots[] = {"C:\\cbm-root-%PATH%", "C:\\cbm-root-!name!",
|
||
"C:\\cbm-root-^name"};
|
||
const char *project = "windows-cmd-root-validation";
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
ASSERT_NOT_NULL(srv);
|
||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||
ASSERT_NOT_NULL(store);
|
||
cbm_mcp_server_set_project(srv, project);
|
||
mcp_command_hook_probe_t command_probe = {0};
|
||
cbm_mcp_server_set_command_test_hook(srv, mcp_command_hook_probe, &command_probe);
|
||
|
||
for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) {
|
||
ASSERT_EQ(cbm_store_upsert_project(store, project, roots[i]), CBM_STORE_OK);
|
||
char *response = cbm_mcp_handle_tool(
|
||
srv, "detect_changes",
|
||
"{\"project\":\"windows-cmd-root-validation\",\"base_branch\":\"main\"}");
|
||
ASSERT_NOT_NULL(response);
|
||
ASSERT_NOT_NULL(strstr(response, "project path contains invalid characters"));
|
||
free(response);
|
||
}
|
||
ASSERT_EQ(command_probe.diff_calls, 0);
|
||
ASSERT_EQ(command_probe.merge_base_calls, 0);
|
||
cbm_mcp_server_free(srv);
|
||
PASS();
|
||
#else
|
||
SKIP_PLATFORM("cmd.exe interpolation validation runs on Windows");
|
||
#endif
|
||
}
|
||
|
||
/* 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();
|
||
}
|
||
|
||
TEST(index_repository_relative_path_uses_explicit_session_root) {
|
||
char session_root[512];
|
||
char cache[512];
|
||
snprintf(session_root, sizeof(session_root), "%s/cbm_daemon_session_XXXXXX", cbm_tmpdir());
|
||
snprintf(cache, sizeof(cache), "%s/cbm_daemon_cache_XXXXXX", cbm_tmpdir());
|
||
if (!cbm_mkdtemp(session_root) || !cbm_mkdtemp(cache)) {
|
||
th_rmtree(session_root);
|
||
th_rmtree(cache);
|
||
FAIL("cbm_mkdtemp failed");
|
||
}
|
||
|
||
char repo[1024];
|
||
char source[1200];
|
||
snprintf(repo, sizeof(repo), "%s/repo", session_root);
|
||
snprintf(source, sizeof(source), "%s/main.py", repo);
|
||
ASSERT_EQ(th_write_file(source, "def main():\n return 1\n"), 0);
|
||
|
||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||
const char *saved_supervisor = getenv("CBM_INDEX_SUPERVISOR");
|
||
char *saved_supervisor_copy = saved_supervisor ? strdup(saved_supervisor) : NULL;
|
||
cbm_setenv("CBM_CACHE_DIR", cache, 1);
|
||
cbm_setenv("CBM_INDEX_SUPERVISOR", "0", 1);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
bool context_set = srv && cbm_mcp_server_set_session_context(srv, session_root, session_root);
|
||
const char request[] = "{\"jsonrpc\":\"2.0\",\"id\":89,\"method\":\"tools/call\","
|
||
"\"params\":{\"name\":\"index_repository\","
|
||
"\"arguments\":{\"repo_path\":\"repo\",\"mode\":\"fast\"}}}";
|
||
char *response = context_set ? cbm_mcp_server_handle(srv, request) : NULL;
|
||
bool accepted = response && strstr(response, "outside the allowed root") == NULL &&
|
||
strstr(response, "\"isError\":true") == NULL;
|
||
|
||
char *project = cbm_project_name_from_path(repo);
|
||
char db_path[CBM_SZ_4K];
|
||
snprintf(db_path, sizeof(db_path), "%s/%s.db", cache, project ? project : "missing");
|
||
bool indexed_session_repo = project && cbm_file_size(db_path) >= 0;
|
||
|
||
free(response);
|
||
cbm_mcp_server_free(srv);
|
||
cleanup_project_db(cache, project);
|
||
free(project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
if (saved_supervisor_copy) {
|
||
cbm_setenv("CBM_INDEX_SUPERVISOR", saved_supervisor_copy, 1);
|
||
} else {
|
||
cbm_unsetenv("CBM_INDEX_SUPERVISOR");
|
||
}
|
||
free(saved_supervisor_copy);
|
||
th_rmtree(session_root);
|
||
th_rmtree(cache);
|
||
|
||
ASSERT_TRUE(context_set);
|
||
ASSERT_TRUE(accepted);
|
||
ASSERT_TRUE(indexed_session_repo);
|
||
PASS();
|
||
}
|
||
|
||
/* A daemon-backed session validates repo_path against its own session root, but
|
||
* the supervised worker is a fresh process that inherits the daemon's cwd. A
|
||
* relative path must therefore be resolved once by the session and forwarded to
|
||
* the worker as that same canonical absolute path. The decoy repo makes an
|
||
* unsanitized handoff observable: forwarding the original "repo" indexes the
|
||
* cwd-relative decoy instead of the validated session repo. */
|
||
enum {
|
||
IDXCANON_OK = 0,
|
||
IDXCANON_GETCWD_FAILED = 71,
|
||
IDXCANON_CHDIR_FAILED = 72,
|
||
IDXCANON_NO_SERVER = 73,
|
||
IDXCANON_CONTEXT_FAILED = 74,
|
||
IDXCANON_NO_SPAWN = 75,
|
||
IDXCANON_NO_RESULT = 76,
|
||
IDXCANON_NOT_INDEXED = 77,
|
||
IDXCANON_WRONG_PROJECT = 78,
|
||
IDXCANON_DECOY_INDEXED = 79,
|
||
IDXCANON_TARGET_MISSING = 80,
|
||
IDXCANON_CWD_RESTORE_FAILED = 81,
|
||
};
|
||
|
||
#ifndef _WIN32
|
||
static int idxcanon_supervised_session_path_check(const char *session_root, const char *decoy_cwd) {
|
||
char saved_cwd[CBM_SZ_4K];
|
||
if (!cbm_getcwd(saved_cwd, sizeof(saved_cwd))) {
|
||
return IDXCANON_GETCWD_FAILED;
|
||
}
|
||
if (cbm_chdir(decoy_cwd) != 0) {
|
||
return IDXCANON_CHDIR_FAILED;
|
||
}
|
||
|
||
/* Match a real supervisor host. Environment changes are isolated to this
|
||
* forked child and inherited by its worker; the parent test process keeps
|
||
* its supervisor kill switch and allowed-root environment untouched. */
|
||
cbm_index_supervisor_mark_host();
|
||
cbm_unsetenv("CBM_INDEX_SUPERVISOR");
|
||
cbm_unsetenv("CBM_ALLOWED_ROOT");
|
||
cbm_setenv("CBM_INDEX_MAX_RESTARTS", "1", 1);
|
||
cbm_setenv("CBM_INDEX_WORKER_TIMEOUT_S", "30", 1);
|
||
|
||
char session_repo[CBM_SZ_4K];
|
||
char decoy_repo[CBM_SZ_4K];
|
||
snprintf(session_repo, sizeof(session_repo), "%s/repo", session_root);
|
||
snprintf(decoy_repo, sizeof(decoy_repo), "%s/repo", decoy_cwd);
|
||
char *session_project = cbm_project_name_from_path(session_repo);
|
||
char *decoy_project = cbm_project_name_from_path(decoy_repo);
|
||
|
||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||
int code = IDXCANON_OK;
|
||
if (!srv) {
|
||
code = IDXCANON_NO_SERVER;
|
||
} else if (!cbm_mcp_server_set_session_context(srv, session_root, session_root)) {
|
||
code = IDXCANON_CONTEXT_FAILED;
|
||
}
|
||
|
||
int spawns_before = cbm_index_supervisor_spawn_count();
|
||
char *resp = code == IDXCANON_OK
|
||
? cbm_mcp_handle_tool(srv, "index_repository",
|
||
"{\"repo_path\":\"repo\",\"mode\":\"fast\"}")
|
||
: NULL;
|
||
int spawns_after = cbm_index_supervisor_spawn_count();
|
||
if (code == IDXCANON_OK && spawns_after == spawns_before) {
|
||
code = IDXCANON_NO_SPAWN;
|
||
} else if (code == IDXCANON_OK && !resp) {
|
||
code = IDXCANON_NO_RESULT;
|
||
} else if (code == IDXCANON_OK &&
|
||
!response_contains_json_fragment(resp, "\"status\":\"indexed\"")) {
|
||
code = IDXCANON_NOT_INDEXED;
|
||
}
|
||
|
||
if (code == IDXCANON_OK) {
|
||
char expected[CBM_SZ_4K];
|
||
snprintf(expected, sizeof(expected), "\"project\":\"%s\"",
|
||
session_project ? session_project : "");
|
||
if (!session_project || !response_contains_json_fragment(resp, expected)) {
|
||
code = IDXCANON_WRONG_PROJECT;
|
||
}
|
||
}
|
||
free(resp);
|
||
|
||
/* A raw "repo" handoff is interpreted relative to decoy_cwd by the worker
|
||
* and creates this project DB. Its absence proves the original JSON did not
|
||
* substitute a different path after the parent validated session_repo. */
|
||
if (code == IDXCANON_OK) {
|
||
const char *cache = getenv("CBM_CACHE_DIR");
|
||
char decoy_db[CBM_SZ_4K];
|
||
snprintf(decoy_db, sizeof(decoy_db), "%s/%s.db", cache ? cache : "",
|
||
decoy_project ? decoy_project : "");
|
||
if (!cache || !decoy_project || cbm_file_size(decoy_db) >= 0) {
|
||
code = IDXCANON_DECOY_INDEXED;
|
||
}
|
||
}
|
||
|
||
if (code == IDXCANON_OK) {
|
||
char query[CBM_SZ_4K];
|
||
snprintf(query, sizeof(query),
|
||
"{\"project\":\"%s\",\"name_pattern\":\"canonical_target_fn\","
|
||
"\"label\":\"Function\"}",
|
||
session_project ? session_project : "");
|
||
char *search = cbm_mcp_handle_tool(srv, "search_graph", query);
|
||
if (!session_project || !search || !strstr(search, "canonical_target_fn")) {
|
||
code = IDXCANON_TARGET_MISSING;
|
||
}
|
||
free(search);
|
||
}
|
||
|
||
cbm_mcp_server_free(srv);
|
||
free(session_project);
|
||
free(decoy_project);
|
||
if (cbm_chdir(saved_cwd) != 0 && code == IDXCANON_OK) {
|
||
code = IDXCANON_CWD_RESTORE_FAILED;
|
||
}
|
||
return code;
|
||
}
|
||
#endif
|
||
|
||
TEST(index_repository_supervisor_uses_canonical_session_path) {
|
||
#ifdef _WIN32
|
||
SKIP_PLATFORM("supervisor-host guard needs fork isolation (POSIX-only)");
|
||
#else
|
||
char session_root[512];
|
||
char decoy_cwd[512];
|
||
char cache[512];
|
||
snprintf(session_root, sizeof(session_root), "%s/cbm_canonical_session_XXXXXX", cbm_tmpdir());
|
||
snprintf(decoy_cwd, sizeof(decoy_cwd), "%s/cbm_canonical_decoy_XXXXXX", cbm_tmpdir());
|
||
snprintf(cache, sizeof(cache), "%s/cbm_canonical_cache_XXXXXX", cbm_tmpdir());
|
||
if (!cbm_mkdtemp(session_root) || !cbm_mkdtemp(decoy_cwd) || !cbm_mkdtemp(cache)) {
|
||
th_rmtree(session_root);
|
||
th_rmtree(decoy_cwd);
|
||
th_rmtree(cache);
|
||
FAIL("cbm_mkdtemp failed");
|
||
}
|
||
|
||
char session_source[CBM_SZ_4K];
|
||
char decoy_source[CBM_SZ_4K];
|
||
snprintf(session_source, sizeof(session_source), "%s/repo/main.py", session_root);
|
||
snprintf(decoy_source, sizeof(decoy_source), "%s/repo/main.py", decoy_cwd);
|
||
ASSERT_EQ(th_write_file(session_source, "def canonical_target_fn():\n return 1\n"), 0);
|
||
ASSERT_EQ(th_write_file(decoy_source, "def decoy_fn():\n return 2\n"), 0);
|
||
|
||
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);
|
||
|
||
int code = -1;
|
||
bool signalled = false;
|
||
int sig = 0;
|
||
fflush(NULL);
|
||
pid_t pid = fork();
|
||
if (pid == 0) {
|
||
alarm(60);
|
||
_exit(idxcanon_supervised_session_path_check(session_root, decoy_cwd));
|
||
}
|
||
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 session_repo[CBM_SZ_4K];
|
||
char decoy_repo[CBM_SZ_4K];
|
||
snprintf(session_repo, sizeof(session_repo), "%s/repo", session_root);
|
||
snprintf(decoy_repo, sizeof(decoy_repo), "%s/repo", decoy_cwd);
|
||
char *session_project = cbm_project_name_from_path(session_repo);
|
||
char *decoy_project = cbm_project_name_from_path(decoy_repo);
|
||
cleanup_project_db(cache, session_project);
|
||
cleanup_project_db(cache, decoy_project);
|
||
free(session_project);
|
||
free(decoy_project);
|
||
restore_cache_dir(saved_cache_copy);
|
||
free(saved_cache_copy);
|
||
th_rmtree(session_root);
|
||
th_rmtree(decoy_cwd);
|
||
th_rmtree(cache);
|
||
|
||
if (signalled) {
|
||
printf(" child killed by signal %d (alarm => worker hang)\n", sig);
|
||
} else if (code != IDXCANON_OK) {
|
||
printf(" child exit code %d (75=no spawn, 77=not indexed, 78=wrong project, "
|
||
"79=decoy indexed, 80=target missing)\n",
|
||
code);
|
||
}
|
||
ASSERT_FALSE(signalled);
|
||
ASSERT_EQ(code, IDXCANON_OK);
|
||
PASS();
|
||
#endif
|
||
}
|
||
|
||
/* ══════════════════════════════════════════════════════════════════
|
||
* SUITE
|
||
* ══════════════════════════════════════════════════════════════════ */
|
||
|
||
SUITE(mcp) {
|
||
RUN_TEST(mcp_path_within_root_rejects_escape);
|
||
RUN_TEST(detect_changes_rejects_option_like_base_branch);
|
||
RUN_TEST(detect_changes_rejects_windows_cmd_metacharacters_in_base_branch);
|
||
RUN_TEST(detect_changes_rejects_windows_cmd_metacharacters_in_project_root);
|
||
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(tree_cell_sanitizes_control_and_invalid_utf8);
|
||
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_tools_have_behavior_annotations);
|
||
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_wraps_plain_text_as_structured_content);
|
||
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_analysis_profile_filters_and_rejects_mutators);
|
||
RUN_TEST(server_handle_scout_profile_exposes_only_the_fast_tier);
|
||
RUN_TEST(analysis_profile_arguments_fail_closed_and_disable_http);
|
||
RUN_TEST(hook_windows_path_containment_is_case_insensitive_and_segment_safe);
|
||
RUN_TEST(server_handle_prompts_list_workflows);
|
||
RUN_TEST(server_handle_prompts_get_workflows);
|
||
RUN_TEST(server_handle_prompts_get_validates_arguments);
|
||
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_trace_totals_respect_test_filter);
|
||
RUN_TEST(tool_get_architecture_cycles_detects_scc);
|
||
RUN_TEST(tool_get_code_snippet_clips_whole_file_node);
|
||
RUN_TEST(tool_search_graph_includes_node_properties);
|
||
RUN_TEST(tool_search_graph_toon_never_leaks_internal_fields);
|
||
RUN_TEST(tool_lean_defaults_schema_and_status);
|
||
RUN_TEST(tool_output_regression_gate);
|
||
RUN_TEST(tool_output_byte_budgets);
|
||
RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552);
|
||
RUN_TEST(mcp_resource_discovery_methods_return_empty_lists);
|
||
RUN_TEST(tool_query_graph_basic);
|
||
RUN_TEST(tool_index_status_no_project);
|
||
RUN_TEST(tool_check_index_coverage_finds_path_beyond_status_cap);
|
||
RUN_TEST(tool_check_index_coverage_reports_paths_scopes_and_ranges);
|
||
RUN_TEST(tool_check_index_coverage_rejects_stale_generation);
|
||
RUN_TEST(tool_check_index_coverage_requires_source_when_file_metadata_changed);
|
||
RUN_TEST(tool_check_index_coverage_surfaces_lookup_errors);
|
||
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_union_records_min_hop_across_seeds);
|
||
RUN_TEST(tool_trace_pagination_exactly_once);
|
||
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_relative_path_uses_explicit_session_root);
|
||
RUN_TEST(index_repository_supervisor_uses_canonical_session_path);
|
||
RUN_TEST(index_repository_cli_name_override_issue823);
|
||
RUN_TEST(index_supervisor_unsafe_clean_is_never_fallback_or_recovery);
|
||
RUN_TEST(index_supervisor_gate_requires_marked_host_issue845);
|
||
RUN_TEST(index_supervisor_start_failure_is_fail_closed_in_real_host);
|
||
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_detect_changes_contained_commands_clean_up_error_and_success);
|
||
RUN_TEST(tool_ingest_traces_basic);
|
||
RUN_TEST(tool_ingest_traces_empty);
|
||
|
||
/* Query store generation freshness */
|
||
RUN_TEST(query_store_reopens_after_database_replacement);
|
||
/* 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);
|
||
RUN_TEST(tool_list_projects_ignores_missed_shadow_issue1044);
|
||
|
||
/* 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);
|
||
}
|
||
|
||
/* Kept separate so daemon-coordination regressions can be iterated without
|
||
* running the much larger MCP behavior suite. */
|
||
SUITE(mcp_mutation_guard) {
|
||
RUN_TEST(tool_delete_project_mutation_guard_blocks_then_releases);
|
||
RUN_TEST(tool_index_repository_mutation_guard_blocks_before_local_worker);
|
||
RUN_TEST(tool_manage_adr_mutation_guard_balances_success);
|
||
RUN_TEST(tool_manage_adr_mutation_guard_releases_on_missing_store);
|
||
RUN_TEST(tool_raw_dispatch_cancel_is_scoped_non_mutating_and_next_request_clean);
|
||
RUN_TEST(tool_outer_request_scope_preserves_predispatch_cancel);
|
||
RUN_TEST(tool_index_repository_early_raw_cancel_survives_index_entry);
|
||
RUN_TEST(tool_cross_repo_mutation_guard_sorts_dedupes_and_unwinds);
|
||
RUN_TEST(tool_cross_repo_mutation_guard_casefolds_aliases_and_order);
|
||
RUN_TEST(tool_cross_repo_rejects_wildcard_mixed_with_named_targets);
|
||
RUN_TEST(tool_cross_repo_checks_cancellation_after_acquiring_leases);
|
||
RUN_TEST(tool_cross_repo_missing_inputs_fail_without_creating_ghost_databases);
|
||
RUN_TEST(tool_cross_repo_dedupes_targets_before_scanning_and_counting);
|
||
RUN_TEST(tool_cross_repo_honors_source_name_override);
|
||
RUN_TEST(tool_corrupt_store_cleanup_guard_is_balanced_and_not_nested);
|
||
RUN_TEST(tool_corrupt_store_cleanup_guard_denial_preserves_db_and_wal);
|
||
RUN_TEST(tool_corrupt_store_cleanup_rechecks_generation_after_guard_wait);
|
||
RUN_TEST(tool_corrupt_store_cleanup_preserves_existing_backup_and_uses_unique_name);
|
||
RUN_TEST(tool_corrupt_store_cleanup_publish_failure_preserves_db_and_wal);
|
||
RUN_TEST(tool_corrupt_store_cleanup_publishes_complete_wal_snapshot_before_delete);
|
||
}
|