From 2ea73059f894df564e21be409908c2b5d3d53b37 Mon Sep 17 00:00:00 2001 From: Daichi Matsumoto <222130627+z2250389@users.noreply.github.com> Date: Mon, 24 Aug 2026 19:50:29 +0900 Subject: [PATCH 1/5] fix(windows): support Unicode repository paths Signed-off-by: Daichi Matsumoto <222130627+z2250389@users.noreply.github.com> --- src/git/git_context.c | 5 ++- src/ui/http_server.c | 15 +++++---- tests/test_daemon_application.c | 48 +++++++++++++++++++++++++++++ tests/test_git_context.c | 32 ++++++++++++++++--- tests/test_httpd.c | 54 +++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 16 deletions(-) diff --git a/src/git/git_context.c b/src/git/git_context.c index f739c46e6a..47956562f4 100644 --- a/src/git/git_context.c +++ b/src/git/git_context.c @@ -2,6 +2,7 @@ #include "foundation/compat_fs.h" #include "foundation/constants.h" +#include "foundation/platform.h" #include "foundation/str_util.h" #include @@ -9,7 +10,6 @@ #include #include #include -#include enum { GIT_CMD_MAX = 1024, @@ -254,8 +254,7 @@ int cbm_git_context_resolve(const char *path, cbm_git_context_t *out) { return CBM_NOT_FOUND; } - struct stat st; - out->root_exists = (stat(path, &st) == 0); + out->root_exists = cbm_is_dir(path); if (!out->root_exists) { return 0; } diff --git a/src/ui/http_server.c b/src/ui/http_server.c index b806b10c67..f4a1218c0b 100644 --- a/src/ui/http_server.c +++ b/src/ui/http_server.c @@ -715,7 +715,6 @@ static void handle_processes(cbm_http_conn_t *c) { /* ── Directory browser ────────────────────────────────────────── */ -#include static void append_roots_json(char *buf, size_t bufsz, int *pos) { http_appendf(buf, bufsz, pos, ",\"roots\":["); @@ -770,7 +769,7 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { return; } - DIR *dir = opendir(path); + cbm_dir_t *dir = cbm_opendir(path); if (!dir) { cbm_http_replyf(c, 403, g_cors_json, "{\"error\":\"cannot open directory\"}"); return; @@ -781,16 +780,16 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { int pos = 0; http_appendf(buf, sizeof(buf), &pos, "{\"path\":\"%s\",\"dirs\":[", path); - struct dirent *ent; + cbm_dirent_t *ent; int count = 0; - while ((ent = readdir(dir)) != NULL) { + while ((ent = cbm_readdir(dir)) != NULL) { /* Skip hidden dirs and . / .. */ - if (ent->d_name[0] == '.') + if (ent->name[0] == '.') continue; /* Check if it's actually a directory */ char full[2048]; - snprintf(full, sizeof(full), "%s/%s", path, ent->d_name); + snprintf(full, sizeof(full), "%s/%s", path, ent->name); if (!cbm_is_dir(full)) continue; @@ -799,7 +798,7 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { /* Escape directory name to prevent XSS (e.g., names with quotes/angle brackets) */ { char esc[512]; - cbm_json_escape(esc, (int)sizeof(esc), ent->d_name); + cbm_json_escape(esc, (int)sizeof(esc), ent->name); http_appendf(buf, sizeof(buf), &pos, "\"%s\"", esc); } if (pos >= (int)sizeof(buf)) { @@ -810,7 +809,7 @@ static void handle_browse(cbm_http_conn_t *c, const cbm_http_req_t *req) { if (count >= 200) break; /* safety limit */ } - closedir(dir); + cbm_closedir(dir); /* Parent path — escape to prevent injection */ char parent[1024]; diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c index fceeb71309..f452822714 100644 --- a/tests/test_daemon_application.c +++ b/tests/test_daemon_application.c @@ -1823,6 +1823,53 @@ static bool app_env_backup_restore(app_env_backup_t *backup) { return status == 0; } +/* The Graph UI enters through the daemon's background coordinator rather than + * the MCP tool path. Keep that path UTF-8-safe as well. */ +TEST(daemon_application_ui_index_accepts_non_ascii_directory) { + app_env_backup_t cache_environment; + bool cache_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR"); + char root[APP_TEST_PATH_CAP]; + char cache[APP_TEST_PATH_CAP]; + (void)snprintf(root, sizeof(root), + "%s/cbm-app-ui-index-\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E-XXXXXX", cbm_tmpdir()); + (void)snprintf(cache, sizeof(cache), "%s/cbm-app-ui-index-cache-XXXXXX", cbm_tmpdir()); + bool dirs_created = cbm_mkdtemp(root) != NULL && cbm_mkdtemp(cache) != NULL; + bool cache_set = cache_saved && dirs_created && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0; + + app_fake_worker_context_t fake; + app_fake_worker_context_init(&fake); + atomic_store(&fake.allow_completion, true); + cbm_daemon_application_worker_ops_t worker_ops = { + .context = &fake, + .start = app_fake_worker_start, + .poll = app_fake_worker_poll, + .cancel = app_fake_worker_cancel, + .log_path = app_fake_worker_log_path, + .destroy = app_fake_worker_destroy, + }; + cbm_daemon_application_config_t config = {.worker_ops = &worker_ops}; + cbm_daemon_application_t *application = cache_set ? cbm_daemon_application_new(&config) : NULL; + int result = application ? cbm_daemon_application_index(application, "", root) : -1; + bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS); + cbm_daemon_application_free(application); + bool root_cleaned = th_rmtree(root) == 0; + bool cache_cleaned = th_rmtree(cache) == 0; + bool cache_restored = app_env_backup_restore(&cache_environment); + + ASSERT_TRUE(cache_saved); + ASSERT_TRUE(dirs_created); + ASSERT_TRUE(cache_set); + ASSERT_NOT_NULL(application); + ASSERT_EQ(result, 0); + ASSERT_EQ(atomic_load(&fake.starts), 1); + ASSERT_EQ(atomic_load(&fake.destroys), 1); + ASSERT_TRUE(stopped); + ASSERT_TRUE(root_cleaned); + ASSERT_TRUE(cache_cleaned); + ASSERT_TRUE(cache_restored); + PASS(); +} + typedef struct { atomic_int starts; atomic_int start_failures_remaining; @@ -5400,6 +5447,7 @@ SUITE(daemon_application) { RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions); RUN_TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls); RUN_TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls); + RUN_TEST(daemon_application_ui_index_accepts_non_ascii_directory); RUN_TEST(daemon_application_auto_index_honors_tracked_file_limit); RUN_TEST(daemon_application_auto_index_file_count_handles_literal_metacharacter_path); RUN_TEST(daemon_application_auto_index_file_count_supports_non_git_roots); diff --git a/tests/test_git_context.c b/tests/test_git_context.c index a384651a50..cc94f1d06e 100644 --- a/tests/test_git_context.c +++ b/tests/test_git_context.c @@ -8,8 +8,9 @@ * (input_path), not to worktree_root. Joining it with worktree_root and then * string-stripping "/.git" left unresolved ".." components in the result. * - * These tests shell out to `git`, so they SKIP_PLATFORM on Windows (the CI - * shell there cannot init a repo via system()). + * The canonical_root tests shell out to `git`, so they SKIP_PLATFORM on + * Windows (the CI shell there cannot init a repo via system()). The root-path + * existence test runs on every platform. * * Reproduce-first guard: canonical_root_subdir is the genuine RED-without-the-fix * guard — a repo indexed from a subdirectory yields a relative --git-common-dir @@ -34,9 +35,29 @@ #include #endif -/* These helpers shell out to git and are only used by the non-Windows test - * bodies below; on Windows every test SKIP_PLATFORMs, so guard them here too or - * they'd be unused-static functions and fail the -Werror build. */ +/* The Windows narrow stat() API interprets UTF-8 paths through the active ANSI + * code page. That made an existing Unicode repository root appear absent and + * stopped Git discovery before git was invoked (#1240). */ +TEST(root_exists_non_ascii_directory) { + /* UTF-8 for "Japanese language", written as bytes to keep the source ASCII. */ + char *tmp = th_mktempdir("cbm_gitctx_\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E"); + if (!tmp) + FAIL("th_mktempdir returned NULL for non-ASCII path"); + + cbm_git_context_t ctx = {0}; + int rc = cbm_git_context_resolve(tmp, &ctx); + bool root_exists = ctx.root_exists; + cbm_git_context_free(&ctx); + th_rmtree(tmp); + + ASSERT_EQ(rc, 0); + ASSERT_TRUE(root_exists); + PASS(); +} + +/* These helpers shell out to git and are only used by the non-Windows + * canonical_root test bodies below; guard them here too or they'd be + * unused-static functions and fail the -Werror build on Windows. */ #ifndef _WIN32 /* Run a git command inside dir, return 0 on success. */ static int git_run(const char *dir, const char *args) { @@ -235,6 +256,7 @@ TEST(canonical_root_linked_worktree) { /* ── Suite ──────────────────────────────────────────────────────── */ SUITE(git_context) { + RUN_TEST(root_exists_non_ascii_directory); RUN_TEST(canonical_root_repo_root); RUN_TEST(canonical_root_subdir); RUN_TEST(canonical_root_linked_worktree); diff --git a/tests/test_httpd.c b/tests/test_httpd.c index 3ac680197f..842a94ea8c 100644 --- a/tests/test_httpd.c +++ b/tests/test_httpd.c @@ -2143,6 +2143,59 @@ TEST(ui_server_rejects_non_loopback_host) { PASS(); } +/* The browser sends UTF-8 repository paths in a percent-encoded query. On + * Windows, the handler's narrow opendir/readdir calls accepted the ASCII parent + * but could neither list nor open a non-ASCII child, even though cbm_is_dir's + * wide-path check had already accepted it. Exercise both user-visible steps: + * discover the Unicode directory from its parent, then browse into it. */ +TEST(ui_server_browse_non_ascii_directory) { + static const char utf8_name[] = "\xE9\x81\x93\xE5\x85\xB7\xE7\xAE\xB1"; /* toolbox */ + char *created = th_mktempdir("cbm_browse_utf8"); + if (!created) + FAIL("mktempdir"); + + char base[512]; + snprintf(base, sizeof(base), "%s", created); + cbm_normalize_path_sep(base); + + char unicode_dir[768]; + char child[1024]; + int unicode_len = snprintf(unicode_dir, sizeof(unicode_dir), "%s/%s", base, utf8_name); + int child_len = snprintf(child, sizeof(child), "%s/Percy", unicode_dir); + if (unicode_len <= 0 || (size_t)unicode_len >= sizeof(unicode_dir) || child_len <= 0 || + (size_t)child_len >= sizeof(child) || th_mkdir_p(child) != 0) { + th_cleanup(base); + FAIL("failed to create non-ASCII browse fixture"); + } + + th_server_t ts; + if (th_server_start(&ts) != 0) { + th_cleanup(base); + FAIL("server start"); + } + + int port = cbm_http_server_port(ts.srv); + char request[1536]; + char response[8192]; + snprintf(request, sizeof(request), + "GET /api/browse?path=%s/%%E9%%81%%93%%E5%%85%%B7%%E7%%AE%%B1 HTTP/1.1\r\n\r\n", base); + int n = th_http(port, request, response, sizeof(response)); + bool child_browse_ok = + n > 0 && th_status(response) == 200 && strstr(response, "\"Percy\"") != NULL; + + snprintf(request, sizeof(request), "GET /api/browse?path=%s HTTP/1.1\r\n\r\n", base); + n = th_http(port, request, response, sizeof(response)); + bool parent_lists_unicode = + n > 0 && th_status(response) == 200 && strstr(response, utf8_name) != NULL; + + th_server_stop(&ts); + th_cleanup(base); + + ASSERT_TRUE(parent_lists_unicode); + ASSERT_TRUE(child_browse_ok); + PASS(); +} + /* The directory browser formats readdir() entries into a fixed 32 KB response * buffer. The per-entry loop is clamped, but the trailing "parent"/"roots" * appends were not — once the entries filled the buffer, pos ran past the end @@ -2360,6 +2413,7 @@ TEST(ui_server_index_status_long_paths_no_overflow) { /* ── Suite ────────────────────────────────────────────────────── */ SUITE(httpd) { + RUN_TEST(ui_server_browse_non_ascii_directory); RUN_TEST(ui_server_browse_wide_dir_no_overflow); RUN_TEST(ui_server_logs_escape_dense_no_overflow); RUN_TEST(ui_server_index_status_long_paths_no_overflow); From 64297ff3e8e142887fc36c933d57fd4d32924cc2 Mon Sep 17 00:00:00 2001 From: Ivan Lin <35771002+ivanlinhf@users.noreply.github.com> Date: Thu, 24 Sep 2026 21:14:29 +0800 Subject: [PATCH 2/5] chore(scoop): bump packaged manifest to 0.11.0 Sync pkg/scoop/codebase-memory-mcp.json with the Scoop Main bucket manifest (v0.11.0): add arm64, notes, and matching hashes/URLs. Signed-off-by: Ivan Lin <35771002+ivanlinhf@users.noreply.github.com> --- pkg/scoop/codebase-memory-mcp.json | 49 +++++++++++++++++------------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/pkg/scoop/codebase-memory-mcp.json b/pkg/scoop/codebase-memory-mcp.json index f4ae23c1b1..5945c17a3e 100644 --- a/pkg/scoop/codebase-memory-mcp.json +++ b/pkg/scoop/codebase-memory-mcp.json @@ -1,27 +1,34 @@ { - "version": "0.8.1", - "description": "Fast code intelligence engine — native executable with verified runtime assets", - "homepage": "https://github.com/DeusData/codebase-memory-mcp", - "license": "MIT", - "architecture": { - "64bit": { - "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.8.1/codebase-memory-mcp-windows-amd64.zip", - "hash": "a602ad090ed3f49d86c55472f73f27ad7055222806a82358f2e08513e027f00f", - "bin": "codebase-memory-mcp.exe" - } - }, - "checkver": { - "github": "https://github.com/DeusData/codebase-memory-mcp" - }, - "autoupdate": { + "version": "0.11.0", + "description": "Code intelligence knowledge graph MCP server for AI coding agents", + "homepage": "https://deusdata.github.io/codebase-memory-mcp/", + "license": "MIT", + "notes": "To register this MCP server with your clients, run `codebase-memory-mcp install` after install.", "architecture": { - "64bit": { - "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v$version/codebase-memory-mcp-windows-amd64.zip", + "64bit": { + "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/codebase-memory-mcp-windows-amd64.zip", + "hash": "6eb6beaf261b19e419766e78baf93cbc3cf1c6338cff8fb7c0234859f96d1685" + }, + "arm64": { + "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/codebase-memory-mcp-windows-arm64.zip", + "hash": "52b29881214fce47d529e098308b1de77c40f6812e20a34d588ee4c25b84fd1a" + } + }, + "bin": "codebase-memory-mcp.exe", + "checkver": { + "github": "https://github.com/DeusData/codebase-memory-mcp" + }, + "autoupdate": { + "architecture": { + "64bit": { + "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v$version/codebase-memory-mcp-windows-amd64.zip" + }, + "arm64": { + "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v$version/codebase-memory-mcp-windows-arm64.zip" + } + }, "hash": { - "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v$version/checksums.txt", - "find": "([a-f0-9]{64})\\s+codebase-memory-mcp-windows-amd64\\.zip" + "url": "$baseurl/checksums.txt" } - } } - } } From ff8dd47011c43c622d3dc82b33be32b4493e4ea9 Mon Sep 17 00:00:00 2001 From: Ivan Lin <35771002+ivanlinhf@users.noreply.github.com> Date: Sat, 26 Sep 2026 06:23:52 +0800 Subject: [PATCH 3/5] fix(scoop): address v0.11.0 review feedback Signed-off-by: Ivan Lin <35771002+ivanlinhf@users.noreply.github.com> --- pkg/scoop/codebase-memory-mcp.json | 59 ++++++++++++++----------- tests/test_version_metadata_contract.sh | 4 +- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/pkg/scoop/codebase-memory-mcp.json b/pkg/scoop/codebase-memory-mcp.json index 5945c17a3e..a4af5cf00a 100644 --- a/pkg/scoop/codebase-memory-mcp.json +++ b/pkg/scoop/codebase-memory-mcp.json @@ -1,34 +1,39 @@ { - "version": "0.11.0", - "description": "Code intelligence knowledge graph MCP server for AI coding agents", - "homepage": "https://deusdata.github.io/codebase-memory-mcp/", - "license": "MIT", - "notes": "To register this MCP server with your clients, run `codebase-memory-mcp install` after install.", + "version": "0.11.0", + "description": "Code intelligence knowledge graph MCP server for AI coding agents", + "homepage": "https://deusdata.github.io/codebase-memory-mcp/", + "license": "MIT", + "notes": "To register this MCP server with your clients, run `codebase-memory-mcp install` after install.", + "architecture": { + "64bit": { + "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/codebase-memory-mcp-windows-amd64.zip", + "hash": "6eb6beaf261b19e419766e78baf93cbc3cf1c6338cff8fb7c0234859f96d1685" + }, + "arm64": { + "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/codebase-memory-mcp-windows-arm64.zip", + "hash": "52b29881214fce47d529e098308b1de77c40f6812e20a34d588ee4c25b84fd1a" + } + }, + "bin": "codebase-memory-mcp.exe", + "checkver": { + "github": "https://github.com/DeusData/codebase-memory-mcp" + }, + "autoupdate": { "architecture": { - "64bit": { - "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/codebase-memory-mcp-windows-amd64.zip", - "hash": "6eb6beaf261b19e419766e78baf93cbc3cf1c6338cff8fb7c0234859f96d1685" - }, - "arm64": { - "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v0.11.0/codebase-memory-mcp-windows-arm64.zip", - "hash": "52b29881214fce47d529e098308b1de77c40f6812e20a34d588ee4c25b84fd1a" + "64bit": { + "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v$version/codebase-memory-mcp-windows-amd64.zip", + "hash": { + "url": "$baseurl/checksums.txt", + "find": "([a-f0-9]{64})\\s+codebase-memory-mcp-windows-amd64\\.zip$" } - }, - "bin": "codebase-memory-mcp.exe", - "checkver": { - "github": "https://github.com/DeusData/codebase-memory-mcp" - }, - "autoupdate": { - "architecture": { - "64bit": { - "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v$version/codebase-memory-mcp-windows-amd64.zip" - }, - "arm64": { - "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v$version/codebase-memory-mcp-windows-arm64.zip" - } - }, + }, + "arm64": { + "url": "https://github.com/DeusData/codebase-memory-mcp/releases/download/v$version/codebase-memory-mcp-windows-arm64.zip", "hash": { - "url": "$baseurl/checksums.txt" + "url": "$baseurl/checksums.txt", + "find": "([a-f0-9]{64})\\s+codebase-memory-mcp-windows-arm64\\.zip$" } + } } + } } diff --git a/tests/test_version_metadata_contract.sh b/tests/test_version_metadata_contract.sh index b191597bfb..fa66caeaa2 100644 --- a/tests/test_version_metadata_contract.sh +++ b/tests/test_version_metadata_contract.sh @@ -108,7 +108,7 @@ SURFACES=( "pkg/chocolatey/codebase-memory-mcp.nuspec|release" "pkg/chocolatey/tools/chocolateyInstall.ps1|release" "pkg/homebrew/Formula/codebase-memory-mcp.rb|pin:0.10.3" - "pkg/scoop/codebase-memory-mcp.json|pin:0.8.1" + "pkg/scoop/codebase-memory-mcp.json|pin:0.11.0" "pkg/aur/PKGBUILD|pin:0.8.1" "pkg/aur/.SRCINFO|pin:0.8.1" ) @@ -119,7 +119,7 @@ SURFACES=( # AND every sha256 it pins, then move the entry to "release" above. PIN_REASONS=( "pkg/homebrew/Formula/codebase-memory-mcp.rb|pins 4 per-asset sha256 (darwin/linux x arm/intel); last re-pinned for v0.10.3" - "pkg/scoop/codebase-memory-mcp.json|pins the windows-amd64.zip sha256; last re-pinned for v0.8.1" + "pkg/scoop/codebase-memory-mcp.json|pins 2 per-asset sha256 (Windows amd64 + arm64 zip); last re-pinned for v0.11.0" "pkg/aur/PKGBUILD|pins sha256sums_x86_64 + sha256sums_aarch64; last re-pinned for v0.8.1" "pkg/aur/.SRCINFO|generated from PKGBUILD, so it must move with it, not before it" ) From efb231285d188ffda16a58f7a5ce8e0e6448638f Mon Sep 17 00:00:00 2001 From: Mike Parsons <212198286+TheDarkniteFalls@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:55:16 +1200 Subject: [PATCH 4/5] test(pipeline): cover changed-file and empty-commit branch HEAD refresh Signed-off-by: Mike Parsons <212198286+TheDarkniteFalls@users.noreply.github.com> --- tests/test_pipeline.c | 47 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 6ef16a89be..798c6556ad 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8981,9 +8981,10 @@ static int branch_head_match_count(cbm_store_t *store, const char *project, cons return matches; } -/* Git context is graph input even when every repository file is unchanged. - * Construct the second pipeline before moving HEAD so the test also proves - * that run start refreshes the snapshot captured by cbm_pipeline_new(). */ +/* Regression for #1213: index A, re-index B after a tracked source change, + * then re-index C after an empty commit. Each successful run publishes its + * run-start HEAD. Construct the final pipeline before moving HEAD to also + * cover refreshing the snapshot captured by cbm_pipeline_new(). */ TEST(pipeline_git_context_change_forces_full_and_refreshes_branch) { if (!git_available()) { FAIL("git unavailable"); @@ -9026,6 +9027,42 @@ TEST(pipeline_git_context_change_forces_full_and_refreshes_branch) { snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(baseline)); cbm_pipeline_free(baseline); + cbm_store_t *initial_store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(initial_store); + int initial_matches = branch_head_match_count(initial_store, project, initial_head); + cbm_store_close(initial_store); + ASSERT_EQ(initial_matches, 1); + + /* B: a committed source change must advance the existing project's HEAD. */ + ASSERT_EQ(th_write_file(TH_PATH(repo, "stable.py"), "def StableGitContext():\n return 2\n"), + 0); + snprintf(cmd, sizeof(cmd), + "git -C \"%s\" -c user.name=\"CBM Test\" " + "-c user.email=\"cbm@example.invalid\" commit -am \"source change\" >%s 2>&1", + repo, null_dev); + ASSERT_EQ(run_cmd(cmd), 0); + cbm_git_context_t source_ctx = {0}; + ASSERT_EQ(cbm_git_context_resolve(repo, &source_ctx), 0); + ASSERT_NOT_NULL(source_ctx.head_sha); + char source_head[128]; + snprintf(source_head, sizeof(source_head), "%s", source_ctx.head_sha); + cbm_git_context_free(&source_ctx); + ASSERT_TRUE(strcmp(initial_head, source_head) != 0); + + cbm_pipeline_t *after_source_change = cbm_pipeline_new(repo, db_path, CBM_MODE_FAST); + ASSERT_NOT_NULL(after_source_change); + int source_rc = cbm_pipeline_run(after_source_change); + cbm_pipeline_free(after_source_change); + ASSERT_EQ(source_rc, 0); + cbm_store_t *source_store = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(source_store); + int source_matches = branch_head_match_count(source_store, project, source_head); + int initial_stale_matches = branch_head_match_count(source_store, project, initial_head); + cbm_store_close(source_store); + ASSERT_EQ(source_matches, 1); + ASSERT_EQ(initial_stale_matches, 0); + + /* C: no file hashes change, but the empty commit must still advance HEAD. */ cbm_pipeline_t *after_head_move = cbm_pipeline_new(repo, db_path, CBM_MODE_FAST); ASSERT_NOT_NULL(after_head_move); snprintf(cmd, sizeof(cmd), @@ -9040,7 +9077,7 @@ TEST(pipeline_git_context_change_forces_full_and_refreshes_branch) { char changed_head[128]; snprintf(changed_head, sizeof(changed_head), "%s", changed_ctx.head_sha); cbm_git_context_free(&changed_ctx); - ASSERT_TRUE(strcmp(initial_head, changed_head) != 0); + ASSERT_TRUE(strcmp(source_head, changed_head) != 0); cbm_pipeline_incremental_test_reset_faults(); int changed_rc = cbm_pipeline_run(after_head_move); @@ -9049,7 +9086,7 @@ TEST(pipeline_git_context_change_forces_full_and_refreshes_branch) { cbm_store_t *store = cbm_store_open_path(db_path); int changed_branch_matches = store ? branch_head_match_count(store, project, changed_head) : -1; - int stale_branch_matches = store ? branch_head_match_count(store, project, initial_head) : -1; + int stale_branch_matches = store ? branch_head_match_count(store, project, source_head) : -1; cbm_file_hash_t git_input = {0}; int git_input_rc = store ? cbm_store_get_file_hash( From bc960d87d663b7b4fc4c3ba558013c4609073762 Mon Sep 17 00:00:00 2001 From: Hubert Tarnacki Date: Wed, 9 Sep 2026 22:44:18 +0200 Subject: [PATCH 5/5] test(cli): pin JSON array literal handling for array-typed arguments `codebase-memory-mcp cli --flag value` builds the tool's JSON arguments from typed flags. Array-typed parameters such as `semantic_query` used to accept only repeated flags; the JSON spelling shown in the MCP schema (`--semantic-query '["a","b"]'`) was stored as a single opaque string element (#2150). `cli_add_typed` now expands such a literal; this pins the edge cases of that contract so it cannot regress: - a well-formed array of strings contributes its elements - an explicit empty array `[]` contributes nothing (not the keyword "[]") - a malformed literal stays one literal string, without an error - non-string items are copied as typed values, not stringified - literals and repeated flags mix, accumulating in argument order - repeated literals accumulate Signed-off-by: Hubert Tarnacki --- tests/test_cli.c | 81 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/tests/test_cli.c b/tests/test_cli.c index d56ff7c992..4f95e9c43a 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -15426,6 +15426,81 @@ TEST(cli_build_args_json_repeated_array_issue680) { PASS(); } +/* An array-typed flag also accepts the JSON-array spelling shown by the MCP + * schemas, expanding it into individual items instead of one opaque keyword. */ +TEST(cli_build_args_json_array_literal) { + char *err = NULL; + char *argv[] = {"--semantic-query", "[\"create\",\"billing\",\"account\"]"}; + char *json = cbm_cli_build_args_json("search_graph", 2, argv, &err); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"semantic_query\":[\"create\",\"billing\",\"account\"]") != NULL); + free(json); + PASS(); +} + +/* An explicit empty JSON array contributes no items; it must not turn into + * the literal keyword "[]". */ +TEST(cli_build_args_json_array_literal_empty) { + char *err = NULL; + char *argv[] = {"--semantic-query", "[]"}; + char *json = cbm_cli_build_args_json("search_graph", 2, argv, &err); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"semantic_query\":[]") != NULL); + ASSERT(strstr(json, "\"[]\"") == NULL); + free(json); + PASS(); +} + +/* Malformed JSON keeps the pre-existing behaviour: the value stays one + * literal string and no error is raised. */ +TEST(cli_build_args_json_array_literal_malformed) { + char *err = NULL; + char *argv[] = {"--semantic-query", "[\"create\","}; + char *json = cbm_cli_build_args_json("search_graph", 2, argv, &err); + ASSERT_NOT_NULL(json); + ASSERT_NULL(err); + ASSERT(strstr(json, "\"semantic_query\":[\"[\\\"create\\\",\"]") != NULL); + free(json); + PASS(); +} + +/* Non-string items of a well-formed JSON array are copied as typed values, + * not stringified and not collapsed into one literal element. */ +TEST(cli_build_args_json_array_literal_non_string_items) { + char *err = NULL; + char *argv[] = {"--semantic-query", "[1,2]"}; + char *json = cbm_cli_build_args_json("search_graph", 2, argv, &err); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"semantic_query\":[1,2]") != NULL); + ASSERT(strstr(json, "\"[1,2]\"") == NULL); + free(json); + PASS(); +} + +/* Repeated flags and array literals may be mixed; items accumulate in + * argument order. */ +TEST(cli_build_args_json_array_literal_mixed_with_repeated) { + char *err = NULL; + char *argv[] = {"--semantic-query", "send", "--semantic-query", "[\"publish\",\"emit\"]", + "--semantic-query", "notify"}; + char *json = cbm_cli_build_args_json("search_graph", 6, argv, &err); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"semantic_query\":[\"send\",\"publish\",\"emit\",\"notify\"]") != NULL); + free(json); + PASS(); +} + +/* A repeated array literal accumulates into one array. */ +TEST(cli_build_args_json_array_literal_repeated) { + char *err = NULL; + char *argv[] = {"--semantic-query", "[\"send\"]", "--semantic-query", "[\"publish\"]"}; + char *json = cbm_cli_build_args_json("search_graph", 4, argv, &err); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"semantic_query\":[\"send\",\"publish\"]") != NULL); + free(json); + PASS(); +} + /* kebab-case flag names map to snake_case JSON keys. */ TEST(cli_build_args_json_kebab_to_snake_issue680) { char *err = NULL; @@ -16560,6 +16635,12 @@ SUITE(cli) { RUN_TEST(cli_build_args_json_array_flag_accepts_json_literal); RUN_TEST(cli_build_args_json_unknown_flag_rejected); RUN_TEST(cli_build_args_json_repeated_array_issue680); + RUN_TEST(cli_build_args_json_array_literal); + RUN_TEST(cli_build_args_json_array_literal_empty); + RUN_TEST(cli_build_args_json_array_literal_malformed); + RUN_TEST(cli_build_args_json_array_literal_non_string_items); + RUN_TEST(cli_build_args_json_array_literal_mixed_with_repeated); + RUN_TEST(cli_build_args_json_array_literal_repeated); RUN_TEST(cli_build_args_json_kebab_to_snake_issue680); RUN_TEST(cli_build_args_json_key_equals_value_issue680); RUN_TEST(cli_build_args_json_bad_positional_errors_issue680);