mcp: fix 3 dep search bugs found by binary dogfooding

Bug 1: resolve_store opens wrong DB for dep projects
  Root cause: resolve_store("myapp.dep.pandas") opens myapp.dep.pandas.db
  instead of myapp.db where deps actually live (same-db design).
  Fix: parent_project_for_db() strips .dep.* suffix to find parent DB.

Bug 2: store.c prefix match was actually exact match
  Root cause: cbm_store_search with params->project set (non-exact mode)
  used "n.project = ?" — exact match, not prefix. Deps invisible.
  Fix: SQL now uses "(n.project = ? OR n.project LIKE ?||'.%')" for
  prefix mode, so search_graph(project="myapp") returns project + deps.

Bug 3: cbm_is_dep_project fails for cross-project deps
  Root cause: Early return when session_project doesn't match prefix.
  "otherapp.dep.pandas" with session "myapp" → false (should be true).
  Fix: Fall through to generic .dep. strstr check when session prefix
  doesn't match. Any project containing ".dep." is a dependency.

Bug 4: Package name extraction used wrong offset
  Root cause: Used strlen(session_project) as offset into project name,
  but session_project is CWD-detected, not the indexed project.
  Fix: Use strstr(project, ".dep.") to find separator position directly.

Binary verification (all confirmed working):
- search_graph(project="myapp") → 18 results (9 project + 9 dep)
- source:"project" vs source:"dependency" correctly tagged
- package:"testlib" correctly extracted
- Multiple deps in one index_dependencies call works

