mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 18:58:00 +00:00
fix(mcp): preserve lean output continuations
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
1 parent
3909e5a0e1
commit
e842fd78cf
3 files changed
+1209
-312
No files matched your search
@@ -633,7 +633,7 @@ When stderr is an interactive terminal, the CLI automatically shows lifecycle an
|
||||
|
||||
Large compact-tree tables may start with a response-local `<section>_refs` directory and an explicit `<section>_ref_rule`. A cell such as `@0+handler.go` reconstructs to ref `0`'s prefix plus `handler.go`. References are local to that sibling `<section>` table and expansion is non-recursive: entries inside `<section>_refs` are always literal prefixes. This is limited to declared path and qualified-name columns and activates only when that table is at least 15% and 64 bytes smaller. Search and trace likewise render direct and prefix-grouped tree shapes and keep the smaller complete representation, so singleton or scattered answers do not pay directory overhead. Keys are declared once per table but never cryptically abbreviated, and `--format json` keeps stable literal strings for machine consumers. These are deterministic model-neutral byte savings that normally reduce tokens; exact token counts still depend on the caller's tokenizer.
|
||||
|
||||
Lean responses truncate semantically, never by cutting arbitrary bytes from code or identifiers. Ranked graph rows are retained ahead of raw grep rows and diagnostic summaries; omitted rows/sections report totals, `has_more`, and a strictly advancing continuation offset or cursor. If even the first whole row cannot fit, CBM asks for a higher budget and emits no self-looping cursor. `max_output_tokens` is model-neutral sizing guidance: CBM enforces a deterministic ceiling of four UTF-8 bytes per requested token, so it is not a tokenizer-exact count. Detail flags such as `diagnostics`, `source_mode`, and `detail` opt into heavier fields. `search_code` additionally exposes `raw_limit`, `directory_limit`, `match_limit`, and `source_max_lines`; its default retains eight match locations per graph result and reports the exact `matches_omitted` remainder. `detect_changes` pages changed files, impacted symbols, and module summaries independently.
|
||||
Lean responses truncate semantically, never by cutting arbitrary bytes from code or identifiers. Ranked graph rows are retained ahead of raw grep rows and diagnostic summaries; omitted rows/sections report totals, `has_more`, and a strictly advancing continuation offset or cursor. If even the first whole row cannot fit, CBM asks for a higher budget and emits no self-looping cursor. `max_output_tokens` is model-neutral sizing guidance: CBM enforces a deterministic ceiling of four UTF-8 bytes per requested token, so it is not a tokenizer-exact count. Detail flags such as `diagnostics`, `source_mode`, and `detail` opt into heavier fields. `search_code` additionally exposes `raw_limit`/`raw_offset`, `directory_limit`/`directory_offset`, `match_limit`, and `source_max_lines`; its default retains eight match locations per graph result and reports the exact `matches_omitted` remainder. The ranked graph answer stays intact while raw matches and directory summaries page independently. `detect_changes` pages changed files, impacted symbols, and module summaries independently.
|
||||
|
||||
Use `cli <tool> --help` to see the flags generated from that tool's input schema:
|
||||
|
||||
|
||||
+589
-309
File diff suppressed because it is too large.
Load diff
+619
-2
@@ -593,7 +593,7 @@ static int mcp_count_corrupt_artifacts(const char *cache, const char *project) {
|
||||
static int mcp_count_directory_entries_with_prefix(const char *directory, const char *prefix) {
|
||||
cbm_dir_t *dir = cbm_opendir(directory);
|
||||
if (!dir) {
|
||||
return -1;
|
||||
return 0;
|
||||
}
|
||||
size_t prefix_length = strlen(prefix);
|
||||
int count = 0;
|
||||
@@ -7554,6 +7554,130 @@ TEST(search_code_default_budget_limits_raw_rows_before_graph_results) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(search_code_raw_and_directory_remainders_are_independently_pageable) {
|
||||
char tmp[256];
|
||||
snprintf(tmp, sizeof(tmp), "%s/cbm_srch_section_pages_XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
|
||||
|
||||
const char *directories[] = {"graph-a", "graph-b"};
|
||||
for (size_t i = 0; i < sizeof(directories) / sizeof(directories[0]); i++) {
|
||||
char path[CBM_SZ_4K];
|
||||
snprintf(path, sizeof(path), "%s/%s", tmp, directories[i]);
|
||||
ASSERT_EQ(cbm_mkdir(path), 0);
|
||||
}
|
||||
|
||||
const char *relative_paths[] = {"graph-a/route.c", "graph-b/function.c", "raw-a.txt",
|
||||
"raw-b.txt"};
|
||||
for (size_t i = 0; i < sizeof(relative_paths) / sizeof(relative_paths[0]); i++) {
|
||||
char path[CBM_SZ_4K];
|
||||
snprintf(path, sizeof(path), "%s/%s", tmp, relative_paths[i]);
|
||||
ASSERT_EQ(th_write_file(path, i < 2 ? "int PAGE_NEEDLE_graph = 1;\n"
|
||||
: "int PAGE_NEEDLE_raw = 1;\nint sentinel = 0;\n"),
|
||||
0);
|
||||
}
|
||||
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||||
ASSERT_NOT_NULL(store);
|
||||
const char *project = "search-section-pages";
|
||||
cbm_mcp_server_set_project(srv, project);
|
||||
ASSERT_EQ(cbm_store_upsert_project(store, project, tmp), CBM_STORE_OK);
|
||||
|
||||
cbm_node_t nodes[] = {
|
||||
{.project = project,
|
||||
.label = "Route",
|
||||
.name = "route_hit",
|
||||
.qualified_name = "fixture.route_hit",
|
||||
.file_path = "graph-a/route.c",
|
||||
.start_line = 1,
|
||||
.end_line = 1},
|
||||
{.project = project,
|
||||
.label = "Function",
|
||||
.name = "function_hit",
|
||||
.qualified_name = "fixture.function_hit",
|
||||
.file_path = "graph-b/function.c",
|
||||
.start_line = 1,
|
||||
.end_line = 1},
|
||||
/* These nodes put the files in the scoped grep list, but deliberately
|
||||
* do not cover the matching first line, leaving one raw row per file. */
|
||||
{.project = project,
|
||||
.label = "Variable",
|
||||
.name = "raw_a_sentinel",
|
||||
.qualified_name = "fixture.raw_a_sentinel",
|
||||
.file_path = "raw-a.txt",
|
||||
.start_line = 2,
|
||||
.end_line = 2},
|
||||
{.project = project,
|
||||
.label = "Variable",
|
||||
.name = "raw_b_sentinel",
|
||||
.qualified_name = "fixture.raw_b_sentinel",
|
||||
.file_path = "raw-b.txt",
|
||||
.start_line = 2,
|
||||
.end_line = 2},
|
||||
};
|
||||
for (size_t i = 0; i < sizeof(nodes) / sizeof(nodes[0]); i++) {
|
||||
ASSERT_GT(cbm_store_upsert_node(store, &nodes[i]), 0);
|
||||
}
|
||||
|
||||
char *first_response = cbm_mcp_handle_tool(
|
||||
srv, "search_code",
|
||||
"{\"pattern\":\"PAGE_NEEDLE\",\"project\":\"search-section-pages\","
|
||||
"\"limit\":2,\"raw_limit\":1,\"directory_limit\":1,"
|
||||
"\"max_output_tokens\":10000,\"format\":\"json\"}");
|
||||
char *first_inner = extract_text_content(first_response);
|
||||
yyjson_doc *first_doc =
|
||||
first_inner ? yyjson_read(first_inner, strlen(first_inner), 0) : NULL;
|
||||
yyjson_val *first_root = first_doc ? yyjson_doc_get_root(first_doc) : NULL;
|
||||
yyjson_val *first_raw = first_root ? yyjson_obj_get(first_root, "raw_matches") : NULL;
|
||||
yyjson_val *first_raw_rows = first_raw ? yyjson_obj_get(first_raw, "rows") : NULL;
|
||||
yyjson_val *first_raw_row = first_raw_rows ? yyjson_arr_get(first_raw_rows, 0) : NULL;
|
||||
yyjson_val *first_directories =
|
||||
first_root ? yyjson_obj_get(first_root, "directories") : NULL;
|
||||
bool first_page_bound =
|
||||
first_root && yyjson_get_int(yyjson_obj_get(first_root, "raw_next_offset")) == 1 &&
|
||||
yyjson_get_int(yyjson_obj_get(first_root, "directory_next_offset")) == 1 &&
|
||||
first_raw_row && strcmp(yyjson_get_str(yyjson_arr_get(first_raw_row, 0)), "raw-a.txt") == 0 &&
|
||||
yyjson_obj_get(first_directories, "graph-a/") != NULL &&
|
||||
yyjson_obj_get(first_directories, "graph-b/") == NULL;
|
||||
|
||||
char *second_response = cbm_mcp_handle_tool(
|
||||
srv, "search_code",
|
||||
"{\"pattern\":\"PAGE_NEEDLE\",\"project\":\"search-section-pages\","
|
||||
"\"limit\":2,\"raw_limit\":1,\"raw_offset\":1,"
|
||||
"\"directory_limit\":1,\"directory_offset\":1,"
|
||||
"\"max_output_tokens\":10000,\"format\":\"json\"}");
|
||||
char *second_inner = extract_text_content(second_response);
|
||||
yyjson_doc *second_doc =
|
||||
second_inner ? yyjson_read(second_inner, strlen(second_inner), 0) : NULL;
|
||||
yyjson_val *second_root = second_doc ? yyjson_doc_get_root(second_doc) : NULL;
|
||||
yyjson_val *second_raw = second_root ? yyjson_obj_get(second_root, "raw_matches") : NULL;
|
||||
yyjson_val *second_raw_rows = second_raw ? yyjson_obj_get(second_raw, "rows") : NULL;
|
||||
yyjson_val *second_raw_row = second_raw_rows ? yyjson_arr_get(second_raw_rows, 0) : NULL;
|
||||
yyjson_val *second_directories =
|
||||
second_root ? yyjson_obj_get(second_root, "directories") : NULL;
|
||||
bool remainders_reachable =
|
||||
second_root && second_raw_row &&
|
||||
strcmp(yyjson_get_str(yyjson_arr_get(second_raw_row, 0)), "raw-b.txt") == 0 &&
|
||||
yyjson_obj_get(second_directories, "graph-a/") == NULL &&
|
||||
yyjson_obj_get(second_directories, "graph-b/") != NULL &&
|
||||
!yyjson_get_bool(yyjson_obj_get(second_root, "raw_has_more")) &&
|
||||
!yyjson_get_bool(yyjson_obj_get(second_root, "directories_has_more"));
|
||||
|
||||
yyjson_doc_free(second_doc);
|
||||
free(second_inner);
|
||||
free(second_response);
|
||||
yyjson_doc_free(first_doc);
|
||||
free(first_inner);
|
||||
free(first_response);
|
||||
cbm_mcp_server_free(srv);
|
||||
ASSERT_EQ(th_rmtree(tmp), 0);
|
||||
|
||||
ASSERT_TRUE(first_page_bound);
|
||||
ASSERT_TRUE(remainders_reachable);
|
||||
PASS();
|
||||
}
|
||||
|
||||
static char *search_code_long_identity(char fill, bool path) {
|
||||
enum { LONG_IDENTITY_LENGTH = 2304, FIRST_DIRECTORY_LENGTH = 200 };
|
||||
char *value = malloc(LONG_IDENTITY_LENGTH + 1U);
|
||||
@@ -11591,6 +11715,416 @@ TEST(tool_detect_changes_not_found_rich_error) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(tool_detect_changes_invalid_base_is_an_error) {
|
||||
char repo[CBM_SZ_4K];
|
||||
snprintf(repo, sizeof(repo), "%s/cbm-detect-invalid-base-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(repo));
|
||||
char cache[CBM_SZ_4K];
|
||||
snprintf(cache, sizeof(cache), "%s/cbm-detect-invalid-base-cache-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
|
||||
const char *const init_args[] = {"init", "-q", NULL};
|
||||
const char *const commit_args[] = {
|
||||
"-c", "user.name=cbm-test",
|
||||
"-c", "user.email=cbm-test@example.invalid",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "--allow-empty",
|
||||
"-q", "-m",
|
||||
"fixture", NULL,
|
||||
};
|
||||
ASSERT_EQ(mcp_test_git(repo, init_args), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, commit_args), 0);
|
||||
|
||||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||||
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0);
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||||
ASSERT_NOT_NULL(store);
|
||||
const char *project = "detect-invalid-base-project";
|
||||
ASSERT_EQ(cbm_store_upsert_project(store, project, repo), CBM_STORE_OK);
|
||||
cbm_mcp_server_set_project(srv, project);
|
||||
|
||||
char *response = cbm_mcp_handle_tool(
|
||||
srv, "detect_changes",
|
||||
"{\"project\":\"detect-invalid-base-project\","
|
||||
"\"base_branch\":\"refs/heads/cbm-base-that-does-not-exist\","
|
||||
"\"scope\":\"files\",\"format\":\"json\"}");
|
||||
bool errored = response_contains_json_fragment(response, "\"isError\":true");
|
||||
|
||||
free(response);
|
||||
cbm_mcp_server_free(srv);
|
||||
restore_cache_dir(saved_cache_copy);
|
||||
free(saved_cache_copy);
|
||||
ASSERT_EQ(th_rmtree(cache), 0);
|
||||
ASSERT_EQ(th_rmtree(repo), 0);
|
||||
|
||||
ASSERT_TRUE(errored);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(tool_detect_changes_preserves_utf8_git_path_and_impact_seed) {
|
||||
char repo[CBM_SZ_4K];
|
||||
snprintf(repo, sizeof(repo), "%s/cbm-detect-utf8-path-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(repo));
|
||||
char cache[CBM_SZ_4K];
|
||||
snprintf(cache, sizeof(cache), "%s/cbm-detect-utf8-path-cache-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
|
||||
static const char utf8_path[] = "caf" "\xC3\xA9" ".c";
|
||||
char source_path[CBM_SZ_4K];
|
||||
snprintf(source_path, sizeof(source_path), "%s/%s", repo, utf8_path);
|
||||
ASSERT_EQ(th_write_file(source_path, "int cafe_value = 1;\n"), 0);
|
||||
|
||||
const char *const init_args[] = {"init", "-q", NULL};
|
||||
const char *const add_args[] = {"add", "-A", NULL};
|
||||
const char *const commit_args[] = {
|
||||
"-c", "user.name=cbm-test",
|
||||
"-c", "user.email=cbm-test@example.invalid",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "-q",
|
||||
"-m", "fixture",
|
||||
NULL,
|
||||
};
|
||||
const char *const quote_path_args[] = {"config", "core.quotePath", "true", NULL};
|
||||
ASSERT_EQ(mcp_test_git(repo, init_args), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, add_args), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, commit_args), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, quote_path_args), 0);
|
||||
ASSERT_EQ(th_write_file(source_path, "int cafe_value = 2;\n"), 0);
|
||||
|
||||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||||
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0);
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||||
ASSERT_NOT_NULL(store);
|
||||
const char *project = "detect-utf8-path-project";
|
||||
ASSERT_EQ(cbm_store_upsert_project(store, project, repo), CBM_STORE_OK);
|
||||
cbm_mcp_server_set_project(srv, project);
|
||||
|
||||
cbm_node_t seed = {.project = project,
|
||||
.label = "Function",
|
||||
.name = "cafe_value",
|
||||
.qualified_name = "fixture.cafe_value",
|
||||
.file_path = utf8_path,
|
||||
.start_line = 1,
|
||||
.end_line = 1};
|
||||
int64_t seed_id = cbm_store_upsert_node(store, &seed);
|
||||
ASSERT_GT(seed_id, 0);
|
||||
cbm_node_t caller = {.project = project,
|
||||
.label = "Function",
|
||||
.name = "cafe_caller",
|
||||
.qualified_name = "fixture.cafe_caller",
|
||||
.file_path = "caller.c",
|
||||
.start_line = 1,
|
||||
.end_line = 1};
|
||||
int64_t caller_id = cbm_store_upsert_node(store, &caller);
|
||||
ASSERT_GT(caller_id, 0);
|
||||
cbm_edge_t edge = {.project = project,
|
||||
.source_id = caller_id,
|
||||
.target_id = seed_id,
|
||||
.type = "CALLS"};
|
||||
ASSERT_GT(cbm_store_insert_edge(store, &edge), 0);
|
||||
|
||||
char *response = cbm_mcp_handle_tool(
|
||||
srv, "detect_changes",
|
||||
"{\"project\":\"detect-utf8-path-project\",\"base_branch\":\"HEAD\","
|
||||
"\"scope\":\"impact\",\"depth\":1,\"max_output_tokens\":10000,"
|
||||
"\"format\":\"json\"}");
|
||||
char *inner = extract_text_content(response);
|
||||
yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL;
|
||||
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
|
||||
yyjson_val *changed_files = root ? yyjson_obj_get(root, "changed_files") : NULL;
|
||||
yyjson_val *changed_path = changed_files ? yyjson_arr_get(changed_files, 0) : NULL;
|
||||
yyjson_val *impacted = root ? yyjson_obj_get(root, "impacted") : NULL;
|
||||
yyjson_val *first_impact = impacted ? yyjson_arr_get(impacted, 0) : NULL;
|
||||
bool path_preserved = changed_path && yyjson_is_str(changed_path) &&
|
||||
strcmp(yyjson_get_str(changed_path), utf8_path) == 0;
|
||||
bool seed_found = root && yyjson_get_int(yyjson_obj_get(root, "seed_symbols")) == 1;
|
||||
bool impact_found =
|
||||
first_impact &&
|
||||
strcmp(yyjson_get_str(yyjson_obj_get(first_impact, "qn")), "fixture.cafe_caller") == 0;
|
||||
|
||||
yyjson_doc_free(doc);
|
||||
free(inner);
|
||||
free(response);
|
||||
cbm_mcp_server_free(srv);
|
||||
restore_cache_dir(saved_cache_copy);
|
||||
free(saved_cache_copy);
|
||||
ASSERT_EQ(th_rmtree(cache), 0);
|
||||
ASSERT_EQ(th_rmtree(repo), 0);
|
||||
|
||||
ASSERT_TRUE(path_preserved);
|
||||
ASSERT_TRUE(seed_found);
|
||||
ASSERT_TRUE(impact_found);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(tool_detect_changes_finds_nested_untracked_file_and_impact_seed) {
|
||||
char repo[CBM_SZ_4K];
|
||||
snprintf(repo, sizeof(repo), "%s/cbm-detect-untracked-path-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(repo));
|
||||
char cache[CBM_SZ_4K];
|
||||
snprintf(cache, sizeof(cache), "%s/cbm-detect-untracked-cache-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
|
||||
const char *const init_args[] = {"init", "-q", NULL};
|
||||
const char *const add_args[] = {"add", "-A", NULL};
|
||||
const char *const commit_args[] = {
|
||||
"-c", "user.name=cbm-test",
|
||||
"-c", "user.email=cbm-test@example.invalid",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "-q",
|
||||
"-m", "fixture",
|
||||
NULL,
|
||||
};
|
||||
ASSERT_EQ(mcp_test_git(repo, init_args), 0);
|
||||
/* Track mcp_test_git's isolated config so the nested source is the only
|
||||
* worktree change below. */
|
||||
ASSERT_EQ(mcp_test_git(repo, add_args), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, commit_args), 0);
|
||||
|
||||
char nested_directory[CBM_SZ_4K];
|
||||
snprintf(nested_directory, sizeof(nested_directory), "%s/newdir", repo);
|
||||
ASSERT_EQ(cbm_mkdir(nested_directory), 0);
|
||||
char nested_source[CBM_SZ_4K];
|
||||
snprintf(nested_source, sizeof(nested_source), "%s/new.c", nested_directory);
|
||||
ASSERT_EQ(th_write_file(nested_source, "int untracked_seed(void) { return 1; }\n"), 0);
|
||||
|
||||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||||
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0);
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||||
ASSERT_NOT_NULL(store);
|
||||
const char *project = "detect-untracked-path-project";
|
||||
ASSERT_EQ(cbm_store_upsert_project(store, project, repo), CBM_STORE_OK);
|
||||
cbm_mcp_server_set_project(srv, project);
|
||||
|
||||
cbm_node_t seed = {.project = project,
|
||||
.label = "Function",
|
||||
.name = "untracked_seed",
|
||||
.qualified_name = "fixture.untracked_seed",
|
||||
.file_path = "newdir/new.c",
|
||||
.start_line = 1,
|
||||
.end_line = 1};
|
||||
int64_t seed_id = cbm_store_upsert_node(store, &seed);
|
||||
ASSERT_GT(seed_id, 0);
|
||||
cbm_node_t caller = {.project = project,
|
||||
.label = "Function",
|
||||
.name = "untracked_caller",
|
||||
.qualified_name = "fixture.untracked_caller",
|
||||
.file_path = "caller.c",
|
||||
.start_line = 1,
|
||||
.end_line = 1};
|
||||
int64_t caller_id = cbm_store_upsert_node(store, &caller);
|
||||
ASSERT_GT(caller_id, 0);
|
||||
cbm_edge_t edge = {.project = project,
|
||||
.source_id = caller_id,
|
||||
.target_id = seed_id,
|
||||
.type = "CALLS"};
|
||||
ASSERT_GT(cbm_store_insert_edge(store, &edge), 0);
|
||||
|
||||
char *response = cbm_mcp_handle_tool(
|
||||
srv, "detect_changes",
|
||||
"{\"project\":\"detect-untracked-path-project\",\"base_branch\":\"HEAD\","
|
||||
"\"scope\":\"impact\",\"depth\":1,\"max_output_tokens\":10000,"
|
||||
"\"format\":\"json\"}");
|
||||
char *inner = extract_text_content(response);
|
||||
yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL;
|
||||
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
|
||||
yyjson_val *changed_files = root ? yyjson_obj_get(root, "changed_files") : NULL;
|
||||
yyjson_val *changed_path = changed_files ? yyjson_arr_get(changed_files, 0) : NULL;
|
||||
yyjson_val *impacted = root ? yyjson_obj_get(root, "impacted") : NULL;
|
||||
yyjson_val *first_impact = impacted ? yyjson_arr_get(impacted, 0) : NULL;
|
||||
bool exact_nested_path =
|
||||
root && yyjson_get_int(yyjson_obj_get(root, "changed_total")) == 1 && changed_path &&
|
||||
strcmp(yyjson_get_str(changed_path), "newdir/new.c") == 0;
|
||||
bool seed_found = root && yyjson_get_int(yyjson_obj_get(root, "seed_symbols")) == 1;
|
||||
bool impact_found =
|
||||
first_impact &&
|
||||
strcmp(yyjson_get_str(yyjson_obj_get(first_impact, "qn")),
|
||||
"fixture.untracked_caller") == 0;
|
||||
|
||||
yyjson_doc_free(doc);
|
||||
free(inner);
|
||||
free(response);
|
||||
cbm_mcp_server_free(srv);
|
||||
restore_cache_dir(saved_cache_copy);
|
||||
free(saved_cache_copy);
|
||||
ASSERT_EQ(th_rmtree(cache), 0);
|
||||
ASSERT_EQ(th_rmtree(repo), 0);
|
||||
|
||||
ASSERT_TRUE(exact_nested_path);
|
||||
ASSERT_TRUE(seed_found);
|
||||
ASSERT_TRUE(impact_found);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(tool_detect_changes_escapes_newline_path_in_tree_and_round_trips_json) {
|
||||
#ifdef _WIN32
|
||||
/* Win32 rejects control characters in filenames, so Windows cannot create
|
||||
* this valid POSIX Git-path fixture. The NUL parser itself is exercised by
|
||||
* the portable UTF-8 and nested-untracked cases above. */
|
||||
PASS();
|
||||
#else
|
||||
char repo[CBM_SZ_4K];
|
||||
snprintf(repo, sizeof(repo), "%s/cbm-detect-newline-path-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(repo));
|
||||
char cache[CBM_SZ_4K];
|
||||
snprintf(cache, sizeof(cache), "%s/cbm-detect-newline-cache-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
|
||||
const char *const init_args[] = {"init", "-q", NULL};
|
||||
const char *const add_args[] = {"add", "-A", NULL};
|
||||
const char *const commit_args[] = {
|
||||
"-c", "user.name=cbm-test",
|
||||
"-c", "user.email=cbm-test@example.invalid",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "-q",
|
||||
"-m", "fixture",
|
||||
NULL,
|
||||
};
|
||||
ASSERT_EQ(mcp_test_git(repo, init_args), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, add_args), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, commit_args), 0);
|
||||
|
||||
static const char newline_path[] = "safe.c\nseed_symbols: 777";
|
||||
char source_path[CBM_SZ_4K];
|
||||
snprintf(source_path, sizeof(source_path), "%s/%s", repo, newline_path);
|
||||
ASSERT_EQ(th_write_file(source_path, "int newline_path;\n"), 0);
|
||||
|
||||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||||
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0);
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||||
ASSERT_NOT_NULL(store);
|
||||
const char *project = "detect-newline-path-project";
|
||||
ASSERT_EQ(cbm_store_upsert_project(store, project, repo), CBM_STORE_OK);
|
||||
cbm_mcp_server_set_project(srv, project);
|
||||
|
||||
char *tree_response = cbm_mcp_handle_tool(
|
||||
srv, "detect_changes",
|
||||
"{\"project\":\"detect-newline-path-project\",\"base_branch\":\"HEAD\","
|
||||
"\"scope\":\"files\",\"max_output_tokens\":10000}");
|
||||
char *tree = extract_text_content(tree_response);
|
||||
bool escaped_single_cell =
|
||||
tree && strstr(tree, "\"safe.c\\nseed_symbols: 777\"") != NULL;
|
||||
bool no_injected_scalar =
|
||||
tree && strstr(tree, "\nseed_symbols: 777\n") == NULL;
|
||||
|
||||
char *json_response = cbm_mcp_handle_tool(
|
||||
srv, "detect_changes",
|
||||
"{\"project\":\"detect-newline-path-project\",\"base_branch\":\"HEAD\","
|
||||
"\"scope\":\"files\",\"max_output_tokens\":10000,\"format\":\"json\"}");
|
||||
char *json = extract_text_content(json_response);
|
||||
yyjson_doc *doc = json ? yyjson_read(json, strlen(json), 0) : NULL;
|
||||
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
|
||||
yyjson_val *changed_files = root ? yyjson_obj_get(root, "changed_files") : NULL;
|
||||
yyjson_val *changed_path = changed_files ? yyjson_arr_get(changed_files, 0) : NULL;
|
||||
bool json_round_trip =
|
||||
root && yyjson_get_int(yyjson_obj_get(root, "changed_total")) == 1 && changed_path &&
|
||||
strcmp(yyjson_get_str(changed_path), newline_path) == 0;
|
||||
|
||||
yyjson_doc_free(doc);
|
||||
free(json);
|
||||
free(json_response);
|
||||
free(tree);
|
||||
free(tree_response);
|
||||
cbm_mcp_server_free(srv);
|
||||
restore_cache_dir(saved_cache_copy);
|
||||
free(saved_cache_copy);
|
||||
ASSERT_EQ(th_rmtree(cache), 0);
|
||||
ASSERT_EQ(th_rmtree(repo), 0);
|
||||
|
||||
ASSERT_TRUE(escaped_single_cell);
|
||||
ASSERT_TRUE(no_injected_scalar);
|
||||
ASSERT_TRUE(json_round_trip);
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
TEST(tool_detect_changes_staged_rename_uses_exact_destination_record) {
|
||||
#ifdef _WIN32
|
||||
/* The literal '>' used to catch legacy "old -> new" splitting is not a
|
||||
* valid Win32 filename byte, so this Git-path fixture is POSIX-only. */
|
||||
PASS();
|
||||
#else
|
||||
char repo[CBM_SZ_4K];
|
||||
snprintf(repo, sizeof(repo), "%s/cbm-detect-rename-path-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(repo));
|
||||
char cache[CBM_SZ_4K];
|
||||
snprintf(cache, sizeof(cache), "%s/cbm-detect-rename-cache-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
|
||||
const char *const init_args[] = {"init", "-q", NULL};
|
||||
const char *const add_args[] = {"add", "-A", NULL};
|
||||
const char *const commit_args[] = {
|
||||
"-c", "user.name=cbm-test",
|
||||
"-c", "user.email=cbm-test@example.invalid",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "-q",
|
||||
"-m", "fixture",
|
||||
NULL,
|
||||
};
|
||||
ASSERT_EQ(mcp_test_git(repo, init_args), 0);
|
||||
char old_path[CBM_SZ_4K];
|
||||
snprintf(old_path, sizeof(old_path), "%s/old.c", repo);
|
||||
ASSERT_EQ(th_write_file(old_path, "int renamed_value;\n"), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, add_args), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, commit_args), 0);
|
||||
|
||||
static const char destination[] = "renamed -> destination.c";
|
||||
char destination_path[CBM_SZ_4K];
|
||||
snprintf(destination_path, sizeof(destination_path), "%s/%s", repo, destination);
|
||||
ASSERT_EQ(cbm_rename_replace(old_path, destination_path), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, add_args), 0);
|
||||
|
||||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||||
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0);
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||||
ASSERT_NOT_NULL(store);
|
||||
const char *project = "detect-rename-path-project";
|
||||
ASSERT_EQ(cbm_store_upsert_project(store, project, repo), CBM_STORE_OK);
|
||||
cbm_mcp_server_set_project(srv, project);
|
||||
|
||||
char *response = cbm_mcp_handle_tool(
|
||||
srv, "detect_changes",
|
||||
"{\"project\":\"detect-rename-path-project\",\"base_branch\":\"HEAD\","
|
||||
"\"scope\":\"files\",\"max_output_tokens\":10000,\"format\":\"json\"}");
|
||||
char *inner = extract_text_content(response);
|
||||
yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL;
|
||||
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
|
||||
yyjson_val *changed_files = root ? yyjson_obj_get(root, "changed_files") : NULL;
|
||||
yyjson_val *changed_path = changed_files ? yyjson_arr_get(changed_files, 0) : NULL;
|
||||
bool exact_destination =
|
||||
root && yyjson_get_int(yyjson_obj_get(root, "changed_total")) == 1 && changed_files &&
|
||||
yyjson_arr_size(changed_files) == 1 && changed_path &&
|
||||
strcmp(yyjson_get_str(changed_path), destination) == 0;
|
||||
|
||||
yyjson_doc_free(doc);
|
||||
free(inner);
|
||||
free(response);
|
||||
cbm_mcp_server_free(srv);
|
||||
restore_cache_dir(saved_cache_copy);
|
||||
free(saved_cache_copy);
|
||||
ASSERT_EQ(th_rmtree(cache), 0);
|
||||
ASSERT_EQ(th_rmtree(repo), 0);
|
||||
|
||||
ASSERT_TRUE(exact_destination);
|
||||
PASS();
|
||||
#endif
|
||||
}
|
||||
|
||||
/* detect_changes owns shell output through regular temporary files. An error
|
||||
* after opening that file must use fclose + unlink. The command hook then
|
||||
* rejects merge-base only when it reaches the contained subprocess helper, so
|
||||
@@ -11753,7 +12287,7 @@ TEST(tool_detect_changes_contained_commands_clean_up_error_and_success) {
|
||||
ASSERT_EQ(artifacts_after_rejection, 0);
|
||||
ASSERT_TRUE(merge_base_reported);
|
||||
ASSERT_EQ(artifacts_after_success, 0);
|
||||
ASSERT_EQ(command_probe.diff_calls, 3);
|
||||
ASSERT_EQ(command_probe.diff_calls, 4);
|
||||
ASSERT_EQ(command_probe.merge_base_calls, 2);
|
||||
ASSERT_TRUE(cleaned);
|
||||
ASSERT_TRUE(repo_cleaned);
|
||||
@@ -12031,6 +12565,82 @@ TEST(tool_detect_changes_pages_changed_files_and_honors_semantic_budget) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(tool_detect_changes_output_budget_sets_truncated_in_tree_and_json) {
|
||||
char repo[CBM_SZ_4K];
|
||||
snprintf(repo, sizeof(repo), "%s/cbm-detect-budget-flag-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(repo));
|
||||
char cache[CBM_SZ_4K];
|
||||
snprintf(cache, sizeof(cache), "%s/cbm-detect-budget-flag-cache-XXXXXX", cbm_tmpdir());
|
||||
ASSERT_NOT_NULL(cbm_mkdtemp(cache));
|
||||
|
||||
const char *const init_args[] = {"init", "-q", NULL};
|
||||
const char *const commit_args[] = {
|
||||
"-c", "user.name=cbm-test",
|
||||
"-c", "user.email=cbm-test@example.invalid",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "--allow-empty",
|
||||
"-q", "-m",
|
||||
"fixture", NULL,
|
||||
};
|
||||
ASSERT_EQ(mcp_test_git(repo, init_args), 0);
|
||||
ASSERT_EQ(mcp_test_git(repo, commit_args), 0);
|
||||
|
||||
char filler[181];
|
||||
memset(filler, 'x', sizeof(filler) - 1U);
|
||||
filler[sizeof(filler) - 1U] = '\0';
|
||||
for (int i = 0; i < 8; i++) {
|
||||
char path[CBM_SZ_4K];
|
||||
snprintf(path, sizeof(path), "%s/change-%02d-%s.c", repo, i, filler);
|
||||
ASSERT_EQ(th_write_file(path, "int changed;\n"), 0);
|
||||
}
|
||||
|
||||
const char *saved_cache = getenv("CBM_CACHE_DIR");
|
||||
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
|
||||
ASSERT_EQ(cbm_setenv("CBM_CACHE_DIR", cache, 1), 0);
|
||||
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
|
||||
ASSERT_NOT_NULL(srv);
|
||||
cbm_store_t *store = cbm_mcp_server_store(srv);
|
||||
ASSERT_NOT_NULL(store);
|
||||
const char *project = "detect-budget-flag-project";
|
||||
ASSERT_EQ(cbm_store_upsert_project(store, project, repo), CBM_STORE_OK);
|
||||
cbm_mcp_server_set_project(srv, project);
|
||||
|
||||
char *tree_response = cbm_mcp_handle_tool(
|
||||
srv, "detect_changes",
|
||||
"{\"project\":\"detect-budget-flag-project\",\"base_branch\":\"HEAD\","
|
||||
"\"scope\":\"files\",\"max_output_tokens\":128}");
|
||||
char *tree = extract_text_content(tree_response);
|
||||
bool tree_budgeted = tree && strstr(tree, "truncation_reason: output_budget") != NULL;
|
||||
bool tree_truncated = tree && strstr(tree, "truncated: true") != NULL;
|
||||
|
||||
char *json_response = cbm_mcp_handle_tool(
|
||||
srv, "detect_changes",
|
||||
"{\"project\":\"detect-budget-flag-project\",\"base_branch\":\"HEAD\","
|
||||
"\"scope\":\"files\",\"max_output_tokens\":128,\"format\":\"json\"}");
|
||||
char *json = extract_text_content(json_response);
|
||||
yyjson_doc *doc = json ? yyjson_read(json, strlen(json), 0) : NULL;
|
||||
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
|
||||
const char *reason = root ? yyjson_get_str(yyjson_obj_get(root, "truncation_reason")) : NULL;
|
||||
bool json_budgeted = reason && strcmp(reason, "output_budget") == 0;
|
||||
bool json_truncated = root && yyjson_get_bool(yyjson_obj_get(root, "truncated"));
|
||||
|
||||
yyjson_doc_free(doc);
|
||||
free(json);
|
||||
free(json_response);
|
||||
free(tree);
|
||||
free(tree_response);
|
||||
cbm_mcp_server_free(srv);
|
||||
restore_cache_dir(saved_cache_copy);
|
||||
free(saved_cache_copy);
|
||||
ASSERT_EQ(th_rmtree(cache), 0);
|
||||
ASSERT_EQ(th_rmtree(repo), 0);
|
||||
|
||||
ASSERT_TRUE(tree_budgeted);
|
||||
ASSERT_TRUE(json_budgeted);
|
||||
ASSERT_TRUE(tree_truncated && json_truncated);
|
||||
PASS();
|
||||
}
|
||||
|
||||
/* Regression test for issue #1363: detect_changes seeded every definition in
|
||||
* a changed file instead of just the ones whose line range overlaps the diff
|
||||
* hunk. cbm_detect_node_in_hunks is the overlap primitive; this exercises it
|
||||
@@ -17402,6 +18012,7 @@ SUITE(mcp) {
|
||||
RUN_TEST(search_code_preserves_valid_utf8_source);
|
||||
RUN_TEST(search_code_reports_scan_saturation);
|
||||
RUN_TEST(search_code_default_budget_limits_raw_rows_before_graph_results);
|
||||
RUN_TEST(search_code_raw_and_directory_remainders_are_independently_pageable);
|
||||
RUN_TEST(search_code_preserves_multi_kib_identities_and_distinct_long_directories);
|
||||
RUN_TEST(search_code_long_raw_line_is_one_truthfully_truncated_match);
|
||||
RUN_TEST(search_code_match_locations_are_explicitly_bounded_and_expandable);
|
||||
@@ -17468,8 +18079,14 @@ SUITE(mcp) {
|
||||
RUN_TEST(tool_manage_adr_get_accepts_abs_path);
|
||||
RUN_TEST(tool_manage_adr_get_accepts_symlink_path);
|
||||
RUN_TEST(tool_detect_changes_not_found_rich_error);
|
||||
RUN_TEST(tool_detect_changes_invalid_base_is_an_error);
|
||||
RUN_TEST(tool_detect_changes_preserves_utf8_git_path_and_impact_seed);
|
||||
RUN_TEST(tool_detect_changes_finds_nested_untracked_file_and_impact_seed);
|
||||
RUN_TEST(tool_detect_changes_escapes_newline_path_in_tree_and_round_trips_json);
|
||||
RUN_TEST(tool_detect_changes_staged_rename_uses_exact_destination_record);
|
||||
RUN_TEST(tool_detect_changes_contained_commands_clean_up_error_and_success);
|
||||
RUN_TEST(tool_detect_changes_pages_changed_files_and_honors_semantic_budget);
|
||||
RUN_TEST(tool_detect_changes_output_budget_sets_truncated_in_tree_and_json);
|
||||
RUN_TEST(detect_changes_node_in_hunks_overlap_issue1363);
|
||||
RUN_TEST(detect_changes_seeds_only_touched_symbol_issue1363);
|
||||
RUN_TEST(detect_changes_zero_overlap_falls_back_issue1363);
|
||||
|
||||
Reference in new issue
Block a user