Files
codebase-memory-mcp/tests/test_input_validation.c
T
Andrew Hundt 9a7d83275b feat(mcp): self-healing query_graph hints, one notification authority, live tools/list schema
Response-format consolidation: add cbm_mcp_response_format/
cbm_mcp_invalid_response_format so every response-generating handler
reads the format argument or default_response_format config through one
function instead of duplicating the enum-mapping logic, and migrate
query_graph's legacy-JSON detection to the canonical
cbm_mcp_output_format_t enum. Fix the regression this surfaced: TOON-path
_context/session_project delivery (toon_append_context_once) was
serializing the one-shot context object as a raw escaped JSON string
inside the TOON stream instead of native TOON fields, silently dropping
it for any TOON-consuming client. Add toon_append_context_model plus its
scalar/table helpers to map the same format-neutral context model
inject_context_once already builds into native TOON output.

Self-healing zero-row hints: query_graph_no_rows_hint reparses the
already-executed query on the zero-row path only, walks MATCH/OPTIONAL
MATCH patterns and EXISTS predicates in WHERE and post-WITH-WHERE
clauses (hint_walk_expr_exists_types/hint_walk_where_exists_types), and
probes each referenced label/edge type against the store via
cbm_store_schema_label_observed/cbm_store_schema_type_observed
(schema_probe_one_row: fail-open on any non-SQLITE_DONE outcome, so a
probe error never produces a false accusation). Unobserved names are
named in the response; a bounded hint_seen_t/hint_seen_add set (capped
at CBM_STORE_SCHEMA_HINT_VOCAB_LIMIT) dedups names repeated across
patterns/UNION branches so "Klass, Klass." can't happen and avoids
re-probing a name already resolved. The vocabulary summary and the
generic zero-row fallback no longer claim WHERE-clause predicates were
verified, since only labels/edge types are ever probed — only the
labels/edge types claim is made.

One invalidation authority: every graph-mutation path now calls
the single cbm_mcp_server_notify_index_published (flags-only,
any-thread-safe) instead of ad hoc local staleness bookkeeping —
sync auto-index, in-process index_repository, in-process
autoindex_thread, overlay compaction (gated on compacted>0),
index_dependencies. handle_delete_project's notify is gated on the
mutation actually succeeding (!is_error): a delete of a nonexistent
project is a no-op and must not stale the tools/list description cache
for nothing. The request-thread-only drain
(mcp_drain_tools_list_changed) is factored once and shared by both
transports (line-delimited and Content-Length-framed), gated so a
notification published before any tools/list has ever been served is
suppressed rather than sent for a client that never asked.

Shared overlay-aware schema-view selector (mcp_get_current_schema/
mcp_overlay_view_ready) ends the schema/overlay-readiness dance
previously triplicated across handle_get_graph_schema,
build_resource_schema, and the query path, so query_graph's advertised
vocabulary can never diverge from the view a query actually runs
against. The selector takes an mcp_cypher_vocabulary_t level instead of
a bool: MCP_CYPHER_MATCH_VOCABULARY (labels/edge types/observed
patterns with counts — enough to compose executable MATCH clauses;
never parses property JSON) vs MCP_CYPHER_FULL_QUERY_VOCABULARY (adds
per-label/type property keys for WHERE/RETURN authorship; O(total
property rows) json_each discovery, so only the docstring cache and
get_graph_schema request it).

Route the two remaining canonical-only cbm_store_get_schema callers
through the selector at MATCH_VOCABULARY level: inject_context_once
(first-response _context previously paid the full property-key scan for
counts it never rendered, and could advertise canonical labels a ready
overlay had tombstoned) and build_resource_architecture (rendered only
rel_patterns yet fetched full property keys, canonical-only). The
architecture resource's mixed-read-model disclosure moves
relationship_patterns into the overlay-aware list accordingly.

build_query_graph_tool_description resolves the project's real
store via resolve_store(srv, project) instead of trusting srv->store
as-is: on the very first tools/list of a fresh session for an
already-indexed project, srv->store was still the empty default
in-memory store (maybe_auto_index detects "already indexed" and skips
reindexing without ever opening it), so the schema section rendered
silently empty until some other tool call happened to resolve it first.
The project name is snapshotted into a local buffer before the
resolve_store call rather than passed as a live alias to
srv->current_project, because resolve_store can call
reap_stale_store internally, which frees srv->current_project when a
deferred invalidation is pending — aliasing it produced a
heap-use-after-free (caught by the ASan full-suite run, confirmed fixed
by rerun).

Docstring clarity for newcomers: the live schema section now embeds its
notation legend in the section header ("Labels name{extra property
keys}[count]:") so the compact schema is self-explanatory in every
tools/list response, and the query_graph/get_architecture/search_code
format parameters gain the brief description their three siblings
already had, all naming default_response_format.

Tests: response-format and TOON-context regressions in test_mcp.c/
test_tool_consolidation.c/test_input_validation.c/test_token_reduction.c;
zero-row hint coverage including no-hidden-tool-name, missed-graph shadow-
project probing, empty-project no-crash, TOON/JSON format parity,
UNION/repeated-name dedup, and the previously-untested EXISTS-predicate
walk; notification coverage for every notify call site including the
delete_project no-op negative direction and the "notify before first
tools/list is suppressed" negative direction; a cold-start regression
test seeding a real on-disk project store and calling tools/list as the
literal first request of a fresh session; overlay-consistency tests for
the two rerouted callers (first_response_context_uses_ready_overlay_schema,
resource_arch_rel_patterns_use_ready_overlay) proving _context and the
architecture resource report the active-overlay vocabulary, not
tombstoned canonical labels/types.

Docs: README.md documents the zero-row hint behavior and the
default_response_format config example; docs/CONFIGURATION.md adds the
default_response_format row to the important-keys table.

Verification: make -f Makefile.cbm test (full ASan/UBSan suite) run on
this exact tree after the final revisions. Earlier iterations of this
content additionally passed make -f Makefile.cbm test-leak (0 leaks for
0 total leaked bytes) and manual checks against the live MCP binary
(zero-row hint names real schema on two repositories; the query_graph
tools/list docstring shows the live schema on the very first request of
a fresh session); revisions since those checks are docstring text, the
vocabulary-enum rename, and the two selector reroutes, all covered by
the full-suite rerun.

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
2026-07-19 01:42:38 -04:00