Tests: 2173 passing (updated test_depindex cross-project assertion).
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-03-22 23:51:34 -04:00
parent f6f0767748
commit 3fd21f8a23
4 changed files with 53 additions and 14 deletions
+8 -3
View File
@@ -63,12 +63,17 @@ char *cbm_dep_project_name(const char *project, const char *package_name) {
bool cbm_is_dep_project(const char *project_name, const char *session_project) {
if (!project_name) return false;
/* Check session-specific match first (e.g., "myapp.dep.pandas" with session "myapp") */
if (session_project && session_project[0]) {
size_t sp_len = strlen(session_project);
return (strncmp(project_name, session_project, sp_len) == 0 &&
strncmp(project_name + sp_len, CBM_DEP_SEPARATOR,
CBM_DEP_SEPARATOR_LEN) == 0);
if (strncmp(project_name, session_project, sp_len) == 0 &&
strncmp(project_name + sp_len, CBM_DEP_SEPARATOR,
CBM_DEP_SEPARATOR_LEN) == 0) {
return true;
}
}
/* Generic fallback: any project containing ".dep." or starting with "dep." is a dep.
* Handles cross-project queries where session_project doesn't match. */
return strstr(project_name, CBM_DEP_SEPARATOR) != NULL ||
strncmp(project_name, "dep.", 4) == 0;
}
+31 -7
View File
@@ -816,6 +816,21 @@ static const char *project_db_path(const char *project, char *buf, size_t bufsz)
/* Open the right project's .db file for query tools.
* Caches the connection — reopens only when project changes.
* Tracks last-access time so the event loop can evict idle stores. */
/* Extract the parent project name from a dep project name.
* "myapp.dep.pandas" → "myapp", "myapp.dep" → "myapp", "myapp" → "myapp".
* Returns a stack buffer pointer (caller must NOT free). */
static const char *parent_project_for_db(const char *project, char *buf, size_t bufsz) {
const char *dep = strstr(project, ".dep");
if (dep && (dep[4] == '.' || dep[4] == '\0')) {
size_t len = (size_t)(dep - project);
if (len >= bufsz) len = bufsz - 1;
memcpy(buf, project, len);
buf[len] = '\0';
return buf;
}
return project; /* no .dep → use as-is */
}
static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) {
if (!project) {
return srv->store; /* no project specified → use whatever's open */
@@ -823,8 +838,13 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) {
srv->store_last_used = time(NULL);
/* Already open for this project? */
if (srv->current_project && strcmp(srv->current_project, project) == 0 && srv->store) {
/* Dep projects (e.g., "myapp.dep.pandas") live in the parent project's DB
* ("myapp.db"), not in a separate "myapp.dep.pandas.db". Extract parent. */
char parent_buf[1024];
const char *db_project = parent_project_for_db(project, parent_buf, sizeof(parent_buf));
/* Already open for this project's DB? */
if (srv->current_project && strcmp(srv->current_project, db_project) == 0 && srv->store) {
return srv->store;
}
@@ -836,11 +856,11 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) {
/* Open project's .db file */
char path[1024];
project_db_path(project, path, sizeof(path));
project_db_path(db_project, path, sizeof(path));
srv->store = cbm_store_open_path(path);
srv->owns_store = true;
free(srv->current_project);
srv->current_project = heap_strdup(project);
srv->current_project = heap_strdup(db_project);
return srv->store;
}
@@ -1473,9 +1493,13 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) {
bool is_dep = cbm_is_dep_project(sr->node.project, srv->session_project);
yyjson_mut_obj_add_str(doc, item, "source", is_dep ? "dependency" : "project");
if (is_dep && sr->node.project) {
size_t sp_len2 = strlen(srv->session_project);
const char *pkg = sr->node.project + sp_len2 + CBM_DEP_SEPARATOR_LEN;
yyjson_mut_obj_add_strcpy(doc, item, "package", pkg);
/* Extract package name: find ".dep." and take everything after it.
* "myapp.dep.pandas" → "pandas", "myapp.dep.uv.pandas" → "uv.pandas" */
const char *dep_sep = strstr(sr->node.project, CBM_DEP_SEPARATOR);
if (dep_sep) {
const char *pkg = dep_sep + CBM_DEP_SEPARATOR_LEN;
yyjson_mut_obj_add_strcpy(doc, item, "package", pkg);
}
yyjson_mut_obj_add_bool(doc, item, "read_only", true);
}
+9 -2
View File
@@ -1803,6 +1803,8 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
char bind_buf[64];
char *like_pattern = NULL;
char proj_like[1024]; /* prefix match pattern — must outlive BIND_TEXT usage */
proj_like[0] = '\0';
if (params->project_pattern) {
/* Glob/LIKE pattern from smart project param (e.g., "myapp.dep.%") */
@@ -1815,10 +1817,15 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
ADD_WHERE(bind_buf);
BIND_TEXT(params->project);
} else if (params->project) {
/* Default: exact match (same as before — prefix matching added in mcp.c) */
snprintf(bind_buf, sizeof(bind_buf), "n.project = ?%d", bind_idx + 1);
/* Prefix match: project itself + any dep sub-projects (e.g., myapp.dep.pandas).
* Uses (exact OR LIKE prefix) to include deps in same DB. */
snprintf(proj_like, sizeof(proj_like), "%s.%%", params->project);
snprintf(bind_buf, sizeof(bind_buf),
"(n.project = ?%d OR n.project LIKE ?%d)",
bind_idx + 1, bind_idx + 2);
ADD_WHERE(bind_buf);
BIND_TEXT(params->project);
BIND_TEXT(proj_like);
}
if (params->label) {
snprintf(bind_buf, sizeof(bind_buf), "n.label = ?%d", bind_idx + 1);
+5 -2
View File
@@ -580,11 +580,14 @@ TEST(test_dep_project_name_format) {
}
TEST(test_is_dep_project_with_session) {
/* With session context — precise prefix check */
/* With session context — precise prefix check first, then generic .dep. fallback */
ASSERT_TRUE(cbm_is_dep_project("myapp.dep.pandas", "myapp"));
ASSERT_TRUE(cbm_is_dep_project("myapp.dep.serde", "myapp"));
ASSERT_FALSE(cbm_is_dep_project("myapp", "myapp"));
ASSERT_FALSE(cbm_is_dep_project("otherapp.dep.pandas", "myapp"));
/* Cross-project deps: otherapp.dep.pandas contains ".dep." → IS a dep.
* This is correct: when querying across projects, dep nodes from any project
* should be tagged as dependencies for AI grounding (read_only, source tagging). */
ASSERT_TRUE(cbm_is_dep_project("otherapp.dep.pandas", "myapp"));
ASSERT_FALSE(cbm_is_dep_project(NULL, "myapp"));
PASS();
}