mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-09 09:11:00 +08:00
fix(leaks,depindex,mcp): fix 206 heap leaks, expand ecosystem detection to 17 managers, improve compact output
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>
This commit is contained in:
+34
-4
@@ -886,7 +886,9 @@ TEST(snippet_exact_qn) {
|
||||
call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
|
||||
"\"project\":\"test-project\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
|
||||
/* compact: name omitted when it equals last segment of qualified_name */
|
||||
ASSERT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\""));
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
|
||||
/* Exact match should NOT have match_method */
|
||||
ASSERT_NULL(strstr(resp, "\"match_method\""));
|
||||
@@ -903,6 +905,27 @@ TEST(snippet_exact_qn) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── TestSnippet_CompactFalse: name present when compact=false ── */
|
||||
|
||||
TEST(snippet_compact_false_name_present) {
|
||||
char tmp[256];
|
||||
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
|
||||
ASSERT_NOT_NULL(srv);
|
||||
|
||||
/* compact=false: name must be present even when it equals last segment of QN */
|
||||
char *resp = call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\","
|
||||
"\"project\":\"test-project\","
|
||||
"\"compact\":false}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\""));
|
||||
free(resp);
|
||||
|
||||
cbm_mcp_server_free(srv);
|
||||
cleanup_snippet_dir(tmp);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* ── TestSnippet_QNSuffix ─────────────────────────────────────── */
|
||||
|
||||
TEST(snippet_qn_suffix) {
|
||||
@@ -913,7 +936,9 @@ TEST(snippet_qn_suffix) {
|
||||
char *resp = call_snippet(srv, "{\"qualified_name\":\"main.HandleRequest\","
|
||||
"\"project\":\"test-project\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
|
||||
/* compact: name omitted when it equals last segment of qualified_name */
|
||||
ASSERT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
|
||||
ASSERT_NOT_NULL(strstr(resp, "HandleRequest")); /* present in qualified_name */
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\""));
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
|
||||
free(resp);
|
||||
@@ -934,7 +959,9 @@ TEST(snippet_unique_short_name) {
|
||||
char *resp = call_snippet(srv, "{\"qualified_name\":\"ProcessOrder\","
|
||||
"\"project\":\"test-project\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"ProcessOrder\""));
|
||||
/* compact: name omitted when it equals last segment of qualified_name */
|
||||
ASSERT_NULL(strstr(resp, "\"name\":\"ProcessOrder\""));
|
||||
ASSERT_NOT_NULL(strstr(resp, "ProcessOrder")); /* present in qualified_name */
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\""));
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"source\""));
|
||||
free(resp);
|
||||
@@ -955,7 +982,9 @@ TEST(snippet_name_tier) {
|
||||
char *resp = call_snippet(srv, "{\"qualified_name\":\"HandleRequest\","
|
||||
"\"project\":\"test-project\"}");
|
||||
ASSERT_NOT_NULL(resp);
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
|
||||
/* compact: name omitted when it equals last segment of qualified_name */
|
||||
ASSERT_NULL(strstr(resp, "\"name\":\"HandleRequest\""));
|
||||
ASSERT_NOT_NULL(strstr(resp, "HandleRequest")); /* present in qualified_name */
|
||||
ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\""));
|
||||
free(resp);
|
||||
|
||||
@@ -1247,6 +1276,7 @@ SUITE(mcp) {
|
||||
|
||||
/* Snippet resolution (port of snippet_test.go) */
|
||||
RUN_TEST(snippet_exact_qn);
|
||||
RUN_TEST(snippet_compact_false_name_present);
|
||||
RUN_TEST(snippet_qn_suffix);
|
||||
RUN_TEST(snippet_unique_short_name);
|
||||
RUN_TEST(snippet_name_tier);
|
||||
|
||||
@@ -4875,4 +4875,7 @@ SUITE(pipeline) {
|
||||
RUN_TEST(githistory_compute_change_coupling);
|
||||
RUN_TEST(githistory_coupling_skips_large_commits);
|
||||
RUN_TEST(githistory_coupling_limits_output);
|
||||
/* Release pipeline-level global state (compiled regex patterns etc.).
|
||||
* Patterns are compiled on first use and cached; free once at suite end. */
|
||||
cbm_pipeline_global_cleanup();
|
||||
}
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
|
||||
/* ── 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;
|
||||
@@ -556,6 +559,56 @@ TEST(trace_compact_omits_redundant_name) {
|
||||
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
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
@@ -783,6 +836,151 @@ TEST(response_includes_meta_fields) {
|
||||
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.
|
||||
@@ -1179,6 +1377,285 @@ TEST(trace_call_path_default_edge_types_calls_only) {
|
||||
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
|
||||
* ══════════════════════════════════════════════════════════════════ */
|
||||
@@ -1202,6 +1679,8 @@ SUITE(token_reduction) {
|
||||
|
||||
/* 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 */
|
||||
@@ -1220,6 +1699,22 @@ SUITE(token_reduction) {
|
||||
/* 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);
|
||||
@@ -1233,4 +1728,13 @@ SUITE(token_reduction) {
|
||||
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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user