mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-02 07:53:41 +08:00
Memory leaks fixed (0 leaks confirmed via leaks --atExit): - mcp.c resolve_store: cbm_project_free_fields was gated on proj.root_path[0] — empty string paths silently skipped free. Separated free from the watcher call; now always frees after successful cbm_store_get_project. - mcp.c handle_index_status: cbm_store_search_free skipped when dep_out.count==0 — cbm_store_search allocates even for empty results. Restructured to free whenever search succeeds. Same fix for cbm_project_free_fields call in ecosystem detection path. - pagerank.c: node_labels leaked on two early return paths (N==0 and id_map_init failure). Both paths now free node_ids and node_labels (with per-element free for strdup'd entries before the N==0 branch assigns any). - pass_envscan.c: 8 static regexes compiled once by compile_patterns() were never freed. Added cbm_envscan_free_patterns() that calls cbm_regfree on each and resets patterns_compiled=0. - pipeline.h/pipeline.c: public cbm_pipeline_global_cleanup() wraps cbm_envscan_free_patterns(). Called in main.c after ALL server threads joined (HTTP + stdio) to avoid racing with autoindex threads. Also called in run_cli() path and test_pipeline.c teardown. Ecosystem detection expanded from 8 to 17 package managers: - depindex.h: added CBM_PKG_MAKE, CBM_PKG_CMAKE, CBM_PKG_MESON, CBM_PKG_CONAN (C/C++ build systems). Expanded CBM_MANIFEST_FILES with build.gradle.kts, bun.lockb, global.json, Directory.Build.props, NuGet.Config, Makefile, GNUmakefile, Makefile.cbm, CMakeLists.txt, meson.build, conanfile.txt, conanfile.py, vcpkg.json. - depindex.c: rewrote cbm_detect_ecosystem to cover all 17 managers using CHECK() macro for exact filename matches and dir_contains_suffix() for wildcard patterns (*.csproj, *.fsproj). Added has_vendored_deps_dir() helper. Added discover_vendored_deps() which scans vendor/ vendored/ third_party/ thirdparty/ deps/ external/ ext/ contrib/ lib/ _vendor/ submodules/ for C/C++ and CBM_PKG_CUSTOM build systems. dep search hint in handle_search_graph: - When a dep project search (project:"dep", expanded to prefix "<session>.dep") returns 0 results, emits a "hint" field with an ecosystem-aware actionable message. If cbm_detect_ecosystem succeeds, the hint names the detected build system and instructs to re-run index_repository. If no ecosystem detected, lists all 17 supported manifest file types. Compact output improvements in mcp.c: - handle_search_graph: skip emitting "name" when it equals the last segment of qualified_name (ends_with_segment check) or when empty. - handle_trace_call_path: same fix for both outbound (callees) and inbound (callers) node arrays. Added callers_total emission to match callees_total (was documented in tool description but never emitted). - build_snippet_response: skip empty name, label, and file_path fields. Compact param now wired through all six call sites in handle_get_code. Zero-value numeric fields skipped in compact mode. - handle_get_architecture / build_resource_architecture: skip redundant name (when equals last qualified_name segment) and empty label/fp in key_functions arrays. Test coverage: - test_token_reduction.c: 504-line new file covering compact suppression of redundant name/label/empty fields, callers_total presence, get_code compact param propagation, architecture key_functions, and dep search hint emission. - test_mcp.c, test_pipeline.c: minor additions for new behaviors. Makefile.cbm: - Added nosan build (CFLAGS_NOSAN, LDFLAGS_NOSAN, MONGOOSE_CFLAGS_NOSAN, per-object NOSAN variants for sqlite3/lsp/grammar/ts_runtime/mongoose). - Added test-leak target: macOS uses leaks --atExit on test-runner-nosan; Linux uses ASAN_OPTIONS=detect_leaks=1 on regular test-runner. - Added test-analyze target: Clang --analyze on production + test sources (skipped with message when IS_GCC=yes). - Updated .PHONY with test-leak, test-analyze, test-runner-nosan. Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
1741 lines
69 KiB
C
1741 lines
69 KiB
C
/*
|
|
* test_token_reduction.c — Tests for token reduction changes.
|
|
*
|
|
* Covers: default limits, smart truncation, compact mode, summary mode,
|
|
* trace edge cases, query_graph output truncation, token metadata.
|
|
*
|
|
* TDD: All tests written BEFORE implementation. They should fail (RED)
|
|
* until the corresponding feature is implemented (GREEN).
|
|
*/
|
|
#include "../src/foundation/compat.h"
|
|
#include "test_framework.h"
|
|
#include <mcp/mcp.h>
|
|
#include <store/store.h>
|
|
#include <yyjson/yyjson.h>
|
|
#include <string.h>
|
|
#include <stdlib.h>
|
|
#include <stdio.h>
|
|
#include <sys/stat.h>
|
|
#include <unistd.h>
|
|
|
|
/* ── Helpers (reuse patterns from test_mcp.c) ────────────────── */
|
|
|
|
/* Forward declaration — definition is in the SEARCH PARAMETERIZATION section */
|
|
static cbm_mcp_server_t *setup_sp_server(void);
|
|
|
|
static char *extract_text_content_tr(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);
|
|
yyjson_val *root = yyjson_doc_get_root(doc);
|
|
yyjson_val *content = yyjson_obj_get(root, "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;
|
|
}
|
|
|
|
/* Create an MCP server pre-populated with many functions for limit testing.
|
|
* Writes a source file with 80 small functions to tmp_dir/project/many.py.
|
|
* Returns NULL on failure. Caller must free server and call cleanup. */
|
|
static cbm_mcp_server_t *setup_limit_test_server(char *tmp_dir, size_t tmp_sz) {
|
|
snprintf(tmp_dir, tmp_sz, "/tmp/cbm_limit_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 source file with many functions */
|
|
char src_path[512];
|
|
snprintf(src_path, sizeof(src_path), "%s/many.py", proj_dir);
|
|
FILE *fp = fopen(src_path, "w");
|
|
if (!fp)
|
|
return NULL;
|
|
for (int i = 0; i < 80; i++) {
|
|
fprintf(fp, "def func_%03d():\n pass\n\n", i);
|
|
}
|
|
fclose(fp);
|
|
|
|
/* Write a large function for truncation tests */
|
|
char big_path[512];
|
|
snprintf(big_path, sizeof(big_path), "%s/big.py", proj_dir);
|
|
fp = fopen(big_path, "w");
|
|
if (!fp)
|
|
return NULL;
|
|
fprintf(fp, "def large_function(arg1, arg2, arg3):\n");
|
|
fprintf(fp, " \"\"\"Process data with multiple steps.\"\"\"\n");
|
|
for (int i = 2; i < 298; i++) {
|
|
fprintf(fp, " step_%03d = process(arg1, %d)\n", i, i);
|
|
}
|
|
fprintf(fp, " result = combine(step_002, step_297)\n");
|
|
fprintf(fp, " return result\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);
|
|
if (!st) {
|
|
cbm_mcp_server_free(srv);
|
|
return NULL;
|
|
}
|
|
|
|
const char *proj_name = "limit-test";
|
|
cbm_mcp_server_set_project(srv, proj_name);
|
|
cbm_store_upsert_project(st, proj_name, proj_dir);
|
|
|
|
/* Create 80 function nodes */
|
|
for (int i = 0; i < 80; i++) {
|
|
cbm_node_t n = {0};
|
|
n.project = proj_name;
|
|
n.label = "Function";
|
|
char name_buf[32], qn_buf[64];
|
|
snprintf(name_buf, sizeof(name_buf), "func_%03d", i);
|
|
snprintf(qn_buf, sizeof(qn_buf), "limit-test.many.func_%03d", i);
|
|
n.name = name_buf;
|
|
n.qualified_name = qn_buf;
|
|
n.file_path = "many.py";
|
|
n.start_line = i * 3 + 1;
|
|
n.end_line = i * 3 + 2;
|
|
n.properties_json = "{\"is_exported\":true}";
|
|
cbm_store_upsert_node(st, &n);
|
|
}
|
|
|
|
/* Create a large function node for truncation tests */
|
|
cbm_node_t big = {0};
|
|
big.project = proj_name;
|
|
big.label = "Function";
|
|
big.name = "large_function";
|
|
big.qualified_name = "limit-test.big.large_function";
|
|
big.file_path = "big.py";
|
|
big.start_line = 1;
|
|
big.end_line = 300;
|
|
big.properties_json = "{\"signature\":\"def large_function(arg1, arg2, arg3)\","
|
|
"\"return_type\":\"result\",\"is_exported\":true}";
|
|
cbm_store_upsert_node(st, &big);
|
|
|
|
/* Create call chain for trace tests: func_000 -> func_001 -> func_002 */
|
|
int64_t id0 = 1, id1 = 2, id2 = 3; /* approximate IDs */
|
|
cbm_edge_t e1 = {.project = proj_name, .source_id = id0, .target_id = id1, .type = "CALLS"};
|
|
cbm_store_insert_edge(st, &e1);
|
|
cbm_edge_t e2 = {.project = proj_name, .source_id = id1, .target_id = id2, .type = "CALLS"};
|
|
cbm_store_insert_edge(st, &e2);
|
|
/* Create cycle: func_002 -> func_000 */
|
|
cbm_edge_t e3 = {.project = proj_name, .source_id = id2, .target_id = id0, .type = "CALLS"};
|
|
cbm_store_insert_edge(st, &e3);
|
|
|
|
return srv;
|
|
}
|
|
|
|
static void cleanup_limit_test_dir(const char *tmp_dir) {
|
|
char path[512];
|
|
snprintf(path, sizeof(path), "%s/project/many.py", tmp_dir);
|
|
unlink(path);
|
|
snprintf(path, sizeof(path), "%s/project/big.py", tmp_dir);
|
|
unlink(path);
|
|
snprintf(path, sizeof(path), "%s/project", tmp_dir);
|
|
rmdir(path);
|
|
rmdir(tmp_dir);
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 1.1 DEFAULT LIMITS
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(search_graph_default_limit_is_50) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* search_graph with no limit parameter — should default to 50 */
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"limit-test\",\"label\":\"Function\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Parse response to count results */
|
|
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");
|
|
ASSERT_NOT_NULL(results);
|
|
ASSERT_TRUE(yyjson_arr_size(results) <= 50);
|
|
|
|
/* total should reflect all 80 functions */
|
|
yyjson_val *total = yyjson_obj_get(root, "total");
|
|
ASSERT_TRUE(yyjson_get_int(total) >= 80);
|
|
|
|
/* has_more should be true since 80 > 50 */
|
|
yyjson_val *has_more = yyjson_obj_get(root, "has_more");
|
|
ASSERT_TRUE(yyjson_get_bool(has_more));
|
|
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_graph_explicit_limit_honored) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"limit-test\",\"label\":\"Function\","
|
|
"\"limit\":5}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
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");
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 5);
|
|
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_graph_explicit_high_limit_still_works) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* Explicit limit=1000 should override default and return all 80+ */
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"limit-test\",\"label\":\"Function\","
|
|
"\"limit\":1000}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
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");
|
|
/* Should get all 80+ nodes (80 funcs + 1 large_function) */
|
|
ASSERT_TRUE((int)yyjson_arr_size(results) > 50);
|
|
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_code_default_limit_is_50) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* search_code for "def " should match all 81 functions but return ≤50 */
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_code",
|
|
"{\"project\":\"limit-test\",\"pattern\":\"def \"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
if (doc) {
|
|
yyjson_val *root = yyjson_doc_get_root(doc);
|
|
yyjson_val *results = yyjson_obj_get(root, "results");
|
|
if (results && yyjson_is_arr(results)) {
|
|
ASSERT_TRUE((int)yyjson_arr_size(results) <= 50);
|
|
}
|
|
yyjson_doc_free(doc);
|
|
}
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_graph_pagination_stable_ordering) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* Page 1: offset=0, limit=10 */
|
|
char *raw1 = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"limit-test\",\"label\":\"Function\","
|
|
"\"limit\":10,\"offset\":0}");
|
|
char *resp1 = extract_text_content_tr(raw1);
|
|
free(raw1);
|
|
|
|
/* Page 2: offset=10, limit=10 */
|
|
char *raw2 = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"limit-test\",\"label\":\"Function\","
|
|
"\"limit\":10,\"offset\":10}");
|
|
char *resp2 = extract_text_content_tr(raw2);
|
|
free(raw2);
|
|
|
|
ASSERT_NOT_NULL(resp1);
|
|
ASSERT_NOT_NULL(resp2);
|
|
|
|
/* Pages should not overlap — check first result of page 2 is not in page 1 */
|
|
yyjson_doc *d2 = yyjson_read(resp2, strlen(resp2), 0);
|
|
if (d2) {
|
|
yyjson_val *r2 = yyjson_doc_get_root(d2);
|
|
yyjson_val *res2 = yyjson_obj_get(r2, "results");
|
|
if (res2 && yyjson_arr_size(res2) > 0) {
|
|
yyjson_val *first = yyjson_arr_get(res2, 0);
|
|
yyjson_val *qn = yyjson_obj_get(first, "qualified_name");
|
|
const char *qn_str = yyjson_get_str(qn);
|
|
if (qn_str) {
|
|
/* This QN should NOT appear in page 1 */
|
|
ASSERT_NULL(strstr(resp1, qn_str));
|
|
}
|
|
}
|
|
yyjson_doc_free(d2);
|
|
}
|
|
|
|
free(resp1);
|
|
free(resp2);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 1.2 SMART TRUNCATION
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(snippet_full_mode_default_200_lines) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet",
|
|
"{\"qualified_name\":\"limit-test.big.large_function\","
|
|
"\"project\":\"limit-test\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Should be truncated since function is 300 lines, default max_lines=200 */
|
|
ASSERT_NOT_NULL(strstr(resp, "\"truncated\":true"));
|
|
ASSERT_NOT_NULL(strstr(resp, "\"total_lines\":300"));
|
|
/* Signature should still be present for structural context */
|
|
ASSERT_NOT_NULL(strstr(resp, "large_function"));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(snippet_full_mode_small_function_no_truncation) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* func_000 is only 2 lines — should NOT be truncated */
|
|
char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet",
|
|
"{\"qualified_name\":\"limit-test.many.func_000\","
|
|
"\"project\":\"limit-test\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
ASSERT_NULL(strstr(resp, "\"truncated\":true"));
|
|
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(snippet_signature_mode) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet",
|
|
"{\"qualified_name\":\"limit-test.big.large_function\","
|
|
"\"project\":\"limit-test\",\"mode\":\"signature\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Should contain signature from properties */
|
|
ASSERT_NOT_NULL(strstr(resp, "def large_function(arg1, arg2, arg3)"));
|
|
/* Should NOT contain full source body */
|
|
ASSERT_NULL(strstr(resp, "step_050"));
|
|
/* Should indicate total size */
|
|
ASSERT_NOT_NULL(strstr(resp, "\"total_lines\""));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(snippet_head_tail_mode) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet",
|
|
"{\"qualified_name\":\"limit-test.big.large_function\","
|
|
"\"project\":\"limit-test\","
|
|
"\"mode\":\"head_tail\",\"max_lines\":100}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Head (first 60 lines) should include the function def */
|
|
ASSERT_NOT_NULL(strstr(resp, "def large_function"));
|
|
/* Tail (last 40 lines) should include the return statement */
|
|
ASSERT_NOT_NULL(strstr(resp, "return result"));
|
|
/* Omission marker between head and tail */
|
|
ASSERT_NOT_NULL(strstr(resp, "lines omitted"));
|
|
ASSERT_NOT_NULL(strstr(resp, "\"truncated\":true"));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(snippet_head_tail_no_truncation_when_fits) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* func_000 is 2 lines, head_tail with max_lines=100 should return all */
|
|
char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet",
|
|
"{\"qualified_name\":\"limit-test.many.func_000\","
|
|
"\"project\":\"limit-test\","
|
|
"\"mode\":\"head_tail\",\"max_lines\":100}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
ASSERT_NULL(strstr(resp, "lines omitted"));
|
|
ASSERT_NULL(strstr(resp, "\"truncated\":true"));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(snippet_custom_max_lines) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet",
|
|
"{\"qualified_name\":\"limit-test.big.large_function\","
|
|
"\"project\":\"limit-test\",\"max_lines\":50}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
ASSERT_NOT_NULL(strstr(resp, "\"truncated\":true"));
|
|
ASSERT_NOT_NULL(strstr(resp, "\"total_lines\":300"));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(snippet_max_lines_zero_means_unlimited) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* max_lines=0 should return full source without truncation */
|
|
char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet",
|
|
"{\"qualified_name\":\"limit-test.big.large_function\","
|
|
"\"project\":\"limit-test\",\"max_lines\":0}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Should NOT be truncated */
|
|
ASSERT_NULL(strstr(resp, "\"truncated\":true"));
|
|
/* Should contain content from near the end of the function */
|
|
ASSERT_NOT_NULL(strstr(resp, "return result"));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 1.3 COMPACT MODE
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(search_graph_compact_omits_redundant_name) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"limit-test\",\"label\":\"Function\","
|
|
"\"limit\":5,\"compact\":true}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* In compact mode, results should have qualified_name but
|
|
* name should be omitted when it's a suffix of qualified_name.
|
|
* All our test functions have name == last segment of QN,
|
|
* so name should be omitted for all results. */
|
|
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");
|
|
ASSERT_NOT_NULL(results);
|
|
|
|
/* Check first result has qualified_name but no name */
|
|
yyjson_val *first = yyjson_arr_get(results, 0);
|
|
ASSERT_NOT_NULL(first);
|
|
ASSERT_NOT_NULL(yyjson_obj_get(first, "qualified_name"));
|
|
ASSERT_NULL(yyjson_obj_get(first, "name"));
|
|
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(trace_compact_omits_redundant_name) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"func_000\","
|
|
"\"project\":\"limit-test\",\"compact\":true}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Callees should use compact format */
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
if (doc) {
|
|
yyjson_val *root = yyjson_doc_get_root(doc);
|
|
yyjson_val *callees = yyjson_obj_get(root, "callees");
|
|
if (callees && yyjson_arr_size(callees) > 0) {
|
|
yyjson_val *first = yyjson_arr_get(callees, 0);
|
|
ASSERT_NOT_NULL(yyjson_obj_get(first, "qualified_name"));
|
|
/* name should be omitted in compact mode */
|
|
ASSERT_NULL(yyjson_obj_get(first, "name"));
|
|
}
|
|
yyjson_doc_free(doc);
|
|
}
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_graph_compact_defaults_to_true) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
/* No compact param -> default is true -> name omitted when name == last qn segment.
|
|
* All sp-test nodes satisfy this (e.g. name="main", qn="sp-test.main.main"). */
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\","
|
|
"\"include_dependencies\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
ASSERT_NOT_NULL(results);
|
|
ASSERT_GT((int)yyjson_arr_size(results), 0);
|
|
yyjson_val *first = yyjson_arr_get(results, 0);
|
|
/* compact=true default: name == last qn segment -> name field OMITTED */
|
|
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);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_graph_compact_false_includes_name) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\","
|
|
"\"include_dependencies\":false,"
|
|
"\"compact\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
ASSERT_NOT_NULL(results);
|
|
ASSERT_GT((int)yyjson_arr_size(results), 0);
|
|
yyjson_val *first = yyjson_arr_get(results, 0);
|
|
/* compact=false: name field present even when name matches qn suffix */
|
|
ASSERT_NOT_NULL(yyjson_obj_get(first, "name"));
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 1.4 SUMMARY MODE
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(search_graph_summary_mode) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"limit-test\","
|
|
"\"mode\":\"summary\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Should have aggregate fields, NOT individual results */
|
|
ASSERT_NOT_NULL(strstr(resp, "\"total\""));
|
|
ASSERT_NOT_NULL(strstr(resp, "\"by_label\""));
|
|
ASSERT_NULL(strstr(resp, "\"results\""));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 1.5 TRACE EDGE CASES
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(trace_ambiguous_function_returns_candidates) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* Add a second node with same short name but different QN */
|
|
cbm_store_t *st = cbm_mcp_server_store(srv);
|
|
cbm_node_t dup = {0};
|
|
dup.project = "limit-test";
|
|
dup.label = "Function";
|
|
dup.name = "func_000";
|
|
dup.qualified_name = "limit-test.other.func_000";
|
|
dup.file_path = "other.py";
|
|
dup.start_line = 1;
|
|
dup.end_line = 2;
|
|
cbm_store_upsert_node(st, &dup);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"func_000\","
|
|
"\"project\":\"limit-test\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Should include candidates array when name is ambiguous */
|
|
ASSERT_NOT_NULL(strstr(resp, "\"candidates\""));
|
|
ASSERT_NOT_NULL(strstr(resp, "\"resolved\""));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(trace_bfs_deduplicates_cycles) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* func_000 -> func_001 -> func_002 -> func_000 (cycle)
|
|
* BFS should visit each node at most once in results */
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"func_000\","
|
|
"\"project\":\"limit-test\","
|
|
"\"direction\":\"outbound\",\"depth\":5}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
if (doc) {
|
|
yyjson_val *root = yyjson_doc_get_root(doc);
|
|
yyjson_val *callees = yyjson_obj_get(root, "callees");
|
|
if (callees) {
|
|
/* Should have at most 2 unique callees (func_001, func_002)
|
|
* NOT 4+ from the cycle being traversed multiple times */
|
|
ASSERT_TRUE((int)yyjson_arr_size(callees) <= 3);
|
|
}
|
|
yyjson_doc_free(doc);
|
|
}
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(trace_max_results_parameter) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"func_000\","
|
|
"\"project\":\"limit-test\","
|
|
"\"max_results\":1}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
if (doc) {
|
|
yyjson_val *root = yyjson_doc_get_root(doc);
|
|
yyjson_val *callees = yyjson_obj_get(root, "callees");
|
|
if (callees) {
|
|
ASSERT_TRUE((int)yyjson_arr_size(callees) <= 1);
|
|
}
|
|
yyjson_doc_free(doc);
|
|
}
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 1.7 QUERY_GRAPH OUTPUT TRUNCATION
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(query_graph_max_output_bytes_truncates) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* Query that returns many rows, but cap output at 1024 bytes */
|
|
char *raw = cbm_mcp_handle_tool(srv, "query_graph",
|
|
"{\"query\":\"MATCH (f:Function) RETURN f.name, "
|
|
"f.qualified_name, f.file_path\","
|
|
"\"project\":\"limit-test\","
|
|
"\"max_output_bytes\":1024}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Response should indicate truncation */
|
|
ASSERT_NOT_NULL(strstr(resp, "\"truncated\":true"));
|
|
/* Response body should be near the byte limit */
|
|
ASSERT_TRUE(strlen(resp) <= 2048); /* some slack for metadata */
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(query_graph_aggregation_not_broken) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* Aggregation query should return correct count regardless of limits */
|
|
char *raw = cbm_mcp_handle_tool(srv, "query_graph",
|
|
"{\"query\":\"MATCH (f:Function) RETURN count(f)\","
|
|
"\"project\":\"limit-test\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Should NOT be truncated (aggregation returns 1 small row) */
|
|
ASSERT_NULL(strstr(resp, "\"truncated\":true"));
|
|
/* Should contain a count ≥ 80 (our 80 funcs + large_function) */
|
|
ASSERT_NOT_NULL(strstr(resp, "rows"));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
TEST(query_graph_max_output_bytes_zero_unlimited) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
/* max_output_bytes=0 should disable truncation */
|
|
char *raw = cbm_mcp_handle_tool(srv, "query_graph",
|
|
"{\"query\":\"MATCH (f:Function) RETURN f.name\","
|
|
"\"project\":\"limit-test\","
|
|
"\"max_output_bytes\":0}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
ASSERT_NULL(strstr(resp, "\"truncated\":true"));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 1.8 TOKEN METADATA
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(response_includes_meta_fields) {
|
|
char tmp[256];
|
|
cbm_mcp_server_t *srv = setup_limit_test_server(tmp, sizeof(tmp));
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"limit-test\",\"label\":\"Function\","
|
|
"\"limit\":5}");
|
|
ASSERT_NOT_NULL(raw);
|
|
|
|
/* Token metadata is in the MCP envelope (cbm_mcp_text_result output) */
|
|
ASSERT_NOT_NULL(strstr(raw, "\"_result_bytes\""));
|
|
ASSERT_NOT_NULL(strstr(raw, "\"_est_tokens\""));
|
|
|
|
free(raw);
|
|
cbm_mcp_server_free(srv);
|
|
cleanup_limit_test_dir(tmp);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 1.9 FIELD OMISSION (empty label / file_path not emitted)
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(search_graph_omits_empty_label_and_file_path) {
|
|
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
|
ASSERT_NOT_NULL(srv);
|
|
cbm_store_t *st = cbm_mcp_server_store(srv);
|
|
cbm_mcp_server_set_project(srv, "empty-test");
|
|
cbm_store_upsert_project(st, "empty-test", "/tmp");
|
|
|
|
/* Node with empty label and empty file_path */
|
|
cbm_node_t n = {0};
|
|
n.project = "empty-test";
|
|
n.label = "";
|
|
n.name = "anon_func";
|
|
n.qualified_name = "empty-test.mod.anon_func";
|
|
n.file_path = "";
|
|
n.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &n);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"empty-test\",\"compact\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
ASSERT_NOT_NULL(results);
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 1);
|
|
yyjson_val *item = yyjson_arr_get(results, 0);
|
|
/* Empty label and file_path must be omitted, not emitted as "" */
|
|
ASSERT_NULL(yyjson_obj_get(item, "label"));
|
|
ASSERT_NULL(yyjson_obj_get(item, "file_path"));
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_graph_includes_nonempty_label_and_file_path) {
|
|
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
|
ASSERT_NOT_NULL(srv);
|
|
cbm_store_t *st = cbm_mcp_server_store(srv);
|
|
cbm_mcp_server_set_project(srv, "nonempty-test");
|
|
cbm_store_upsert_project(st, "nonempty-test", "/tmp");
|
|
|
|
cbm_node_t n = {0};
|
|
n.project = "nonempty-test";
|
|
n.label = "Function";
|
|
n.name = "do_work";
|
|
n.qualified_name = "nonempty-test.worker.do_work";
|
|
n.file_path = "worker.py";
|
|
n.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &n);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"nonempty-test\",\"compact\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
ASSERT_NOT_NULL(results);
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 1);
|
|
yyjson_val *item = yyjson_arr_get(results, 0);
|
|
/* Non-empty label and file_path must be present with correct values */
|
|
ASSERT_NOT_NULL(yyjson_obj_get(item, "label"));
|
|
ASSERT_NOT_NULL(yyjson_obj_get(item, "file_path"));
|
|
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item, "label")), "Function");
|
|
ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item, "file_path")), "worker.py");
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* TDD: Zero in_degree/out_degree fields omitted when no edges.
|
|
* RED until Change 3 (zero degree omission) is implemented in mcp.c. */
|
|
TEST(search_graph_omits_zero_degrees) {
|
|
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
|
ASSERT_NOT_NULL(srv);
|
|
cbm_store_t *st = cbm_mcp_server_store(srv);
|
|
cbm_mcp_server_set_project(srv, "degree-test");
|
|
cbm_store_upsert_project(st, "degree-test", "/tmp");
|
|
|
|
/* Node with no edges -> in_degree=0, out_degree=0 */
|
|
cbm_node_t n = {0};
|
|
n.project = "degree-test";
|
|
n.label = "Function";
|
|
n.name = "isolated";
|
|
n.qualified_name = "degree-test.mod.isolated";
|
|
n.file_path = "mod.py";
|
|
n.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &n);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"degree-test\",\"compact\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
ASSERT_NOT_NULL(results);
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 1);
|
|
yyjson_val *item = yyjson_arr_get(results, 0);
|
|
/* Zero in_degree and out_degree must be omitted, not emitted as 0 */
|
|
ASSERT_NULL(yyjson_obj_get(item, "in_degree"));
|
|
ASSERT_NULL(yyjson_obj_get(item, "out_degree"));
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* Non-zero degrees must still be present (regression guard for Change 3). */
|
|
TEST(search_graph_includes_nonzero_degrees) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
/* process_request has in_degree=2 (CALLS from main, HTTP_CALLS from fetch_data) */
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\","
|
|
"\"qn_pattern\":\".*process_request.*\","
|
|
"\"include_dependencies\":false,"
|
|
"\"compact\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
ASSERT_NOT_NULL(results);
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 1);
|
|
yyjson_val *item = yyjson_arr_get(results, 0);
|
|
/* process_request has non-zero in_degree -> must be present */
|
|
ASSERT_NOT_NULL(yyjson_obj_get(item, "in_degree"));
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* SEARCH PARAMETERIZATION ACCURACY
|
|
* TDD: Tests written BEFORE implementation.
|
|
* RED before changes applied. GREEN after.
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
/* ── Parameterization test fixture ──────────────────────────── */
|
|
/*
|
|
* Creates a minimal server with:
|
|
* Project "sp-test":
|
|
* node id=1: Function name="main" qn="sp-test.main.main"
|
|
* no inbound CALLS (in_deg=0 — entry point)
|
|
* node id=2: Function name="process_request" qn="sp-test.handlers.process_request"
|
|
* inbound CALLS from main (in_deg=1)
|
|
* node id=3: Function name="fetch_data" qn="sp-test.http.fetch_data"
|
|
* outbound HTTP_CALLS to process_request (in_deg=0)
|
|
* Project "sp-test.dep.mypkg":
|
|
* node id=4: Function name="dep_helper" qn="sp-test.dep.mypkg.dep_helper"
|
|
*
|
|
* Edges:
|
|
* CALLS: id=1 -> id=2 (main calls process_request)
|
|
* HTTP_CALLS: id=3 -> id=2 (fetch_data HTTP calls to process_request)
|
|
*
|
|
* Node IDs are predictable: fresh in-memory SQLite, autoincrement from 1.
|
|
*/
|
|
static cbm_mcp_server_t *setup_sp_server(void) {
|
|
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;
|
|
}
|
|
|
|
cbm_mcp_server_set_project(srv, "sp-test");
|
|
cbm_store_upsert_project(st, "sp-test", "/tmp");
|
|
cbm_store_upsert_project(st, "sp-test.dep.mypkg", "/tmp/dep");
|
|
|
|
cbm_node_t n1 = {0};
|
|
n1.project = "sp-test";
|
|
n1.label = "Function";
|
|
n1.name = "main";
|
|
n1.qualified_name = "sp-test.main.main";
|
|
n1.file_path = "main.py";
|
|
n1.start_line = 1;
|
|
n1.end_line = 5;
|
|
n1.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &n1);
|
|
|
|
cbm_node_t n2 = {0};
|
|
n2.project = "sp-test";
|
|
n2.label = "Function";
|
|
n2.name = "process_request";
|
|
n2.qualified_name = "sp-test.handlers.process_request";
|
|
n2.file_path = "handlers.py";
|
|
n2.start_line = 1;
|
|
n2.end_line = 10;
|
|
n2.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &n2);
|
|
|
|
cbm_node_t n3 = {0};
|
|
n3.project = "sp-test";
|
|
n3.label = "Function";
|
|
n3.name = "fetch_data";
|
|
n3.qualified_name = "sp-test.http.fetch_data";
|
|
n3.file_path = "http.py";
|
|
n3.start_line = 1;
|
|
n3.end_line = 8;
|
|
n3.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &n3);
|
|
|
|
cbm_node_t n4 = {0};
|
|
n4.project = "sp-test.dep.mypkg";
|
|
n4.label = "Function";
|
|
n4.name = "dep_helper";
|
|
n4.qualified_name = "sp-test.dep.mypkg.dep_helper";
|
|
n4.file_path = "mypkg/helper.py";
|
|
n4.start_line = 1;
|
|
n4.end_line = 5;
|
|
n4.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &n4);
|
|
|
|
/* CALLS: main(id=1) -> process_request(id=2) */
|
|
cbm_edge_t e1 = {0};
|
|
e1.project = "sp-test";
|
|
e1.source_id = 1;
|
|
e1.target_id = 2;
|
|
e1.type = "CALLS";
|
|
e1.properties_json = "{}";
|
|
cbm_store_insert_edge(st, &e1);
|
|
|
|
/* HTTP_CALLS: fetch_data(id=3) -> process_request(id=2) */
|
|
cbm_edge_t e2 = {0};
|
|
e2.project = "sp-test";
|
|
e2.source_id = 3;
|
|
e2.target_id = 2;
|
|
e2.type = "HTTP_CALLS";
|
|
e2.properties_json = "{}";
|
|
cbm_store_insert_edge(st, &e2);
|
|
|
|
return srv;
|
|
}
|
|
|
|
/* ── Changes 2.1 + 1.1 + 1.3: qn_pattern filters qualified_name ── */
|
|
|
|
TEST(search_graph_qn_pattern_filters_results) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\","
|
|
"\"qn_pattern\":\".*handlers.*\","
|
|
"\"include_dependencies\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
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");
|
|
ASSERT_NOT_NULL(results);
|
|
/* Only process_request qn contains "handlers". Expect 1 result.
|
|
* RED: qn_pattern ignored, returns all 3 project nodes. GREEN: 1. */
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 1);
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_graph_qn_pattern_no_match_returns_empty) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\","
|
|
"\"qn_pattern\":\".*nonexistent_module.*\","
|
|
"\"include_dependencies\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
/* RED: qn_pattern ignored, returns all nodes. GREEN: 0. */
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 0);
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Changes 2.2 + 1.1 + 1.3: relationship filters by edge type ── */
|
|
|
|
TEST(search_graph_relationship_filters_to_matching_edge_type) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\","
|
|
"\"relationship\":\"HTTP_CALLS\","
|
|
"\"include_dependencies\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
ASSERT_NOT_NULL(results);
|
|
/* fetch_data (source) + process_request (target) both involved in HTTP_CALLS.
|
|
* main has no HTTP_CALLS edges -> excluded.
|
|
* RED: all 3 returned. GREEN: 2 (both endpoints of HTTP_CALLS). */
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 2);
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_graph_relationship_nonexistent_type_returns_empty) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\","
|
|
"\"relationship\":\"WRITES\","
|
|
"\"include_dependencies\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
/* No WRITES edges exist. RED: all nodes returned. GREEN: 0. */
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 0);
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Changes 2.3 + 1.2 + 1.3: exclude_entry_points ─────────── */
|
|
|
|
TEST(search_graph_exclude_entry_points_removes_zero_inbound) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\","
|
|
"\"exclude_entry_points\":true,"
|
|
"\"include_dependencies\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
ASSERT_NOT_NULL(results);
|
|
/* main(in_deg=0) + fetch_data(in_deg=0) excluded. process_request(in_deg=1) kept.
|
|
* RED: all 3 returned. GREEN: 1. */
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 1);
|
|
yyjson_val *first = yyjson_arr_get(results, 0);
|
|
/* Check qualified_name (always present; name may be omitted by compact=true default) */
|
|
yyjson_val *qn = yyjson_obj_get(first, "qualified_name");
|
|
ASSERT_STR_EQ(yyjson_get_str(qn), "sp-test.handlers.process_request");
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_graph_exclude_entry_points_false_keeps_all) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\","
|
|
"\"exclude_entry_points\":false,"
|
|
"\"include_dependencies\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 3);
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Change 1.3: include_dependencies ──────────────────────── */
|
|
|
|
TEST(search_graph_include_dependencies_true_includes_dep_nodes) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
/* Default: include_dependencies not specified = true */
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
/* dep_helper from sp-test.dep.mypkg should appear in results */
|
|
ASSERT_NOT_NULL(strstr(resp, "dep_helper"));
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
TEST(search_graph_include_dependencies_false_excludes_dep_nodes) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
|
|
"{\"project\":\"sp-test\","
|
|
"\"include_dependencies\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results");
|
|
/* dep_helper (project=sp-test.dep.mypkg) must NOT appear.
|
|
* RED: include_dependencies ignored -- may return 4. GREEN: exactly 3. */
|
|
ASSERT_EQ((int)yyjson_arr_size(results), 3);
|
|
ASSERT_NULL(strstr(resp, "dep_helper"));
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Change 3.1 reverted: trace compact default remains true ─── */
|
|
|
|
TEST(trace_call_path_compact_defaults_to_true) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
/* No compact param -> defaults to true -> name omitted when it matches qn suffix */
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"main\","
|
|
"\"project\":\"sp-test\","
|
|
"\"direction\":\"outbound\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
/* Parse and check: callees[0] should NOT have "name" key (compact=true default).
|
|
* main -> process_request. qn "sp-test.handlers.process_request",
|
|
* name "process_request". ends_with_segment(qn, name) is TRUE => name omitted. */
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *root = yyjson_doc_get_root(doc);
|
|
yyjson_val *callees = yyjson_obj_get(root, "callees");
|
|
ASSERT_NOT_NULL(callees);
|
|
ASSERT_GT((int)yyjson_arr_size(callees), 0);
|
|
yyjson_val *first_callee = yyjson_arr_get(callees, 0);
|
|
/* compact=true default: name matches last segment of qn -> name field OMITTED */
|
|
ASSERT_NULL(yyjson_obj_get(first_callee, "name"));
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
TEST(trace_call_path_compact_false_includes_name) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"main\","
|
|
"\"project\":\"sp-test\","
|
|
"\"direction\":\"outbound\","
|
|
"\"compact\":false}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *root = yyjson_doc_get_root(doc);
|
|
yyjson_val *callees = yyjson_obj_get(root, "callees");
|
|
ASSERT_NOT_NULL(callees);
|
|
ASSERT_GT((int)yyjson_arr_size(callees), 0);
|
|
yyjson_val *first_callee = yyjson_arr_get(callees, 0);
|
|
/* compact=false explicit: name field present even though name matches qn suffix */
|
|
ASSERT_NOT_NULL(yyjson_obj_get(first_callee, "name"));
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ── Change 3.2: trace edge_types user param ────────────────── */
|
|
|
|
TEST(trace_call_path_edge_types_http_calls_traverses_http_edges) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
/* fetch_data(id=3) has HTTP_CALLS -> process_request(id=2).
|
|
* With edge_types=["HTTP_CALLS"] outbound, process_request should appear.
|
|
* With CALLS-only (old hardcoded): no CALLS from fetch_data -> empty callees. */
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"fetch_data\","
|
|
"\"project\":\"sp-test\","
|
|
"\"direction\":\"outbound\","
|
|
"\"edge_types\":[\"HTTP_CALLS\"]}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *callees = yyjson_obj_get(yyjson_doc_get_root(doc), "callees");
|
|
ASSERT_NOT_NULL(callees);
|
|
/* RED: edge_types ignored, CALLS used, fetch_data has no CALLS -> callees empty.
|
|
* GREEN: HTTP_CALLS traversed -> process_request in callees. */
|
|
ASSERT_GT((int)yyjson_arr_size(callees), 0);
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
TEST(trace_call_path_default_edge_types_calls_only) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
/* Without edge_types -> default CALLS -> main -> process_request appears */
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"main\","
|
|
"\"project\":\"sp-test\","
|
|
"\"direction\":\"outbound\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *callees = yyjson_obj_get(yyjson_doc_get_root(doc), "callees");
|
|
/* main has CALLS -> process_request. Default behavior unchanged. */
|
|
ASSERT_NOT_NULL(callees);
|
|
ASSERT_GT((int)yyjson_arr_size(callees), 0);
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 2.0 JSON OUTPUT MINIFICATION
|
|
* All tool responses must be single-line minified JSON.
|
|
* yy_doc_to_str uses YYJSON_WRITE_ALLOW_INVALID_UNICODE (no PRETTY).
|
|
* Tests verify this contract holds across the full API surface.
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(all_mcp_responses_are_minified_json) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
|
|
const char *tools[] = {"search_graph", "trace_call_path", "get_architecture", "query_graph"};
|
|
const char *args[] = {
|
|
"{\"project\":\"sp-test\",\"limit\":3}",
|
|
"{\"function_name\":\"main\",\"project\":\"sp-test\"}",
|
|
"{\"project\":\"sp-test\"}",
|
|
"{\"query\":\"MATCH (n) RETURN n.name LIMIT 3\",\"project\":\"sp-test\"}"
|
|
};
|
|
for (int t = 0; t < 4; t++) {
|
|
char *raw = cbm_mcp_handle_tool(srv, tools[t], args[t]);
|
|
char *text = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(text);
|
|
/* Pretty-printed JSON always contains newlines — must be absent */
|
|
ASSERT_NULL(strstr(text, "\n"));
|
|
free(text);
|
|
}
|
|
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 2.1 trace_call_path FIELD OMISSION (TDD)
|
|
* Candidates block uses empty-string fallback for file_path (mcp.c:2116).
|
|
* RED until candidates block is fixed like search_graph.
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
/* Empty file_path in a candidate must be omitted, not emitted as "". */
|
|
TEST(trace_call_path_candidates_omits_empty_file_path) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
cbm_store_t *st = cbm_mcp_server_store(srv);
|
|
|
|
/* Second "main" node with empty file_path forces ambiguity */
|
|
cbm_node_t dup = {0};
|
|
dup.project = "sp-test";
|
|
dup.label = "Function";
|
|
dup.name = "main";
|
|
dup.qualified_name = "sp-test.alt.main";
|
|
dup.file_path = "";
|
|
dup.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &dup);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"main\","
|
|
"\"project\":\"sp-test\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
ASSERT_NOT_NULL(strstr(resp, "\"candidates\""));
|
|
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *candidates = yyjson_obj_get(yyjson_doc_get_root(doc), "candidates");
|
|
ASSERT_NOT_NULL(candidates);
|
|
|
|
bool found = false;
|
|
for (size_t i = 0; i < yyjson_arr_size(candidates); i++) {
|
|
yyjson_val *c = yyjson_arr_get(candidates, i);
|
|
yyjson_val *qn = yyjson_obj_get(c, "qualified_name");
|
|
if (qn && strcmp(yyjson_get_str(qn), "sp-test.alt.main") == 0) {
|
|
/* Candidate with empty file_path must NOT have the key */
|
|
ASSERT_NULL(yyjson_obj_get(c, "file_path"));
|
|
found = true;
|
|
}
|
|
}
|
|
ASSERT_TRUE(found);
|
|
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* Non-empty file_path in candidates must still be present (regression guard). */
|
|
TEST(trace_call_path_candidates_includes_nonempty_file_path) {
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
cbm_store_t *st = cbm_mcp_server_store(srv);
|
|
|
|
cbm_node_t dup = {0};
|
|
dup.project = "sp-test";
|
|
dup.label = "Function";
|
|
dup.name = "main";
|
|
dup.qualified_name = "sp-test.alt.main";
|
|
dup.file_path = "alt.py";
|
|
dup.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &dup);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"main\","
|
|
"\"project\":\"sp-test\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *candidates = yyjson_obj_get(yyjson_doc_get_root(doc), "candidates");
|
|
ASSERT_NOT_NULL(candidates);
|
|
|
|
/* All candidates here have non-empty file_path -> key must be present */
|
|
for (size_t i = 0; i < yyjson_arr_size(candidates); i++) {
|
|
yyjson_val *c = yyjson_arr_get(candidates, i);
|
|
ASSERT_NOT_NULL(yyjson_obj_get(c, "file_path"));
|
|
}
|
|
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 2.2 get_architecture COMPACT COVERAGE
|
|
* key_functions already uses null-guards (if (n), if (lbl), if (fp)).
|
|
* Tests verify the contract and that output remains minified.
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(get_architecture_output_is_minified_and_no_empty_fields) {
|
|
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
|
ASSERT_NOT_NULL(srv);
|
|
cbm_store_t *st = cbm_mcp_server_store(srv);
|
|
cbm_mcp_server_set_project(srv, "arch-test");
|
|
cbm_store_upsert_project(st, "arch-test", "/tmp");
|
|
|
|
cbm_node_t n = {0};
|
|
n.project = "arch-test";
|
|
n.label = "Function";
|
|
n.name = "entry_point";
|
|
n.qualified_name = "arch-test.main.entry_point";
|
|
n.file_path = "main.py";
|
|
n.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &n);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "get_architecture",
|
|
"{\"project\":\"arch-test\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Must be minified */
|
|
ASSERT_NULL(strstr(resp, "\n"));
|
|
|
|
/* key_functions block must never emit empty-string values */
|
|
ASSERT_NULL(strstr(resp, "\"name\":\"\""));
|
|
ASSERT_NULL(strstr(resp, "\"label\":\"\""));
|
|
ASSERT_NULL(strstr(resp, "\"file_path\":\"\""));
|
|
ASSERT_NULL(strstr(resp, "\"qualified_name\":\"\""));
|
|
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 2.3 trace_call_path callers_total field
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(trace_call_path_response_includes_callers_total) {
|
|
/* TDD RED: callers_total never emitted (Bug C) — becomes GREEN after fix */
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
/* direction=both triggers do_inbound=true; main has no callers but
|
|
* callers_total must still appear in the response */
|
|
char *raw = cbm_mcp_handle_tool(srv, "trace_call_path",
|
|
"{\"function_name\":\"main\","
|
|
"\"project\":\"sp-test\","
|
|
"\"direction\":\"both\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
/* callers_total must be present even when callers array is empty */
|
|
ASSERT_NOT_NULL(strstr(resp, "\"callers_total\""));
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 2.4 get_code_snippet empty field omission
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(get_code_snippet_omits_empty_name_label) {
|
|
/* TDD RED: name/label emitted as "" when NULL/empty (Bug B) */
|
|
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
|
ASSERT_NOT_NULL(srv);
|
|
cbm_store_t *st = cbm_mcp_server_store(srv);
|
|
cbm_mcp_server_set_project(srv, "snip-test");
|
|
cbm_store_upsert_project(st, "snip-test", "/tmp");
|
|
|
|
/* Node with empty name and empty label — exercises the "" guard */
|
|
cbm_node_t n = {0};
|
|
n.project = "snip-test";
|
|
n.name = ""; /* empty — should NOT appear as "name":"" */
|
|
n.label = ""; /* empty — should NOT appear as "label":"" */
|
|
n.qualified_name = "snip-test.mod.empty_node";
|
|
n.file_path = ""; /* empty — should NOT appear as "file_path":"" */
|
|
n.start_line = 1;
|
|
n.end_line = 2;
|
|
n.properties_json = "{}";
|
|
cbm_store_upsert_node(st, &n);
|
|
|
|
char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet",
|
|
"{\"qualified_name\":\"snip-test.mod.empty_node\","
|
|
"\"project\":\"snip-test\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
ASSERT_NULL(strstr(resp, "\"name\":\"\""));
|
|
ASSERT_NULL(strstr(resp, "\"label\":\"\""));
|
|
ASSERT_NULL(strstr(resp, "\"file_path\":\"\""));
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* 2.5 get_architecture compact applied to key_functions
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
TEST(get_architecture_compact_omits_redundant_name_in_key_functions) {
|
|
/* TDD RED: key_functions always emits name (Bug A) — becomes GREEN after fix.
|
|
* All sp-test nodes have name == last segment of qualified_name, so
|
|
* compact should omit every name field in key_functions. */
|
|
cbm_mcp_server_t *srv = setup_sp_server();
|
|
ASSERT_NOT_NULL(srv);
|
|
char *raw = cbm_mcp_handle_tool(srv, "get_architecture",
|
|
"{\"project\":\"sp-test\"}");
|
|
char *resp = extract_text_content_tr(raw);
|
|
free(raw);
|
|
ASSERT_NOT_NULL(resp);
|
|
|
|
/* Parse key_functions and assert no entry has a "name" key that equals
|
|
* the last segment of its "qualified_name" */
|
|
yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0);
|
|
ASSERT_NOT_NULL(doc);
|
|
yyjson_val *root = yyjson_doc_get_root(doc);
|
|
yyjson_val *kfs = yyjson_obj_get(root, "key_functions");
|
|
if (kfs && yyjson_is_arr(kfs)) {
|
|
size_t idx, max;
|
|
yyjson_val *kf;
|
|
yyjson_arr_foreach(kfs, idx, max, kf) {
|
|
yyjson_val *name_val = yyjson_obj_get(kf, "name");
|
|
yyjson_val *qn_val = yyjson_obj_get(kf, "qualified_name");
|
|
if (name_val && qn_val) {
|
|
const char *nm = yyjson_get_str(name_val);
|
|
const char *qn = yyjson_get_str(qn_val);
|
|
/* If name is present, it must NOT equal the last segment of qn */
|
|
if (nm && qn) {
|
|
size_t qn_len = strlen(qn);
|
|
size_t nm_len = strlen(nm);
|
|
bool is_suffix = (nm_len < qn_len) &&
|
|
(qn[qn_len - nm_len - 1] == '.' ||
|
|
qn[qn_len - nm_len - 1] == ':' ||
|
|
qn[qn_len - nm_len - 1] == '/') &&
|
|
strcmp(qn + qn_len - nm_len, nm) == 0;
|
|
ASSERT_FALSE(is_suffix); /* compact must have omitted this */
|
|
}
|
|
}
|
|
}
|
|
}
|
|
yyjson_doc_free(doc);
|
|
free(resp);
|
|
cbm_mcp_server_free(srv);
|
|
PASS();
|
|
}
|
|
|
|
/* ══════════════════════════════════════════════════════════════════
|
|
* SUITE
|
|
* ══════════════════════════════════════════════════════════════════ */
|
|
|
|
SUITE(token_reduction) {
|
|
/* 1.1 Default Limits */
|
|
RUN_TEST(search_graph_default_limit_is_50);
|
|
RUN_TEST(search_graph_explicit_limit_honored);
|
|
RUN_TEST(search_graph_explicit_high_limit_still_works);
|
|
RUN_TEST(search_code_default_limit_is_50);
|
|
RUN_TEST(search_graph_pagination_stable_ordering);
|
|
|
|
/* 1.2 Smart Truncation */
|
|
RUN_TEST(snippet_full_mode_default_200_lines);
|
|
RUN_TEST(snippet_full_mode_small_function_no_truncation);
|
|
RUN_TEST(snippet_signature_mode);
|
|
RUN_TEST(snippet_head_tail_mode);
|
|
RUN_TEST(snippet_head_tail_no_truncation_when_fits);
|
|
RUN_TEST(snippet_custom_max_lines);
|
|
RUN_TEST(snippet_max_lines_zero_means_unlimited);
|
|
|
|
/* 1.3 Compact Mode */
|
|
RUN_TEST(search_graph_compact_omits_redundant_name);
|
|
RUN_TEST(search_graph_compact_defaults_to_true);
|
|
RUN_TEST(search_graph_compact_false_includes_name);
|
|
RUN_TEST(trace_compact_omits_redundant_name);
|
|
|
|
/* 1.4 Summary Mode */
|
|
RUN_TEST(search_graph_summary_mode);
|
|
|
|
/* 1.5 Trace Edge Cases */
|
|
RUN_TEST(trace_ambiguous_function_returns_candidates);
|
|
RUN_TEST(trace_bfs_deduplicates_cycles);
|
|
RUN_TEST(trace_max_results_parameter);
|
|
|
|
/* 1.7 query_graph Output Truncation */
|
|
RUN_TEST(query_graph_max_output_bytes_truncates);
|
|
RUN_TEST(query_graph_aggregation_not_broken);
|
|
RUN_TEST(query_graph_max_output_bytes_zero_unlimited);
|
|
|
|
/* 1.8 Token Metadata */
|
|
RUN_TEST(response_includes_meta_fields);
|
|
|
|
/* 1.9 Field Omission */
|
|
RUN_TEST(search_graph_omits_empty_label_and_file_path);
|
|
RUN_TEST(search_graph_includes_nonempty_label_and_file_path);
|
|
RUN_TEST(search_graph_omits_zero_degrees);
|
|
RUN_TEST(search_graph_includes_nonzero_degrees);
|
|
|
|
/* 2.0 JSON Output Minification */
|
|
RUN_TEST(all_mcp_responses_are_minified_json);
|
|
|
|
/* 2.1 trace_call_path Field Omission */
|
|
RUN_TEST(trace_call_path_candidates_omits_empty_file_path);
|
|
RUN_TEST(trace_call_path_candidates_includes_nonempty_file_path);
|
|
|
|
/* 2.2 get_architecture Compact Coverage */
|
|
RUN_TEST(get_architecture_output_is_minified_and_no_empty_fields);
|
|
|
|
/* Search Parameterization Accuracy */
|
|
RUN_TEST(search_graph_qn_pattern_filters_results);
|
|
RUN_TEST(search_graph_qn_pattern_no_match_returns_empty);
|
|
RUN_TEST(search_graph_relationship_filters_to_matching_edge_type);
|
|
RUN_TEST(search_graph_relationship_nonexistent_type_returns_empty);
|
|
RUN_TEST(search_graph_exclude_entry_points_removes_zero_inbound);
|
|
RUN_TEST(search_graph_exclude_entry_points_false_keeps_all);
|
|
RUN_TEST(search_graph_include_dependencies_true_includes_dep_nodes);
|
|
RUN_TEST(search_graph_include_dependencies_false_excludes_dep_nodes);
|
|
RUN_TEST(trace_call_path_compact_defaults_to_true);
|
|
RUN_TEST(trace_call_path_compact_false_includes_name);
|
|
RUN_TEST(trace_call_path_edge_types_http_calls_traverses_http_edges);
|
|
RUN_TEST(trace_call_path_default_edge_types_calls_only);
|
|
|
|
/* 2.3 callers_total field completeness */
|
|
RUN_TEST(trace_call_path_response_includes_callers_total);
|
|
|
|
/* 2.4 get_code_snippet empty field omission */
|
|
RUN_TEST(get_code_snippet_omits_empty_name_label);
|
|
|
|
/* 2.5 get_architecture compact key_functions */
|
|
RUN_TEST(get_architecture_compact_omits_redundant_name_in_key_functions);
|
|
}
|