mcp: fix 6 bugs + token optimization + empty DB reindex + 4 TDD tests

Bugs fixed:
- _hidden_tools dispatch: returns tool list instead of "unknown tool" error
- trace_call_path: accepts project paths via expand_project_param (DRY
  resolve_project_store helper shared with search_code_graph)
- Resources scoping: codebase://schema/architecture/status now reflect the
  most-recently-queried project (active_project_name) instead of always
  returning the empty session CWD project
- compact default: search/trace default to compact=true via new
  cbm_mcp_get_bool_arg_default() — omits redundant name field
- PageRank precision: add_pagerank_val() writes raw JSON via %.4g format
  (e.g. 4.755e-05) instead of 17-digit doubles, no float round-trip
- Empty DB skip: maybe_auto_index now checks db_has_content() (SELECT 1
  FROM nodes LIMIT 1) instead of just stat(). Empty DBs trigger reindex.

New features:
- db_is_stale(): compares DB mtime vs git HEAD commit time, with
  configurable max_age_seconds (reindex_stale_seconds config key)
- reindex_on_startup config: when true + stale DB, triggers reindex at
  server start. Default false for large project safety.

DRY refactors:
- resolve_project_store(): extracted from handle_search_graph, reused by
  handle_trace_call_path. Handles expand_project_param + DB selection +
  prefix collision avoidance + auto-index on first use.