1599 lines
68 KiB
C

/*
* test_input_validation.c — Tests for parameter validation from fuzz testing.
* Covers: F1 (empty label), F6 (invalid sort_by), F7 (invalid mode),
* F9 (invalid regex), F10 (negative depth), F15 (invalid direction).
*
* Each test creates a minimal MCP server, calls a tool handler with invalid
* input, and asserts the error response contains helpful guidance.
*/
#include "../src/foundation/compat.h"
#include "../src/foundation/compat_fs.h"
#include "../src/foundation/platform.h"
#include "test_framework.h"
#include "test_helpers.h"
#include <mcp/mcp.h>
#include <store/store.h>
#include <cli/cli.h>
#include <yyjson/yyjson.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
/* ── Helper: extract inner text content from MCP tool result ── */
static char *extract_text(const char *mcp_result) {
if (!mcp_result) return NULL;
/* Parse MCP JSON wrapper: {"content":[{"type":"text","text":"..."}]} */
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);
yyjson_val *text = item ? yyjson_obj_get(item, "text") : NULL;
const char *str = text ? yyjson_get_str(text) : NULL;
char *result = str ? strdup(str) : strdup(mcp_result);
yyjson_doc_free(doc);
return result;
}
/* ── Helper: create minimal server with pre-populated data ── */
static cbm_mcp_server_t *setup_validation_server(char *tmp, size_t tmp_sz) {
const char *cache = cbm_resolve_cache_dir();
int path_len = snprintf(tmp, tmp_sz, "%s/cbm-test-validation-XXXXXX", cache);
if (path_len < 0 || (size_t)path_len >= tmp_sz)
return NULL;
if (!cbm_mkdtemp(tmp)) return NULL;
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 = "validation-test";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, tmp);
/* Insert test nodes: 2 functions + 1 call edge */
cbm_node_t foo = {.project = proj, .label = "Function", .name = "foo",
.qualified_name = "validation-test.test.foo",
.file_path = "test.c", .start_line = 1, .end_line = 1};
cbm_node_t bar = {.project = proj, .label = "Function", .name = "bar",
.qualified_name = "validation-test.test.bar",
.file_path = "test.c", .start_line = 2, .end_line = 2};
cbm_node_t alpha = {.project = proj,
.label = "Function",
.name = "alphaWorker",
.qualified_name = "validation-test.services.alphaWorker",
.file_path = "worker.c",
.start_line = 3,
.end_line = 3};
cbm_node_t beta = {.project = proj,
.label = "Function",
.name = "betaHandler",
.qualified_name = "validation-test.services.betaHandler",
.file_path = "handler.c",
.start_line = 4,
.end_line = 4};
cbm_store_upsert_node(st, &foo);
cbm_store_upsert_node(st, &bar);
cbm_store_upsert_node(st, &alpha);
cbm_store_upsert_node(st, &beta);
cbm_edge_t e = {.project = proj, .source_id = 2, .target_id = 1, .type = "CALLS"};
cbm_store_insert_edge(st, &e);
return srv;
}
static void cleanup_validation_dir(const char *dir) {
th_cleanup(dir);
}
/* ══════════════════════════════════════════════════════════════════
* F1: Empty label treated as no filter (not silently returning 0)
* ══════════════════════════════════════════════════════════════════ */
TEST(f1_empty_label_returns_results) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"label\":\"\",\"limit\":5}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* Empty label should be treated as "no label filter" → returns all nodes */
/* Should NOT return error, and total should be > 0 if project has data */
ASSERT_NULL(strstr(resp, "\"error\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* F6: Invalid sort_by returns error with valid values
* ══════════════════════════════════════════════════════════════════ */
TEST(f6_invalid_sort_by_errors) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"sort_by\":\"invalid_value\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* Must return error mentioning sort_by */
ASSERT_NOT_NULL(strstr(resp, "error"));
ASSERT_NOT_NULL(strstr(resp, "sort_by"));
/* Must list valid values */
ASSERT_NOT_NULL(strstr(resp, "relevance"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* Edge case: sort_by with typo "degre" (missing 'e') */
TEST(f6_sort_by_typo_errors) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"sort_by\":\"degre\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "error"));
ASSERT_NOT_NULL(strstr(resp, "degree")); /* suggest correct value */
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* F9: Invalid regex in name_pattern returns error
* ══════════════════════════════════════════════════════════════════ */
TEST(f9_invalid_regex_errors) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"name_pattern\":\"(\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* Must return error mentioning regex/pattern */
ASSERT_NOT_NULL(strstr(resp, "error"));
ASSERT_TRUE(strstr(resp, "regex") || strstr(resp, "pattern"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* Edge case: valid regex should NOT error */
TEST(f9_valid_regex_succeeds) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"name_pattern\":\"foo.*bar\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* Valid regex should NOT produce error */
ASSERT_NULL(strstr(resp, "\"error\":\"invalid regex"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* F6: sort_by 'calls' and 'linkrank' must be accepted (Bug 1)
* ══════════════════════════════════════════════════════════════════ */
TEST(f6_sort_by_calls_accepted) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"sort_by\":\"calls\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "invalid sort_by"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(f6_sort_by_linkrank_accepted) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"sort_by\":\"linkrank\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "invalid sort_by"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* F9: Glob wildcard patterns auto-converted to regex (Bug 2)
* ══════════════════════════════════════════════════════════════════ */
TEST(f9_glob_star_autoconverted) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw =
cbm_mcp_handle_tool(srv, "search_graph", "{\"name_pattern\":\"*Worker*\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "alphaWorker"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(f9_glob_question_autoconverted) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"name_pattern\":\"*alpha?orker*\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "alphaWorker"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(f9_valid_regex_shaped_glob_is_normalized_before_compile) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"name_pattern\":\"alpha?orker|beta?andler\",\"limit\":5,"
"\"format\":\"json\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "alphaWorker"));
ASSERT_NOT_NULL(strstr(resp, "betaHandler"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(f9_valid_regex_still_works) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"name_pattern\":\".*tool.*\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "error"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(f9_truly_invalid_pattern_still_errors) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"name_pattern\":\"(\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "error"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(f9_qn_pattern_glob_autoconverted) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"qn_pattern\":\"*Handler*\",\"limit\":3}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "betaHandler"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* F10: Negative depth clamped to 1
* ══════════════════════════════════════════════════════════════════ */
TEST(f10_negative_depth_returns_results) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"function_name\":\"foo\",\"depth\":-1}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* Should NOT return empty — depth clamped to 1, function "foo" exists */
/* At minimum should have function name in response */
ASSERT_NOT_NULL(strstr(resp, "foo"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Bug 3: trace_path fuzzy fallback on case mismatch
* ══════════════════════════════════════════════════════════════════ */
TEST(trace_case_mismatch_finds_via_fallback) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "Foo" does not exist — only "foo" does. Fallback search should find it.
* No project passed: resolve_store returns in-memory store, fallback search
* has no project filter, finds "foo", re-queries with result's project. */
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"function_name\":\"Foo\",\"format\":\"json\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* Must NOT contain "function not found" — fallback should resolve */
ASSERT_NULL(strstr(resp, "function not found"));
/* Response should contain "function" key (BFS result) and direction */
ASSERT_NOT_NULL(strstr(resp, "\"function\""));
ASSERT_NOT_NULL(strstr(resp, "\"direction\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(trace_exact_match_still_works) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Exact name "foo" should work directly without fallback.
* No project: resolve_store returns in-memory store, find_nodes_by_name
* uses project=NULL which binds NULL (won't match). Falls to fallback
* which finds "foo" via search (no project filter). */
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"function_name\":\"foo\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "function not found"));
ASSERT_NOT_NULL(strstr(resp, "foo"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(trace_truly_missing_still_errors) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "nonexistent_xyz" doesn't match anything — should still error */
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"function_name\":\"nonexistent_xyz\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "function not found"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* F15: Invalid direction returns error with valid values
* ══════════════════════════════════════════════════════════════════ */
TEST(f15_invalid_direction_errors) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"function_name\":\"foo\",\"direction\":\"invalid\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* Must return error mentioning direction */
ASSERT_NOT_NULL(strstr(resp, "error"));
ASSERT_NOT_NULL(strstr(resp, "direction"));
/* Must list valid values */
ASSERT_NOT_NULL(strstr(resp, "inbound"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* Edge case: valid direction "outbound" should NOT error */
TEST(f15_valid_direction_succeeds) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"function_name\":\"foo\",\"direction\":\"outbound\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* Valid direction should NOT produce error about direction */
ASSERT_NULL(strstr(resp, "invalid direction"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(trace_invalid_mode_errors) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"function_name\":\"foo\",\"mode\":\"typo\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "error"));
ASSERT_NOT_NULL(strstr(resp, "mode"));
ASSERT_NOT_NULL(strstr(resp, "calls"));
ASSERT_NOT_NULL(strstr(resp, "data_flow"));
ASSERT_NOT_NULL(strstr(resp, "cross_service"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* G1: Summary mode includes results_suppressed indicator
* ══════════════════════════════════════════════════════════════════ */
TEST(g1_summary_mode_has_results_key) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Pass project explicitly to ensure store is found.
* format:"json" opts into the legacy JSON summary shape (G1 contract). */
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"mode\":\"summary\",\"limit\":100,\"format\":\"json\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* G1: summary mode must include "results" key and results_suppressed */
ASSERT_NOT_NULL(strstr(resp, "\"total\""));
ASSERT_NOT_NULL(strstr(resp, "\"results\""));
ASSERT_NOT_NULL(strstr(resp, "results_suppressed"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* CQ-3: Cypher + filter params produces warning
* ══════════════════════════════════════════════════════════════════ */
TEST(cq3_cypher_with_label_warns) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* format:"json" — the ignored-filters warning is part of the legacy JSON
* response shape (the TOON default only carries Cypher-engine warnings). */
char *raw = cbm_mcp_handle_tool(srv, "query_graph",
"{\"cypher\":\"MATCH (n:Function) RETURN n.name LIMIT 5\","
"\"label\":\"Class\",\"format\":\"json\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* CQ-3: Should warn that label is ignored in Cypher mode */
ASSERT_NOT_NULL(strstr(resp, "warning"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* IX-2: Status shows "indexing" during active index
* ══════════════════════════════════════════════════════════════════ */
TEST(ix2_status_resource_format) {
/* IX-2: Verify status resource has expected fields when server has no data.
* Can't set autoindex_failed on opaque struct, but we can verify the
* not_indexed status path returns action_required field. */
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
/* Server with no indexed data should report not_indexed with action hint */
char *raw = cbm_mcp_handle_tool(srv, "index_status", "{}");
/* index_status without a project returns an error — that's expected */
ASSERT_NOT_NULL(raw);
free(raw);
cbm_mcp_server_free(srv);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Pattern OR-search: unified name+qn search (Change 1c)
* ══════════════════════════════════════════════════════════════════ */
TEST(pattern_or_search_graph) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* pattern="foo" should match node named "foo" (OR across name and qualified_name) */
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"pattern\":\"foo\",\"limit\":5,\"format\":\"json\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"error\""));
ASSERT_NOT_NULL(strstr(resp, "\"results\"")); /* results array present */
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(pattern_or_search_graph_normalizes_valid_regex_shaped_glob) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"pattern\":\"services.alpha?orker|beta?andler\",\"limit\":5,"
"\"format\":\"json\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "alphaWorker"));
ASSERT_NOT_NULL(strstr(resp, "betaHandler"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(f9_explicit_group_and_class_regex_quantifiers_stay_regex) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw =
cbm_mcp_handle_tool(srv, "search_graph",
"{\"name_pattern\":\"(alpha)?Worker|[ab].*Handler\",\"limit\":5,"
"\"format\":\"json\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "alphaWorker"));
ASSERT_NOT_NULL(strstr(resp, "betaHandler"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Source search via search_in="source" dispatch
* ══════════════════════════════════════════════════════════════════ */
TEST(source_search_via_search_in_param) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Write a file into the tmpdir so grep has something to search */
char src_path[320];
snprintf(src_path, sizeof(src_path), "%s/hello.c", tmp);
FILE *f = fopen(src_path, "w");
if (f) { fputs("/* cbm_unique_grep_token */\n", f); fclose(f); }
/* search_in="source" with explicit project slug dispatches to handle_search_code
* and finds the file we wrote above. */
char args[512];
snprintf(args, sizeof(args),
"{\"pattern\":\"cbm_unique_grep_token\","
"\"search_in\":\"source\","
"\"project\":\"validation-test\",\"format\":\"json\"}");
char *raw = cbm_mcp_handle_tool(srv, "search_code", args);
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
/* Should return matches array, NOT "project not found" */
ASSERT_NULL(strstr(resp, "\"error\""));
ASSERT_NOT_NULL(strstr(resp, "\"matches\""));
ASSERT_NOT_NULL(strstr(resp, "cbm_unique_grep_token"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Source search: path-based project arg normalizes to slug
* (Bug: get_project_root didn't convert /path → slug, causing
* "project not found" even when the project was indexed)
* ══════════════════════════════════════════════════════════════════ */
TEST(source_search_path_project_normalizes_to_slug) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Write a file with a known token */
char src_path[320];
snprintf(src_path, sizeof(src_path), "%s/hello.c", tmp);
FILE *f = fopen(src_path, "w");
if (f) { fputs("/* path_slug_normalize_token */\n", f); fclose(f); }
/* The project name "validation-test" was stored with root_path=tmp.
* Passing project=tmp (the root_path) should normalize via get_project_root.
* NOTE: this works when cbm_project_name_from_path(tmp) matches the stored
* project name. For arbitrary test slugs it won't — this test verifies the
* slug-based path works (project="validation-test" matches current_project). */
char args[512];
snprintf(args, sizeof(args),
"{\"pattern\":\"path_slug_normalize_token\","
"\"search_in\":\"source\","
"\"project\":\"validation-test\",\"format\":\"json\"}");
char *raw = cbm_mcp_handle_tool(srv, "search_code", args);
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"error\""));
ASSERT_NOT_NULL(strstr(resp, "\"matches\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Verify search_in="graph" (default) still does graph search
* ══════════════════════════════════════════════════════════════════ */
TEST(source_search_default_is_graph) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* No search_in → defaults to graph search → returns results array */
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"pattern\":\"foo\",\"limit\":5,\"format\":\"json\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"error\""));
ASSERT_NOT_NULL(strstr(resp, "\"results\"")); /* graph search returns results array */
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* summary=true bool alias (Change 2c)
* ══════════════════════════════════════════════════════════════════ */
TEST(summary_bool_alias) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"summary\":true,\"format\":\"json\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"error\""));
/* Summary mode: by_label and by_file_top20 present */
ASSERT_NOT_NULL(strstr(resp, "\"by_label\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* case_sensitive graph search (Change 2b)
* ══════════════════════════════════════════════════════════════════ */
TEST(case_sensitive_graph_search) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* case_sensitive=true: "FOO" should NOT match node named "foo" */
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"name_pattern\":\"FOO\",\"case_sensitive\":true,"
"\"limit\":5,\"format\":\"json\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"error\""));
/* Should return 0 results (no uppercase FOO in test store) */
ASSERT_NOT_NULL(strstr(resp, "\"total\":0"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Config: compact default false
* ══════════════════════════════════════════════════════════════════ */
TEST(config_compact_default_false) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Set compact=false in config, then call without compact param */
cbm_config_t *cfg = cbm_config_open(tmp);
ASSERT_NOT_NULL(cfg);
cbm_config_set(cfg, "compact", "false");
cbm_mcp_server_set_config(srv, cfg);
char *raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"error\""));
free(resp);
cbm_mcp_server_free(srv);
cbm_config_close(cfg);
cleanup_validation_dir(tmp);
PASS();
}
TEST(config_response_format_json_with_toon_override) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
cbm_config_t *cfg = cbm_config_open(tmp);
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_config_set(cfg, CBM_CONFIG_DEFAULT_RESPONSE_FORMAT,
CBM_MCP_OUTPUT_FORMAT_JSON),
0);
ASSERT_TRUE(cbm_config_set(cfg, CBM_CONFIG_DEFAULT_RESPONSE_FORMAT, "yaml") != 0);
cbm_mcp_server_set_config(srv, cfg);
const char *query = "MATCH (n:Function) RETURN n.name LIMIT 2";
char *raw = cbm_mcp_handle_tool(
srv, "query_graph",
"{\"query\":\"MATCH (n:Function) RETURN n.name LIMIT 2\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_EQ(resp[0], '{');
free(resp);
raw = cbm_mcp_handle_tool(srv, "get_graph_schema",
"{\"project\":\"validation-test\"}");
resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_EQ(resp[0], '{');
free(resp);
char args[256];
snprintf(args, sizeof(args), "{\"query\":\"%s\",\"format\":\"toon\"}", query);
raw = cbm_mcp_handle_tool(srv, "query_graph", args);
resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "rows["));
ASSERT_TRUE(resp[0] != '{');
free(resp);
cbm_mcp_server_free(srv);
cbm_config_close(cfg);
cleanup_validation_dir(tmp);
PASS();
}
TEST(toon_first_response_context_is_native_toon) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
cbm_mcp_server_set_session_project(srv, "validation-test");
char *raw = cbm_mcp_handle_tool(
srv, "search_graph",
"{\"name_pattern\":\"alpha\",\"limit\":2,\"format\":\"toon\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
/* A TOON response must never contain a verbatim JSON subdocument. The
* first-response context keeps the same facts under explicit TOON keys. */
ASSERT_NULL(strstr(resp, "context: {"));
ASSERT_NULL(strstr(resp, "{\"_context\":"));
ASSERT_NOT_NULL(strstr(resp, "session_project: validation-test"));
ASSERT_NOT_NULL(strstr(resp, "_context_status: ready"));
ASSERT_NOT_NULL(strstr(resp, "_context_project: validation-test"));
ASSERT_NOT_NULL(strstr(resp, "_context_node_labels["));
ASSERT_NOT_NULL(strstr(resp, "_context_edge_types["));
free(resp);
/* Context is delivered once, while the lightweight session identity is
* retained on later TOON responses just as it is for JSON responses. */
raw = cbm_mcp_handle_tool(
srv, "search_graph",
"{\"name_pattern\":\"beta\",\"limit\":2,\"format\":\"toon\"}");
resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "session_project: validation-test"));
ASSERT_NULL(strstr(resp, "_context_status:"));
ASSERT_NULL(strstr(resp, "_context_node_labels["));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(toon_context_injection_config_is_respected) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
cbm_mcp_server_set_session_project(srv, "validation-test");
cbm_config_t *cfg = cbm_config_open(tmp);
ASSERT_NOT_NULL(cfg);
ASSERT_EQ(cbm_config_set(cfg, "context_injection", "false"), 0);
cbm_mcp_server_set_config(srv, cfg);
char *raw = cbm_mcp_handle_tool(
srv, "search_graph",
"{\"name_pattern\":\"alpha\",\"limit\":2,\"format\":\"toon\"}");
char *resp = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "session_project: validation-test"));
ASSERT_NULL(strstr(resp, "_context_status:"));
ASSERT_NULL(strstr(resp, "_context_node_labels["));
ASSERT_NULL(strstr(resp, "context: {"));
free(resp);
cbm_mcp_server_free(srv);
cbm_config_close(cfg);
cleanup_validation_dir(tmp);
PASS();
}
TEST(graph_schema_formats_preserve_bounded_facts) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
static const char *const labels[] = {"Class", "Interface", "Method", "Field",
"Module", "Variable", "Enum", "Type"};
for (size_t i = 0; i < sizeof(labels) / sizeof(labels[0]); i++) {
char name[64];
char qualified_name[128];
snprintf(name, sizeof(name), "SchemaNode%zu", i);
snprintf(qualified_name, sizeof(qualified_name), "validation-test.schema.%s", name);
cbm_node_t node = {.project = "validation-test",
.label = labels[i],
.name = name,
.qualified_name = qualified_name,
.file_path = "schema-fixture.c",
.start_line = (int)i + 1,
.end_line = (int)i + 1,
.properties_json = "{\"schema_fixture_property\":true}"};
ASSERT_GT(cbm_store_upsert_node(store, &node), 0);
}
char *raw = cbm_mcp_handle_tool(
srv, "get_graph_schema",
"{\"project\":\"validation-test\",\"format\":\"json\"}");
char *json = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(json);
ASSERT_EQ(json[0], '{');
ASSERT_NOT_NULL(strstr(json, "\"node_labels\""));
ASSERT_NOT_NULL(strstr(json, "\"properties\""));
ASSERT_NOT_NULL(strstr(json, "\"property_key_limit_per_label_or_type\":50"));
ASSERT_NOT_NULL(strstr(json, "\"relationship_pattern_limit\":50"));
ASSERT_NOT_NULL(strstr(json, "\"Function\""));
ASSERT_NOT_NULL(strstr(json, "\"CALLS\""));
raw = cbm_mcp_handle_tool(srv, "get_graph_schema",
"{\"project\":\"validation-test\"}");
char *default_toon = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(default_toon);
ASSERT_TRUE(default_toon[0] != '{');
ASSERT_NOT_NULL(strstr(default_toon, "node_labels["));
free(default_toon);
raw = cbm_mcp_handle_tool(
srv, "get_graph_schema",
"{\"project\":\"validation-test\",\"format\":\"toon\"}");
char *toon = extract_text(raw);
free(raw);
ASSERT_NOT_NULL(toon);
ASSERT_TRUE(toon[0] != '{');
ASSERT_NOT_NULL(strstr(toon, "node_base_properties:"));
ASSERT_NOT_NULL(strstr(toon, "property_key_limit_per_label_or_type: 50"));
ASSERT_NOT_NULL(strstr(toon, "relationship_pattern_limit: 50"));
ASSERT_NOT_NULL(strstr(toon, "node_labels["));
ASSERT_NOT_NULL(strstr(toon, "edge_base_properties:"));
ASSERT_NOT_NULL(strstr(toon, "edge_types["));
ASSERT_NOT_NULL(strstr(toon, "relationship_patterns["));
ASSERT_NOT_NULL(strstr(toon, "MATCH (source:Function)-[:CALLS]->(target:Function)"));
ASSERT_NOT_NULL(strstr(toon, "Function"));
ASSERT_NOT_NULL(strstr(toon, "CALLS"));
ASSERT_TRUE(strlen(toon) < strlen(json));
free(toon);
free(json);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Config: default_sort_by=calls
* ══════════════════════════════════════════════════════════════════ */
TEST(config_default_sort_by_calls) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
cbm_config_t *cfg = cbm_config_open(tmp);
ASSERT_NOT_NULL(cfg);
cbm_config_set(cfg, "default_sort_by", "calls");
cbm_mcp_server_set_config(srv, cfg);
char *raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "invalid sort_by")); /* valid sort, no error */
free(resp);
cbm_mcp_server_free(srv);
cbm_config_close(cfg);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* trace_path accepts qualified_name param (Change 3a)
* ══════════════════════════════════════════════════════════════════ */
TEST(trace_accepts_qualified_name_param) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Passing full qualified_name should not error (even if BFS finds 0 callers on test store).
* Must NOT return "function not found" — QN lookup path fires first. */
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"qualified_name\":\"validation-test.test.foo\",\"project\":\"validation-test\",\"direction\":\"outbound\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
/* Should find "foo" node via QN and return trace output, not "function not found" */
ASSERT_NULL(strstr(resp, "\"error\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* pattern= glob wildcard auto-converts to regex (*foo* → .*foo.*)
* ══════════════════════════════════════════════════════════════════ */
TEST(pattern_glob_wildcards_auto_convert) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "*foo*" is not valid regex but valid glob — should auto-convert and find "foo" node */
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"pattern\":\"*foo*\",\"limit\":5,\"format\":\"json\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"error\""));
ASSERT_NOT_NULL(strstr(resp, "\"results\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* pattern= invalid regex that can't be salvaged returns error
* ══════════════════════════════════════════════════════════════════ */
TEST(pattern_invalid_regex_returns_error) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* "[invalid" is not valid regex and not a glob — should return error */
char *raw = cbm_mcp_handle_tool(srv, "search_graph",
"{\"pattern\":\"[invalid\",\"limit\":5}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"error\""));
ASSERT_NOT_NULL(strstr(resp, "invalid regex"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* trace: when both function_name AND qualified_name given, QN takes
* priority (QN-first lookup runs before name-based lookup)
* ══════════════════════════════════════════════════════════════════ */
TEST(trace_qn_takes_priority_over_function_name) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Pass a valid QN and a non-existent function_name.
* QN lookup should find "foo" and succeed; function_name is ignored. */
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"qualified_name\":\"validation-test.test.foo\","
"\"function_name\":\"does_not_exist_anywhere\","
"\"project\":\"validation-test\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
/* QN lookup finds "foo" → no error, trace succeeds */
ASSERT_NULL(strstr(resp, "\"error\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* trace: qualified_name not found returns actionable error hint
* ══════════════════════════════════════════════════════════════════ */
TEST(trace_qn_not_found_returns_specific_hint) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Pass a QN that doesn't exist — should get specific hint about using pattern= */
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"qualified_name\":\"no-such.project.func\","
"\"project\":\"validation-test\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"error\""));
/* Should mention qualified_name in error, not generic function_name hint */
ASSERT_NOT_NULL(strstr(resp, "qualified_name"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* detect_changes: slug project doesn't return "project not found"
* (Tests that get_project_root handles slug args correctly after
* the path-normalization refactor.)
* ══════════════════════════════════════════════════════════════════ */
TEST(detect_changes_slug_project_finds_root) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "detect_changes",
"{\"project\":\"validation-test\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
/* Should find the project root (not "project not found" error) */
ASSERT_NULL(strstr(resp, "\"error\":\"project not found\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* manage_adr: slug project doesn't return "project not found"
* ══════════════════════════════════════════════════════════════════ */
TEST(manage_adr_slug_project_finds_root) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "manage_adr",
"{\"project\":\"validation-test\",\"mode\":\"get\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
/* Should find the project root — either returns ADR content or "not found" for the file,
* but NOT "project not found" (the store lookup error). */
ASSERT_NULL(strstr(resp, "\"error\":\"project not found\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Tilde expansion: project="~/relpath" expands correctly
* (get_project_root uses expand_tilde before realpath)
* ══════════════════════════════════════════════════════════════════ */
TEST(source_search_tilde_project_expands) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Build a project path relative to $HOME (e.g. ~/... pointing to tmp) */
const char *home = getenv("HOME");
if (!home || strncmp(tmp, home, strlen(home)) != 0) {
/* tmp is not under $HOME — skip this test on this machine */
cbm_mcp_server_free(srv); cleanup_validation_dir(tmp);
PASS();
}
/* Compute tilde path: replace $HOME prefix with ~ */
char tilde_path[320];
snprintf(tilde_path, sizeof(tilde_path), "~%s", tmp + strlen(home));
char src_path[320];
snprintf(src_path, sizeof(src_path), "%s/tilde.c", tmp);
FILE *f = fopen(src_path, "w");
if (f) { fputs("/* tilde_expand_token */\n", f); fclose(f); }
/* Pass project as tilde path — should expand to absolute and find root */
char args[512];
snprintf(args, sizeof(args),
"{\"pattern\":\"tilde_expand_token\","
"\"search_in\":\"source\","
"\"project\":\"%s\"}", tilde_path);
char *raw = cbm_mcp_handle_tool(srv, "search_code", args);
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"error\""));
ASSERT_NOT_NULL(strstr(resp, "\"matches\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
TEST(graph_search_tilde_project_autoindexes) {
char fake_home[256];
snprintf(fake_home, sizeof(fake_home), "/tmp/cbm_tilde_home_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(fake_home));
char repo_dir[320];
snprintf(repo_dir, sizeof(repo_dir), "%s/repo", fake_home);
ASSERT_TRUE(cbm_mkdir_p(repo_dir, 0755));
char src_path[360];
snprintf(src_path, sizeof(src_path), "%s/main.c", repo_dir);
FILE *f = fopen(src_path, "w");
ASSERT_NOT_NULL(f);
fputs("void tilde_graph_autoindex_sentinel(void) {}\n", f);
fclose(f);
const char *old_home = getenv("HOME");
const char *old_auto_index = getenv("CBM_AUTO_INDEX");
char *old_home_copy = old_home ? strdup(old_home) : NULL;
char *old_auto_index_copy = old_auto_index ? strdup(old_auto_index) : NULL;
if (old_home) ASSERT_NOT_NULL(old_home_copy);
if (old_auto_index) ASSERT_NOT_NULL(old_auto_index_copy);
cbm_setenv("HOME", fake_home, 1);
cbm_setenv("CBM_AUTO_INDEX", "true", 1);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(
srv, "search_graph",
"{\"project\":\"~/repo\",\"pattern\":\"tilde_graph_autoindex_sentinel\"}");
char *resp = extract_text(raw);
free(raw);
bool has_match = resp && strstr(resp, "tilde_graph_autoindex_sentinel") != NULL;
free(resp);
cbm_mcp_server_free(srv);
if (old_home_copy) {
cbm_setenv("HOME", old_home_copy, 1);
free(old_home_copy);
} else {
cbm_unsetenv("HOME");
}
if (old_auto_index_copy) {
cbm_setenv("CBM_AUTO_INDEX", old_auto_index_copy, 1);
free(old_auto_index_copy);
} else {
cbm_unsetenv("CBM_AUTO_INDEX");
}
cbm_unlink(src_path);
cbm_rmdir(repo_dir);
cbm_rmdir(fake_home);
ASSERT_TRUE(has_match);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* project_is_path: path-format project arg routes through slug
* conversion in get_project_root (regression for path-based project)
* ══════════════════════════════════════════════════════════════════ */
TEST(source_search_no_project_falls_back_to_session) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* Simulate session_project being set (as detect_session would do after initialize) */
cbm_mcp_server_set_session_project(srv, "validation-test");
char src_path[320];
snprintf(src_path, sizeof(src_path), "%s/session.c", tmp);
FILE *f = fopen(src_path, "w");
if (f) { fputs("/* session_fallback_token */\n", f); fclose(f); }
/* No project= arg — get_project_root falls back to session_project */
char *raw = cbm_mcp_handle_tool(srv, "search_code",
"{\"pattern\":\"session_fallback_token\",\"search_in\":\"source\","
"\"format\":\"json\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"error\""));
ASSERT_NOT_NULL(strstr(resp, "\"matches\""));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Path-based auto-index: project= is a full directory path that is
* NOT under session_root (Bug #4 — .gitignore-excluded separate repo)
*
* When project= is an absolute path to an accessible directory that
* hasn't been indexed yet and differs from session_root, codebase-memory
* must auto-index that path directly (not session_root).
* ══════════════════════════════════════════════════════════════════ */
TEST(path_project_auto_indexes_separate_directory) {
/* Create two separate temp dirs:
* session_tmp = first project queried (establishes session_root via public API)
* target_tmp = second project queried (separate path, simulates .gitignore subdir)
*
* Workflow mirrors Bug #4: user queries upstream repo that lives in .gitignore
* of the main project, after the main project session is already active. */
char session_tmp[256];
snprintf(session_tmp, sizeof(session_tmp), "/tmp/cbm_path_ai_sess_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(session_tmp));
char session_src[320];
snprintf(session_src, sizeof(session_src), "%s/main.c", session_tmp);
FILE *fp = fopen(session_src, "w");
if (fp) { fputs("void session_fn(void) {}\n", fp); fclose(fp); }
char target_tmp[256];
snprintf(target_tmp, sizeof(target_tmp), "/tmp/cbm_path_ai_tgt_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(target_tmp));
char target_src[320];
snprintf(target_src, sizeof(target_src), "%s/upstream.c", target_tmp);
fp = fopen(target_src, "w");
if (fp) { fputs("void path_autoindex_sentinel(void) {}\n", fp); fclose(fp); }
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
const char *old_auto_index = getenv("CBM_AUTO_INDEX");
char *old_auto_index_copy = old_auto_index ? strdup(old_auto_index) : NULL;
if (old_auto_index) {
ASSERT_NOT_NULL(old_auto_index_copy);
}
cbm_setenv("CBM_AUTO_INDEX", "true", 1);
/* First query: session project path → establishes session_root internally */
char args1[512];
snprintf(args1, sizeof(args1),
"{\"project\":\"%s\",\"pattern\":\"session_fn\",\"search_in\":\"source\"}",
session_tmp);
char *raw1 = cbm_mcp_handle_tool(srv, "search_code", args1);
free(raw1); /* result not checked — just establishing session_root */
/* Second query: DIFFERENT path — resolve_project_store must auto-index it.
* Use graph search (not source grep) so resolve_project_store runs the
* path-based auto-index and the indexed nodes are searchable. */
char args2[512];
snprintf(args2, sizeof(args2),
"{\"project\":\"%s\",\"pattern\":\"path_autoindex_sentinel\"}", target_tmp);
char *raw2 = cbm_mcp_handle_tool(srv, "search_graph", args2);
char *resp = extract_text(raw2); free(raw2);
bool has_match = resp && strstr(resp, "path_autoindex_sentinel") != NULL;
free(resp);
cbm_mcp_server_free(srv);
if (old_auto_index_copy) {
cbm_setenv("CBM_AUTO_INDEX", old_auto_index_copy, 1);
free(old_auto_index_copy);
} else {
cbm_unsetenv("CBM_AUTO_INDEX");
}
cbm_unlink(target_src);
cbm_rmdir(target_tmp);
cbm_unlink(session_src);
cbm_rmdir(session_tmp);
ASSERT_TRUE(has_match);
PASS();
}
TEST(path_project_autoindex_respects_file_limit) {
char session_tmp[256];
snprintf(session_tmp, sizeof(session_tmp), "/tmp/cbm_path_limit_sess_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(session_tmp));
char session_src[320];
snprintf(session_src, sizeof(session_src), "%s/main.c", session_tmp);
FILE *fp = fopen(session_src, "w");
if (fp) { fputs("void session_limit_fn(void) {}\n", fp); fclose(fp); }
char target_tmp[256];
snprintf(target_tmp, sizeof(target_tmp), "/tmp/cbm_path_limit_tgt_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(target_tmp));
char target_src1[320];
char target_src2[320];
snprintf(target_src1, sizeof(target_src1), "%s/upstream.c", target_tmp);
snprintf(target_src2, sizeof(target_src2), "%s/extra.c", target_tmp);
fp = fopen(target_src1, "w");
if (fp) { fputs("void path_limit_sentinel(void) {}\n", fp); fclose(fp); }
fp = fopen(target_src2, "w");
if (fp) { fputs("void path_limit_extra(void) {}\n", fp); fclose(fp); }
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
const char *old_auto_index = getenv("CBM_AUTO_INDEX");
const char *old_limit = getenv("CBM_AUTO_INDEX_LIMIT");
char *old_auto_index_copy = old_auto_index ? strdup(old_auto_index) : NULL;
char *old_limit_copy = old_limit ? strdup(old_limit) : NULL;
if (old_auto_index) {
ASSERT_NOT_NULL(old_auto_index_copy);
}
if (old_limit) {
ASSERT_NOT_NULL(old_limit_copy);
}
cbm_setenv("CBM_AUTO_INDEX", "true", 1);
cbm_setenv("CBM_AUTO_INDEX_LIMIT", "1", 1);
char args1[512];
snprintf(args1, sizeof(args1),
"{\"project\":\"%s\",\"pattern\":\"session_limit_fn\",\"search_in\":\"source\"}",
session_tmp);
char *raw1 = cbm_mcp_handle_tool(srv, "search_code", args1);
free(raw1);
char args2[512];
snprintf(args2, sizeof(args2),
"{\"project\":\"%s\",\"pattern\":\"path_limit_sentinel\"}", target_tmp);
char *raw2 = cbm_mcp_handle_tool(srv, "search_graph", args2);
char *resp = extract_text(raw2);
free(raw2);
bool has_match = resp && strstr(resp, "path_limit_sentinel") != NULL;
free(resp);
cbm_mcp_server_free(srv);
if (old_auto_index_copy) {
cbm_setenv("CBM_AUTO_INDEX", old_auto_index_copy, 1);
free(old_auto_index_copy);
} else {
cbm_unsetenv("CBM_AUTO_INDEX");
}
if (old_limit_copy) {
cbm_setenv("CBM_AUTO_INDEX_LIMIT", old_limit_copy, 1);
free(old_limit_copy);
} else {
cbm_unsetenv("CBM_AUTO_INDEX_LIMIT");
}
cbm_unlink(target_src1);
cbm_unlink(target_src2);
cbm_rmdir(target_tmp);
cbm_unlink(session_src);
cbm_rmdir(session_tmp);
ASSERT_FALSE(has_match);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Regression: classic tool names still work
* ══════════════════════════════════════════════════════════════════ */
TEST(regression_trace_path_tool_name_still_works) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *raw = cbm_mcp_handle_tool(srv, "trace_path",
"{\"function_name\":\"foo\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "unknown tool")); /* must not reject classic name */
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Config: context_injection=false disables _context header
*
* context_injection (default true) / CBM_CONTEXT_INJECTION controls
* whether inject_context_once embeds the _context header in the first
* tool response. Disabling saves tokens in scripted/programmatic use.
* ══════════════════════════════════════════════════════════════════ */
TEST(config_context_injection_disabled) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
cbm_config_t *cfg = cbm_config_open(tmp);
ASSERT_NOT_NULL(cfg);
cbm_config_set(cfg, "context_injection", "false");
cbm_mcp_server_set_config(srv, cfg);
/* With context_injection=false, _context must NOT appear in any call */
char *raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"_context\":"));
free(resp);
/* Second call also no _context */
raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3}");
resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"_context\":"));
free(resp);
cbm_mcp_server_free(srv);
cbm_config_close(cfg);
cleanup_validation_dir(tmp);
PASS();
}
TEST(config_context_injection_enabled_by_default) {
char tmp[256];
cbm_mcp_server_t *srv = setup_validation_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
/* No config set → default is true → _context present on first call.
* format=json: pins the legacy JSON _context shape; default_response_format
* is toon, which delivers the same facts as native _context_* TOON fields. */
char *raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3,\"format\":\"json\"}");
char *resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NOT_NULL(strstr(resp, "\"_context\":"));
free(resp);
/* Second call: _context deduped (context_injected=true) */
raw = cbm_mcp_handle_tool(srv, "search_graph", "{\"limit\":3,\"format\":\"json\"}");
resp = extract_text(raw); free(raw);
ASSERT_NOT_NULL(resp);
ASSERT_NULL(strstr(resp, "\"_context\":"));
free(resp);
cbm_mcp_server_free(srv);
cleanup_validation_dir(tmp);
PASS();
}
/* ══════════════════════════════════════════════════════════════════
* Suite registration
* ══════════════════════════════════════════════════════════════════ */
void suite_input_validation(void) {
RUN_TEST(f1_empty_label_returns_results);
RUN_TEST(f6_invalid_sort_by_errors);
RUN_TEST(f6_sort_by_typo_errors);
RUN_TEST(f9_invalid_regex_errors);
RUN_TEST(f9_valid_regex_succeeds);
RUN_TEST(f6_sort_by_calls_accepted);
RUN_TEST(f6_sort_by_linkrank_accepted);
RUN_TEST(f9_glob_star_autoconverted);
RUN_TEST(f9_glob_question_autoconverted);
RUN_TEST(f9_valid_regex_shaped_glob_is_normalized_before_compile);
RUN_TEST(f9_valid_regex_still_works);
RUN_TEST(f9_explicit_group_and_class_regex_quantifiers_stay_regex);
RUN_TEST(f9_truly_invalid_pattern_still_errors);
RUN_TEST(f9_qn_pattern_glob_autoconverted);
RUN_TEST(f10_negative_depth_returns_results);
RUN_TEST(trace_case_mismatch_finds_via_fallback);
RUN_TEST(trace_exact_match_still_works);
RUN_TEST(trace_truly_missing_still_errors);
RUN_TEST(f15_invalid_direction_errors);
RUN_TEST(f15_valid_direction_succeeds);
RUN_TEST(trace_invalid_mode_errors);
RUN_TEST(g1_summary_mode_has_results_key);
RUN_TEST(cq3_cypher_with_label_warns);
RUN_TEST(ix2_status_resource_format);
RUN_TEST(pattern_or_search_graph);
RUN_TEST(pattern_or_search_graph_normalizes_valid_regex_shaped_glob);
RUN_TEST(source_search_via_search_in_param);
RUN_TEST(source_search_path_project_normalizes_to_slug);
RUN_TEST(source_search_default_is_graph);
RUN_TEST(summary_bool_alias);
RUN_TEST(case_sensitive_graph_search);
RUN_TEST(config_compact_default_false);
RUN_TEST(config_response_format_json_with_toon_override);
RUN_TEST(toon_first_response_context_is_native_toon);
RUN_TEST(toon_context_injection_config_is_respected);
RUN_TEST(graph_schema_formats_preserve_bounded_facts);
RUN_TEST(config_default_sort_by_calls);
RUN_TEST(trace_accepts_qualified_name_param);
RUN_TEST(pattern_glob_wildcards_auto_convert);
RUN_TEST(pattern_invalid_regex_returns_error);
RUN_TEST(trace_qn_takes_priority_over_function_name);
RUN_TEST(trace_qn_not_found_returns_specific_hint);
RUN_TEST(detect_changes_slug_project_finds_root);
RUN_TEST(manage_adr_slug_project_finds_root);
RUN_TEST(source_search_tilde_project_expands);
RUN_TEST(graph_search_tilde_project_autoindexes);
RUN_TEST(source_search_no_project_falls_back_to_session);
RUN_TEST(path_project_auto_indexes_separate_directory);
RUN_TEST(path_project_autoindex_respects_file_limit);
RUN_TEST(regression_trace_path_tool_name_still_works);
RUN_TEST(config_context_injection_disabled);
RUN_TEST(config_context_injection_enabled_by_default);
}