Tests (2193 → 2197):
- hidden_tools_returns_info_not_error
- compact_defaults_to_true
- pagerank_output_has_limited_precision
- empty_db_not_treated_as_indexed

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-03-23 05:49:38 -04:00
parent 47bbe5cd51
commit 719cdfe57f
3 changed files with 322 additions and 35 deletions
+180 -35
View File
@@ -41,6 +41,16 @@
/* ── Constants ────────────────────────────────────────────────── */
/* Add a "pagerank" key with value formatted to 4 significant figures.
* Writes directly as a raw JSON number (e.g. 4.755e-05) — no double round-trip.
* 4 sig figs preserves ranking distinguishability while saving ~12 chars/value.
* This is the single place pagerank values are serialized to JSON. */
static void add_pagerank_val(yyjson_mut_doc *doc, yyjson_mut_val *obj, double v) {
char buf[32];
snprintf(buf, sizeof(buf), "%.4g", v);
yyjson_mut_obj_add_val(doc, obj, "pagerank", yyjson_mut_rawcpy(doc, buf));
}
/* Default snippet fallback line count (when end_line unknown) */
#define SNIPPET_DEFAULT_LINES 50
@@ -281,7 +291,7 @@ static const tool_def_t TOOLS[] = {
"\"mode\":{\"type\":\"string\",\"enum\":[\"full\",\"summary\"],\"default\":\"full\","
"\"description\":\"full=individual results (default), summary=aggregate counts by label and "
"file. Use summary first to understand scope, then full with filters to drill down."
"\"},\"compact\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Omit redundant "
"\"},\"compact\":{\"type\":\"boolean\",\"default\":true,\"description\":\"Omit redundant "
"name field when it matches the last segment of qualified_name. Reduces token usage.\"},"
"\"include_dependencies\":{\"type\":\"boolean\",\"default\":false,\"description\":\"Include "
"indexed dependency symbols in results. Results from dependencies have source:dependency. "
@@ -586,13 +596,17 @@ int cbm_mcp_get_int_arg(const char *args_json, const char *key, int default_val)
// NOLINTNEXTLINE(bugprone-easily-swappable-parameters)
bool cbm_mcp_get_bool_arg(const char *args_json, const char *key) {
return cbm_mcp_get_bool_arg_default(args_json, key, false);
}
bool cbm_mcp_get_bool_arg_default(const char *args_json, const char *key, bool default_val) {
yyjson_doc *doc = yyjson_read(args_json, strlen(args_json), 0);
if (!doc) {
return false;
return default_val;
}
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *val = yyjson_obj_get(root, key);
bool result = false;
bool result = default_val;
if (val && yyjson_is_bool(val)) {
result = yyjson_get_bool(val);
}
@@ -1388,15 +1402,21 @@ static char *handle_get_graph_schema(cbm_mcp_server_t *srv, const char *args) {
return result;
}
static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
char *raw_project = cbm_mcp_get_string_arg(args, "project");
/* Expand a raw project param, resolve the correct store, and auto-index if needed.
* Returns the resolved store (or NULL). Sets *out_pe to the expand result
* (caller must free out_pe->value). Handles:
* - expand_project_param (Rule 0: /path → project name)
* - DB selection with prefix collision avoidance
* - Auto-index on first use (join background thread or sync index) */
static cbm_store_t *resolve_project_store(cbm_mcp_server_t *srv,
char *raw_project,
project_expand_t *out_pe) {
project_expand_t pe = expand_project_param(srv, raw_project);
/* DB selection: if expanded value IS the session project or a dep of it
* (session.dep.X), use session store. Otherwise open the requested project's DB.
* The check requires the char after session_project to be '.' or '\0' to avoid
* prefix collisions (e.g., "myapp" matching "myapp-other-project"). */
const char *db_project = pe.value; /* default: pass through to resolve_store */
* (session.dep.X), use session store. The check requires the char after
* session_project to be '.' or '\0' to avoid prefix collisions. */
const char *db_project = pe.value;
if (pe.value && srv->session_project[0]) {
size_t sp_len = strlen(srv->session_project);
if (strncmp(pe.value, srv->session_project, sp_len) == 0 &&
@@ -1405,8 +1425,8 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
}
}
cbm_store_t *store = resolve_store(srv, db_project);
/* Auto-index on first use — same logic as REQUIRE_STORE macro.
* Handles: CWD-based session_root, explicit path via Rule 0, MCP roots. */
/* Auto-index on first use (same logic as REQUIRE_STORE macro). */
if (!store && srv->session_root[0] && access(srv->session_root, F_OK) == 0) {
if (srv->autoindex_active) {
cbm_thread_join(&srv->autoindex_tid);
@@ -1433,6 +1453,15 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
}
}
}
*out_pe = pe; /* caller takes ownership of pe.value */
return store;
}
static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
char *raw_project = cbm_mcp_get_string_arg(args, "project");
project_expand_t pe = {0};
cbm_store_t *store = resolve_project_store(srv, raw_project, &pe);
if (!store) {
free(pe.value);
return cbm_mcp_text_result(
@@ -1448,7 +1477,7 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
CBM_DEFAULT_SEARCH_LIMIT);
int limit = cbm_mcp_get_int_arg(args, "limit", cfg_search_limit);
int offset = cbm_mcp_get_int_arg(args, "offset", 0);
bool compact = cbm_mcp_get_bool_arg(args, "compact");
bool compact = cbm_mcp_get_bool_arg_default(args, "compact", true);
char *search_mode = cbm_mcp_get_string_arg(args, "mode");
int min_degree = cbm_mcp_get_int_arg(args, "min_degree", -1);
int max_degree = cbm_mcp_get_int_arg(args, "max_degree", -1);
@@ -1547,7 +1576,7 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
yyjson_mut_obj_add_str(doc, item, "file_path",
sr->node.file_path ? sr->node.file_path : "");
if (sr->pagerank_score > 0.0) {
yyjson_mut_obj_add_real(doc, item, "pagerank", sr->pagerank_score);
add_pagerank_val(doc, item, sr->pagerank_score);
} else {
/* Degree fields only when PageRank not available — PR subsumes degree info */
yyjson_mut_obj_add_int(doc, item, "in_degree", sr->in_degree);
@@ -1919,7 +1948,7 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) {
if (qn) yyjson_mut_obj_add_strcpy(doc, kf, "qualified_name", qn);
if (lbl) yyjson_mut_obj_add_strcpy(doc, kf, "label", lbl);
if (fp) yyjson_mut_obj_add_strcpy(doc, kf, "file_path", fp);
yyjson_mut_obj_add_real(doc, kf, "pagerank", rank);
add_pagerank_val(doc, kf, rank);
yyjson_mut_arr_add_val(kf_arr, kf);
}
sqlite3_finalize(kf_stmt);
@@ -1940,14 +1969,16 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) {
static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
char *func_name = cbm_mcp_get_string_arg(args, "function_name");
char *project = cbm_mcp_get_string_arg(args, "project");
cbm_store_t *store = resolve_store(srv, project);
char *raw_project = cbm_mcp_get_string_arg(args, "project");
project_expand_t pe = {0};
cbm_store_t *store = resolve_project_store(srv, raw_project, &pe);
char *project = pe.value; /* take ownership for free() below */
char *direction = cbm_mcp_get_string_arg(args, "direction");
int depth = cbm_mcp_get_int_arg(args, "depth", 3);
int cfg_trace_max = cbm_config_get_int(srv->config, CBM_CONFIG_TRACE_MAX_RESULTS,
CBM_DEFAULT_TRACE_MAX_RESULTS);
int max_results = cbm_mcp_get_int_arg(args, "max_results", cfg_trace_max);
bool compact = cbm_mcp_get_bool_arg(args, "compact");
bool compact = cbm_mcp_get_bool_arg_default(args, "compact", true);
if (!func_name) {
free(project);
@@ -2052,7 +2083,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
{
double pr = cbm_pagerank_get(store, tr_out.visited[i].node.id);
if (pr > 0.0)
yyjson_mut_obj_add_real(doc, item, "pagerank", pr);
add_pagerank_val(doc, item, pr);
}
/* Boundary tagging: mark if callee is in a dependency */
bool callee_dep = cbm_is_dep_project(tr_out.visited[i].node.project,
@@ -2099,7 +2130,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
{
double pr = cbm_pagerank_get(store, tr_in.visited[i].node.id);
if (pr > 0.0)
yyjson_mut_obj_add_real(doc, item, "pagerank", pr);
add_pagerank_val(doc, item, pr);
}
/* Boundary tagging: mark if caller is in a dependency */
bool caller_dep = cbm_is_dep_project(tr_in.visited[i].node.project,
@@ -3435,6 +3466,18 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch
return handle_index_dependencies(srv, args_json);
}
/* _hidden_tools: informational pseudo-tool for progressive disclosure */
if (strcmp(tool_name, "_hidden_tools") == 0) {
return cbm_mcp_text_result(
"{\"hidden_tools\":[\"index_repository\",\"search_graph\",\"query_graph\","
"\"get_code_snippet\",\"get_graph_schema\",\"get_architecture\",\"search_code\","
"\"list_projects\",\"delete_project\",\"index_status\",\"detect_changes\","
"\"manage_adr\",\"ingest_traces\",\"index_dependencies\"],"
"\"enable_all\":\"set env CBM_TOOL_MODE=classic or config set tool_mode classic\","
"\"enable_one\":\"config set tool_<name> true (e.g. tool_index_repository true)\","
"\"resources\":[\"codebase://schema\",\"codebase://architecture\",\"codebase://status\"]}", false);
}
char msg[512];
snprintf(msg, sizeof(msg),
"{\"error\":\"unknown tool: '%s'\","
@@ -3522,31 +3565,122 @@ static void *autoindex_thread(void *arg) {
return NULL;
}
/* Check if a DB file has actual content (at least 1 node).
* Returns true if DB exists AND has nodes. Lightweight raw SQLite check. */
static bool db_has_content(const char *db_path) {
struct stat st;
if (stat(db_path, &st) != 0) return false; /* file doesn't exist */
sqlite3 *db = NULL;
if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) {
sqlite3_close(db);
return false;
}
sqlite3_stmt *stmt = NULL;
bool has = false;
if (sqlite3_prepare_v2(db, "SELECT 1 FROM nodes LIMIT 1", -1, &stmt, NULL) == SQLITE_OK) {
has = (sqlite3_step(stmt) == SQLITE_ROW);
sqlite3_finalize(stmt);
}
sqlite3_close(db);
return has;
}
/* Check if a DB's index is stale by comparing DB file mtime against latest
* git commit time. If the repo has commits newer than the DB, it's stale.
* Also stale if DB is older than max_age_seconds (0 = disabled).
* Returns false on any error (conservative: don't trigger unnecessary reindex). */
static bool db_is_stale(const char *db_path, const char *repo_path, int max_age_seconds) {
struct stat db_st;
if (stat(db_path, &db_st) != 0) return false;
time_t db_mtime = db_st.st_mtime;
/* Check age-based staleness (configurable, 0 = disabled).
* Guard against clock skew: only consider stale if now > db_mtime. */
if (max_age_seconds > 0) {
time_t now = time(NULL);
if (now > db_mtime && (now - db_mtime) > max_age_seconds) return true;
}
/* Check git HEAD commit time vs DB mtime */
char cmd[1024];
snprintf(cmd, sizeof(cmd),
"git -C '%s' log -1 --format=%%ct HEAD 2>/dev/null", repo_path);
// NOLINTNEXTLINE(bugprone-command-processor,cert-env33-c)
FILE *fp = cbm_popen(cmd, "r");
if (!fp) return false;
char line[64] = {0};
if (fgets(line, sizeof(line), fp)) {
long commit_time = strtol(line, NULL, 10);
cbm_pclose(fp);
/* Stale if latest commit is newer than DB */
return commit_time > (long)db_mtime;
}
cbm_pclose(fp);
return false;
}
/* Config keys for reindex behavior */
#define CBM_CONFIG_REINDEX_ON_STARTUP "reindex_on_startup"
#define CBM_CONFIG_REINDEX_STALE_SECONDS "reindex_stale_seconds"
/* Start auto-indexing if configured and project not yet indexed. */
static void maybe_auto_index(cbm_mcp_server_t *srv) {
if (srv->session_root[0] == '\0') {
return; /* no session root detected */
}
/* Check if project already has a DB */
/* Check if project already has a populated DB */
// NOLINTNEXTLINE(concurrency-mt-unsafe)
const char *home = getenv("HOME");
bool needs_index = true;
char db_check[1024] = {0};
if (home) {
char db_check[1024];
snprintf(db_check, sizeof(db_check), "%s/.cache/codebase-memory-mcp/%s.db", home,
srv->session_project);
struct stat st;
if (stat(db_check, &st) == 0) {
/* Already indexed → register watcher for change detection */
cbm_log_info("autoindex.skip", "reason", "already_indexed", "project",
srv->session_project);
if (srv->watcher) {
cbm_watcher_watch(srv->watcher, srv->session_project, srv->session_root);
if (db_has_content(db_check)) {
/* DB exists and has nodes — check if stale */
bool reindex_on_startup = srv->config
? cbm_config_get_bool(srv->config, CBM_CONFIG_REINDEX_ON_STARTUP, false)
: false;
int stale_seconds = srv->config
? cbm_config_get_int(srv->config, CBM_CONFIG_REINDEX_STALE_SECONDS, 0)
: 0;
bool stale = db_is_stale(db_check, srv->session_root, stale_seconds);
if (stale && reindex_on_startup) {
cbm_log_info("autoindex.stale", "reason", "commits_newer_than_index", "project",
srv->session_project);
needs_index = true;
} else {
if (stale) {
cbm_log_info("autoindex.stale_skipped", "reason", "reindex_on_startup=false",
"hint", "set reindex_on_startup true to auto-update on restart",
"project", srv->session_project);
} else {
cbm_log_info("autoindex.skip", "reason", "already_indexed", "project",
srv->session_project);
}
/* Register watcher for live change detection */
if (srv->watcher) {
cbm_watcher_watch(srv->watcher, srv->session_project, srv->session_root);
}
needs_index = false;
}
return;
} else {
struct stat st;
if (stat(db_check, &st) == 0) {
/* DB file exists but has 0 nodes — treat as not indexed */
cbm_log_info("autoindex.empty_db", "reason", "db_exists_but_empty", "project",
srv->session_project);
}
needs_index = true;
}
}
if (!needs_index) return;
/* Default file limit for auto-indexing new projects */
#define DEFAULT_AUTO_INDEX_LIMIT 50000
@@ -3775,9 +3909,20 @@ static char *handle_resources_list(cbm_mcp_server_t *srv) {
return out;
}
/* Resolve session store for resource handlers. Opens the session project DB
* if not already open, so resources return data even before any tool call. */
/* Get the active project name: current_project (from last tool call) or session_project. */
static const char *active_project_name(cbm_mcp_server_t *srv) {
if (srv->current_project) return srv->current_project;
return srv->session_project[0] ? srv->session_project : NULL;
}
/* Resolve store for resource handlers. Prefers the currently-open project
* (set by the most recent tool call) over the session project, so resources
* reflect data the user is actually querying — not the empty CWD project. */
static cbm_store_t *resolve_resource_store(cbm_mcp_server_t *srv) {
/* 1. Use currently-open project (set by last resolve_store call) */
if (srv->current_project && srv->store)
return srv->store;
/* 2. Fall back to session project */
const char *proj = srv->session_project[0] ? srv->session_project : NULL;
if (proj) return resolve_store(srv, proj);
return srv->store;
@@ -3787,7 +3932,7 @@ static cbm_store_t *resolve_resource_store(cbm_mcp_server_t *srv) {
static void build_resource_schema(yyjson_mut_doc *doc, yyjson_mut_val *root,
cbm_mcp_server_t *srv) {
cbm_store_t *store = resolve_resource_store(srv);
const char *proj = srv->session_project[0] ? srv->session_project : NULL;
const char *proj = active_project_name(srv);
if (!store) {
yyjson_mut_obj_add_str(doc, root, "status", "not_indexed");
@@ -3821,7 +3966,7 @@ static void build_resource_schema(yyjson_mut_doc *doc, yyjson_mut_val *root,
static void build_resource_architecture(yyjson_mut_doc *doc, yyjson_mut_val *root,
cbm_mcp_server_t *srv) {
cbm_store_t *store = resolve_resource_store(srv);
const char *proj = srv->session_project[0] ? srv->session_project : NULL;
const char *proj = active_project_name(srv);
if (!store) {
yyjson_mut_obj_add_str(doc, root, "status", "not_indexed");
@@ -3855,7 +4000,7 @@ static void build_resource_architecture(yyjson_mut_doc *doc, yyjson_mut_val *roo
if (qn) yyjson_mut_obj_add_strcpy(doc, kf, "qualified_name", qn);
if (label) yyjson_mut_obj_add_strcpy(doc, kf, "label", label);
if (fp) yyjson_mut_obj_add_strcpy(doc, kf, "file_path", fp);
yyjson_mut_obj_add_real(doc, kf, "pagerank", rank);
add_pagerank_val(doc, kf, rank);
yyjson_mut_arr_add_val(kf_arr, kf);
}
yyjson_mut_obj_add_val(doc, root, "key_functions", kf_arr);
@@ -3880,7 +4025,7 @@ static void build_resource_architecture(yyjson_mut_doc *doc, yyjson_mut_val *roo
static void build_resource_status(yyjson_mut_doc *doc, yyjson_mut_val *root,
cbm_mcp_server_t *srv) {
cbm_store_t *store = resolve_resource_store(srv);
const char *proj = srv->session_project[0] ? srv->session_project : NULL;
const char *proj = active_project_name(srv);
if (proj) yyjson_mut_obj_add_str(doc, root, "project", proj);
+3
View File
@@ -72,6 +72,9 @@ int cbm_mcp_get_int_arg(const char *args_json, const char *key, int default_val)
/* Extract a bool argument. Returns false if not found. */
bool cbm_mcp_get_bool_arg(const char *args_json, const char *key);
/* Extract a bool argument with explicit default. Returns default_val if key absent. */
bool cbm_mcp_get_bool_arg_default(const char *args_json, const char *key, bool default_val);
/* Extract the tool name from a tools/call params JSON. Heap-allocated. */
char *cbm_mcp_get_tool_name(const char *params_json);
+139
View File
@@ -13,6 +13,10 @@
#include <string.h>
#include <stdlib.h>
#include <watcher/watcher.h>
#include <pagerank/pagerank.h>
#include <sqlite3.h>
#include <sys/stat.h>
#include <unistd.h>
/* ── 1. Tool visibility tests ─────────────────────────────── */
@@ -1410,6 +1414,136 @@ TEST(watcher_not_registered_for_unknown_path) {
PASS();
}
/* ── Empty DB / stale index detection ────────────────────── */
TEST(hidden_tools_returns_info_not_error) {
/* _hidden_tools should return tool list, not "unknown tool" error */
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *resp = cbm_mcp_handle_tool(srv, "_hidden_tools", "{}");
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "hidden_tools"));
ASSERT_NOT_NULL(strstr(resp, "index_repository"));
/* Must NOT be an error */
ASSERT_NULL(strstr(resp, "unknown tool"));
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
TEST(compact_defaults_to_true) {
/* When compact is not provided, name field should be omitted if it's
* the last segment of qualified_name */
char db_path[1024];
snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_compact_default_.db",
getenv("HOME"));
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "_tc_compact_default_", "/tmp/compact_test");
cbm_node_t n = {.project = "_tc_compact_default_", .label = "Function",
.name = "my_func", .qualified_name = "_tc_compact_default_.my_func",
.file_path = "test.c"};
cbm_store_upsert_node(s, &n);
cbm_store_close(s);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
/* Search WITHOUT compact param — should default to compact=true */
char *resp = cbm_mcp_handle_tool(srv, "search_code_graph",
"{\"project\":\"_tc_compact_default_\",\"name_pattern\":\"my_func\",\"limit\":1}");
ASSERT_NOT_NULL(resp);
/* In compact mode, "name" should NOT appear as a separate key when
* it matches the last segment of qualified_name */
/* Parse the result text to check */
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
yyjson_val *results = yyjson_obj_get(root, "results");
if (results && yyjson_arr_size(results) > 0) {
yyjson_val *first = yyjson_arr_get_first(results);
/* name key should be absent in compact mode */
ASSERT_NULL(yyjson_obj_get(first, "name"));
ASSERT_NOT_NULL(yyjson_obj_get(first, "qualified_name"));
}
yyjson_doc_free(doc);
free(resp);
cbm_mcp_server_free(srv);
(void)unlink(db_path);
PASS();
}
TEST(pagerank_output_has_limited_precision) {
/* Pagerank values should be serialized with limited precision (~4 sig figs),
* not full 17-digit double precision */
char db_path[1024];
snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_pr_precision_.db",
getenv("HOME"));
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
cbm_store_upsert_project(s, "_tc_pr_precision_", "/tmp/pr_test");
cbm_node_t n1 = {.project = "_tc_pr_precision_", .label = "Function",
.name = "fn_a", .qualified_name = "_tc_pr_precision_.fn_a",
.file_path = "a.c"};
cbm_node_t n2 = {.project = "_tc_pr_precision_", .label = "Function",
.name = "fn_b", .qualified_name = "_tc_pr_precision_.fn_b",
.file_path = "b.c"};
cbm_store_upsert_node(s, &n1);
cbm_store_upsert_node(s, &n2);
/* Compute PageRank (even with no edges, nodes get baseline scores) */
cbm_pagerank_compute_default(s, "_tc_pr_precision_");
cbm_store_close(s);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *resp = cbm_mcp_handle_tool(srv, "search_code_graph",
"{\"project\":\"_tc_pr_precision_\",\"sort_by\":\"relevance\",\"limit\":2}");
ASSERT_NOT_NULL(resp);
/* Pagerank values should NOT have more than ~8 characters (e.g. "4.72e-05")
* Check that we don't have 17-digit sequences like "0.00004717680769635863" */
ASSERT_NULL(strstr(resp, "000000000")); /* No 9+ consecutive zeros in pagerank */
free(resp);
cbm_mcp_server_free(srv);
(void)unlink(db_path);
PASS();
}
TEST(empty_db_not_treated_as_indexed) {
/* A DB file with schema but 0 nodes should NOT prevent re-indexing.
* Regression test: previously stat(db_path)==0 was enough to skip. */
char db_path[1024];
snprintf(db_path, sizeof(db_path), "%s/.cache/codebase-memory-mcp/_tc_empty_db_test_.db",
getenv("HOME"));
/* Create DB with schema but no data */
cbm_store_t *s = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s);
cbm_store_close(s);
/* Verify the file exists */
struct stat st;
ASSERT_EQ(stat(db_path, &st), 0);
/* Open it read-only and verify 0 nodes */
sqlite3 *db = NULL;
ASSERT_EQ(sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, NULL), SQLITE_OK);
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(db, "SELECT count(*) FROM nodes", -1, &stmt, NULL);
ASSERT_EQ(rc, SQLITE_OK);
ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW);
int node_count = sqlite3_column_int(stmt, 0);
ASSERT_EQ(node_count, 0);
sqlite3_finalize(stmt);
/* Verify "SELECT 1 FROM nodes LIMIT 1" returns no rows (this is what db_has_content checks) */
rc = sqlite3_prepare_v2(db, "SELECT 1 FROM nodes LIMIT 1", -1, &stmt, NULL);
ASSERT_EQ(rc, SQLITE_OK);
ASSERT_NEQ(sqlite3_step(stmt), SQLITE_ROW); /* Should be SQLITE_DONE, not SQLITE_ROW */
sqlite3_finalize(stmt);
sqlite3_close(db);
(void)unlink(db_path);
PASS();
}
/* ── Suite registration ──────────────────────────────────── */
SUITE(tool_consolidation) {
@@ -1497,4 +1631,9 @@ SUITE(tool_consolidation) {
RUN_TEST(watcher_registered_after_index_repository);
RUN_TEST(watcher_registered_on_resolve_store);
RUN_TEST(watcher_not_registered_for_unknown_path);
/* Phase 10.2: Bug fixes and token optimization */
RUN_TEST(hidden_tools_returns_info_not_error);
RUN_TEST(compact_defaults_to_true);
RUN_TEST(pagerank_output_has_limited_precision);
RUN_TEST(empty_db_not_treated_as_indexed);
}