From 651f94a751154d9aa844e31493e222779b0dbc47 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 23 Mar 2026 15:00:35 -0500 Subject: [PATCH 1/6] fix(watcher): add submodule dirty check to git_is_dirty Use git submodule foreach to detect uncommitted changes inside submodules, which git status alone does not report for the parent repo. Runs the submodule check only when the main worktree is clean, avoiding double-detection overhead. Uses the portable foreach approach rather than --recurse-submodules which is not supported by all git distributions (e.g. Apple Git). Co-Authored-By: Claude Sonnet 4.6 --- src/watcher/watcher.c | 32 +++++++++++++++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index a50672d..486a928 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -111,7 +111,9 @@ static int git_head(const char *root_path, char *out, size_t out_size) { return -1; } -/* Returns true if working tree has changes (modified, untracked, etc.) */ +/* Returns true if working tree has changes (modified, untracked, etc.). + * Also checks submodules via `git submodule foreach` to detect uncommitted + * changes inside submodules that `git status` alone would not report. */ static bool git_is_dirty(const char *root_path) { char cmd[1024]; snprintf(cmd, sizeof(cmd), @@ -136,6 +138,34 @@ static bool git_is_dirty(const char *root_path) { } } cbm_pclose(fp); + + if (dirty) { + return true; + } + + /* Check submodules: uncommitted changes inside a submodule are invisible + * to the parent's `git status` unless --recurse-submodules is supported. + * Use `git submodule foreach` as a portable fallback. */ + snprintf(cmd, sizeof(cmd), + "git --no-optional-locks -C '%s' submodule foreach --quiet --recursive " + "'git status --porcelain --untracked-files=normal 2>/dev/null' " + "2>/dev/null", + root_path); + // NOLINTNEXTLINE(bugprone-command-processor,cert-env33-c) + fp = cbm_popen(cmd, "r"); + if (!fp) { + return false; + } + if (fgets(line, sizeof(line), fp)) { + size_t len = strlen(line); + while (len > 0 && (line[len - 1] == '\n' || line[len - 1] == '\r')) { + line[--len] = '\0'; + } + if (len > 0) { + dirty = true; + } + } + cbm_pclose(fp); return dirty; } From c15a64f0be01fce079b60938aecd951f545e47d6 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 23 Mar 2026 15:00:42 -0500 Subject: [PATCH 2/6] feat(mcp): expose touch_project as MCP tool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add touch_project to TOOLS[], add handle_touch_project() handler, and add dispatch branch in cbm_mcp_handle_tool(). The tool resets the adaptive poll timer for a named project so the next watcher cycle runs check_changes() immediately — useful from git hooks or editor save hooks to trigger reindex without waiting for the poll interval. Returns "watcher not running" error gracefully when no watcher thread is attached (e.g. CLI mode). Co-Authored-By: Claude Sonnet 4.6 --- src/mcp/mcp.c | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index dbc1ef4..e408381 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -1,5 +1,5 @@ /* - * mcp.c — MCP server: JSON-RPC 2.0 over stdio with 14 graph tools. + * mcp.c — MCP server: JSON-RPC 2.0 over stdio with 15 graph tools. * * Uses yyjson for fast JSON parsing/building. * Single-threaded event loop: read line → parse → dispatch → respond. @@ -329,6 +329,13 @@ static const tool_def_t TOOLS[] = { "{\"type\":\"object\",\"properties\":{\"traces\":{\"type\":\"array\",\"items\":{\"type\":" "\"object\"}},\"project\":{\"type\":" "\"string\"}},\"required\":[\"traces\",\"project\"]}"}, + + {"touch_project", + "Reset the adaptive poll timer for a project so the next watcher cycle " + "runs check_changes() immediately. Useful from git hooks or editor " + "save hooks to trigger reindex without waiting for the poll interval.", + "{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"," + "\"description\":\"Project name to touch\"}},\"required\":[\"project\"]}"}, }; static const int TOOL_COUNT = sizeof(TOOLS) / sizeof(TOOLS[0]); @@ -2842,6 +2849,33 @@ static char *handle_ingest_traces(cbm_mcp_server_t *srv, const char *args) { return result; } +/* touch_project: reset adaptive backoff so next poll cycle is immediate. */ +static char *handle_touch_project(cbm_mcp_server_t *srv, const char *args) { + char *project = cbm_mcp_get_string_arg(args, "project"); + if (!project) { + return cbm_mcp_text_result("project is required", true); + } + if (!srv->watcher) { + free(project); + return cbm_mcp_text_result("watcher not running", true); + } + cbm_watcher_touch(srv->watcher, project); + + yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); + yyjson_mut_val *root = yyjson_mut_obj(doc); + yyjson_mut_doc_set_root(doc, root); + yyjson_mut_obj_add_str(doc, root, "project", project); + yyjson_mut_obj_add_str(doc, root, "status", "touched"); + + char *json = yy_doc_to_str(doc); + yyjson_mut_doc_free(doc); + free(project); + + char *result = cbm_mcp_text_result(json, false); + free(json); + return result; +} + /* ── Tool dispatch ────────────────────────────────────────────── */ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const char *args_json) { @@ -2893,6 +2927,10 @@ char *cbm_mcp_handle_tool(cbm_mcp_server_t *srv, const char *tool_name, const ch if (strcmp(tool_name, "ingest_traces") == 0) { return handle_ingest_traces(srv, args_json); } + if (strcmp(tool_name, "touch_project") == 0) { + return handle_touch_project(srv, args_json); + } + char msg[256]; snprintf(msg, sizeof(msg), "unknown tool: %s", tool_name); return cbm_mcp_text_result(msg, true); From 54f3d4cf91982f411897fa229ecf10242a89a5da Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 23 Mar 2026 15:00:46 -0500 Subject: [PATCH 3/6] feat(cli): add touch_project subcommand to help text Add touch_project to the tools list in print_usage() in main.c and to the tool listing in the CLI help string in cli.c. Co-Authored-By: Claude Sonnet 4.6 --- src/cli/cli.c | 1 + src/main.c | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/cli/cli.c b/src/cli/cli.c index 72739a8..7a4cec4 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -405,6 +405,7 @@ static const char skill_reference_content[] = "- `delete_project` — remove a project\n" "- `manage_adr` — architecture decision records\n" "- `ingest_traces` — import runtime traces\n" + "- `touch_project` — reset poll timer for on-demand reindex\n" "\n" "## Edge Types\n" "CALLS, HTTP_CALLS, ASYNC_CALLS, IMPORTS, DEFINES, DEFINES_METHOD,\n" diff --git a/src/main.c b/src/main.c index 979ee2f..dbc9487 100644 --- a/src/main.c +++ b/src/main.c @@ -149,7 +149,7 @@ static void print_help(void) { printf("\nTools: index_repository, search_graph, query_graph, trace_path,\n"); printf(" get_code_snippet, get_graph_schema, get_architecture, search_code,\n"); printf(" list_projects, delete_project, index_status, detect_changes,\n"); - printf(" manage_adr, ingest_traces\n"); + printf(" manage_adr, ingest_traces, touch_project\n"); } /* ── Main ───────────────────────────────────────────────────────── */ From 73624d7dcec740e7cef91b670e1b6f5babddadf6 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 23 Mar 2026 15:00:55 -0500 Subject: [PATCH 4/6] test(watcher): add submodule detection and touch_project tests test_watcher.c: - watcher_detects_submodule_dirty: creates parent+submodule repos, modifies a file inside the submodule without committing, and verifies the parent watcher detects it via git submodule foreach. - watcher_touch_resets_interval_dedicated: clean dedicated test that cbm_watcher_touch() bypasses the adaptive backoff interval so the next poll_once() checks for changes immediately. test_mcp.c: - Update mcp_tools_list comment from 14 to 15 tools; add assertion for touch_project. - mcp_touch_project_no_watcher: verifies that touch_project returns "watcher not running" when srv->watcher is NULL (CLI mode). - mcp_touch_project_missing_arg: verifies that touch_project returns "project is required" when the project argument is absent. Co-Authored-By: Claude Sonnet 4.6 --- tests/test_mcp.c | 39 ++++++++- tests/test_watcher.c | 204 ++++++++++++++++++++++++++++++++++++++----- 2 files changed, 221 insertions(+), 22 deletions(-) diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 72729f1..a004b4d 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -129,7 +129,7 @@ TEST(mcp_initialize_response) { TEST(mcp_tools_list) { char *json = cbm_mcp_tools_list(); ASSERT_NOT_NULL(json); - /* Should contain all 14 tools */ + /* Should contain all 15 tools */ ASSERT_NOT_NULL(strstr(json, "index_repository")); ASSERT_NOT_NULL(strstr(json, "search_graph")); ASSERT_NOT_NULL(strstr(json, "query_graph")); @@ -144,6 +144,7 @@ TEST(mcp_tools_list) { ASSERT_NOT_NULL(strstr(json, "detect_changes")); ASSERT_NOT_NULL(strstr(json, "manage_adr")); ASSERT_NOT_NULL(strstr(json, "ingest_traces")); + ASSERT_NOT_NULL(strstr(json, "touch_project")); free(json); PASS(); } @@ -707,6 +708,38 @@ TEST(tool_ingest_traces_empty) { PASS(); } +/* ══════════════════════════════════════════════════════════════════ + * TOUCH PROJECT + * ══════════════════════════════════════════════════════════════════ */ + +TEST(mcp_touch_project_no_watcher) { + /* When watcher is NULL (CLI mode), touch_project returns "watcher not running" + * error rather than crashing. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + /* srv->watcher is NULL by default */ + + char *result = cbm_mcp_handle_tool(srv, "touch_project", "{\"project\":\"x\"}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, "watcher not running")); + free(result); + + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(mcp_touch_project_missing_arg) { + /* touch_project without project arg returns "project is required" error. */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + + char *result = cbm_mcp_handle_tool(srv, "touch_project", "{}"); + ASSERT_NOT_NULL(result); + ASSERT_NOT_NULL(strstr(result, "project is required")); + free(result); + + cbm_mcp_server_free(srv); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * IDLE STORE EVICTION * ══════════════════════════════════════════════════════════════════ */ @@ -1717,6 +1750,10 @@ SUITE(mcp) { RUN_TEST(tool_ingest_traces_basic); RUN_TEST(tool_ingest_traces_empty); + /* touch_project */ + RUN_TEST(mcp_touch_project_no_watcher); + RUN_TEST(mcp_touch_project_missing_arg); + /* Idle store eviction */ RUN_TEST(store_idle_eviction); RUN_TEST(store_idle_no_eviction_within_timeout); diff --git a/tests/test_watcher.c b/tests/test_watcher.c index f2b8a66..ffa726e 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -229,7 +229,8 @@ TEST(watcher_stop_flag) { TEST(watcher_detects_git_commit) { /* Create a temporary git repo */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_test_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_test_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -282,7 +283,8 @@ TEST(watcher_detects_git_commit) { TEST(watcher_detects_dirty_worktree) { /* Create a temporary git repo */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_dirty_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_dirty_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -326,7 +328,8 @@ TEST(watcher_detects_dirty_worktree) { TEST(watcher_detects_new_file) { /* Create a temporary git repo */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_newf_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_newf_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -371,7 +374,8 @@ TEST(watcher_detects_new_file) { TEST(watcher_no_change_no_reindex) { /* Create a temporary git repo */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nochg_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nochg_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -414,8 +418,10 @@ TEST(watcher_no_change_no_reindex) { TEST(watcher_multiple_projects) { /* Create two temporary git repos */ - char tmpdirA[256]; snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_mA_XXXXXX"); - char tmpdirB[256]; snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_mB_XXXXXX"); + char tmpdirA[256]; + snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_mA_XXXXXX"); + char tmpdirB[256]; + snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_mB_XXXXXX"); if (!cbm_mkdtemp(tmpdirA) || !cbm_mkdtemp(tmpdirB)) SKIP("cbm_mkdtemp failed"); @@ -475,7 +481,8 @@ TEST(watcher_multiple_projects) { TEST(watcher_non_git_skips) { /* Non-git dir → baseline sets is_git=false → poll never reindexes. * Port of TestProbeStrategyNonGit behavior. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nongit_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_nongit_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -524,7 +531,8 @@ TEST(watcher_interval_blocks_repoll) { /* After baseline, the adaptive interval (5s minimum) should block * immediate re-polling. Without touch(), the next poll is a no-op. * Port of TestWatcherGitNoChanges' interval behavior. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_intv_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_intv_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -598,7 +606,8 @@ TEST(watcher_git_removed_no_crash) { /* Init git repo, baseline, remove .git, poll → should not crash. * Port of TestStrategyDowngradeGitToDirMtime behavior (C version * doesn't downgrade — just git commands fail silently). */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rmgit_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rmgit_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -643,7 +652,8 @@ TEST(watcher_git_removed_no_crash) { TEST(watcher_continued_dirty) { /* If working tree stays dirty, each poll should re-trigger reindex. * Port of repeated git sentinel detection behavior. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cont_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cont_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -711,7 +721,8 @@ TEST(watcher_continued_dirty) { TEST(watcher_baseline_dirty_repo) { /* Baseline on a repo that already has uncommitted changes. * Port of TestGitSentinelDetectsEdit (dirty from the start). */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_bld_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_bld_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -755,7 +766,8 @@ TEST(watcher_baseline_dirty_repo) { TEST(watcher_unwatch_prunes_state) { /* Watch, baseline, unwatch → project state removed. * Port of TestPollAllPrunesUnwatched + TestWatcherPrunesDeletedProjects. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_prune_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_prune_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -801,7 +813,8 @@ TEST(watcher_unwatch_prunes_state) { TEST(watcher_watch_after_unwatch) { /* Re-watching after unwatch should start fresh (new baseline). * Tests lifecycle correctness. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rewatch_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_rewatch_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -862,7 +875,8 @@ TEST(watcher_watch_after_unwatch) { TEST(watcher_detects_file_delete) { /* Port of TestFSNotifyDetectsFileDelete: * Delete a tracked file → git status shows change → reindex triggered. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_del_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_del_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -907,7 +921,8 @@ TEST(watcher_detects_file_delete) { TEST(watcher_detects_subdir_file) { /* Port of TestFSNotifyWatchesNewSubdir: * Create new subdir + file in it → git detects untracked → reindex. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_sub_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_sub_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -978,7 +993,8 @@ TEST(watcher_full_flow_new_file) { * Full lifecycle: watch → baseline → add file → detect change. * This is a more thorough version of watcher_detects_new_file * that mirrors the Go test's structure exactly. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ffnf_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_ffnf_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -1028,7 +1044,8 @@ TEST(watcher_fallback_still_detects) { * Even when the "primary" strategy has issues, the watcher * still detects changes. In C, we test that after removing .git * and re-creating it, changes are still detected on re-watch. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_fb_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_fb_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -1086,8 +1103,10 @@ TEST(watcher_poll_only_watched_projects) { /* Port of TestPollAllOnlyWatched: * Two repos exist, only one is watched → only the watched one * gets polled and can trigger reindex. */ - char tmpdirA[256]; snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_owA_XXXXXX"); - char tmpdirB[256]; snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_owB_XXXXXX"); + char tmpdirA[256]; + snprintf(tmpdirA, sizeof(tmpdirA), "/tmp/cbm_watcher_owA_XXXXXX"); + char tmpdirB[256]; + snprintf(tmpdirB, sizeof(tmpdirB), "/tmp/cbm_watcher_owB_XXXXXX"); if (!cbm_mkdtemp(tmpdirA) || !cbm_mkdtemp(tmpdirB)) SKIP("cbm_mkdtemp failed"); @@ -1145,7 +1164,8 @@ TEST(watcher_touch_resets_immediate) { /* Port of TestTouchProjectUpdatesTimestamp: * Verify that touch() resets the adaptive backoff so the next * poll actually checks for changes immediately. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_tch_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_tch_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -1195,7 +1215,8 @@ TEST(watcher_modify_tracked_file) { * Modify tracked file content (not just create/delete) → detected. * Similar to watcher_detects_dirty_worktree but modifies specific * tracked file content rather than appending. */ - char tmpdir[256]; snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_mod_XXXXXX"); + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_mod_XXXXXX"); if (!cbm_mkdtemp(tmpdir)) SKIP("cbm_mkdtemp failed"); @@ -1539,6 +1560,144 @@ TEST(watcher_null_watch_count) { PASS(); } +/* ══════════════════════════════════════════════════════════════════ + * SUBMODULE DETECTION + TOUCH RESET + * ══════════════════════════════════════════════════════════════════ */ + +TEST(watcher_detects_submodule_dirty) { + /* Verify that the watcher detects uncommitted changes inside a git + * submodule. Without --recurse-submodules, git status only reports + * the submodule HEAD pointer change, not uncommitted file edits inside + * the submodule. */ + char parent[256], submod[256]; + snprintf(submod, sizeof(submod), "/tmp/cbm_watcher_submod_sub_XXXXXX"); + snprintf(parent, sizeof(parent), "/tmp/cbm_watcher_submod_par_XXXXXX"); + if (!cbm_mkdtemp(submod) || !cbm_mkdtemp(parent)) + SKIP("cbm_mkdtemp failed"); + + char cmd[1024]; + + /* Init the submodule repo */ + snprintf(cmd, sizeof(cmd), + "cd '%s' && git init -q && git config user.email test@test && " + "git config user.name test && echo 'hello' > lib.c && " + "git add lib.c && git commit -q -m 'init submod'", + submod); + if (system(cmd) != 0) { + snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", submod, parent); + system(cmd); + SKIP("git not available"); + } + + /* Init parent repo and add submodule */ + snprintf(cmd, sizeof(cmd), + "cd '%s' && git init -q && git config user.email test@test && " + "git config user.name test && " + "git -c protocol.file.allow=always submodule add '%s' sub 2>/dev/null && " + "git add -A && git commit -q -m 'init parent with submod'", + parent, submod); + if (system(cmd) != 0) { + snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", submod, parent); + system(cmd); + SKIP("git submodule add not available"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "par-repo", parent); + index_call_count = 0; + + /* Baseline */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* Make an uncommitted change inside the submodule (not committed to submod) */ + snprintf(cmd, sizeof(cmd), "echo 'modified' >> '%s/sub/lib.c'", parent); + system(cmd); + + /* Touch + poll → --recurse-submodules should detect the dirty submodule */ + cbm_watcher_touch(w, "par-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); + + /* Cleanup */ + cbm_watcher_free(w); + cbm_store_close(store); + snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", parent, submod); + system(cmd); + PASS(); +} + +TEST(watcher_touch_resets_interval_dedicated) { + /* Dedicated test: cbm_watcher_touch() resets next_poll_ns=0 so the + * next poll_once() immediately checks for changes instead of waiting + * for the adaptive interval to expire. */ + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_tchd_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + SKIP("cbm_mkdtemp failed"); + + char cmd[512]; + snprintf(cmd, sizeof(cmd), + "cd '%s' && git init -q && git config user.email test@test && " + "git config user.name test && echo 'hello' > file.txt && " + "git add file.txt && git commit -q -m 'init'", + tmpdir); + if (system(cmd) != 0) { + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir); + system(cmd); + SKIP("git not available"); + } + + cbm_store_t *store = cbm_store_open_memory(); + cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL); + cbm_watcher_watch(w, "tchd-repo", tmpdir); + index_call_count = 0; + + /* Baseline */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); + + /* Make repo dirty */ + snprintf(cmd, sizeof(cmd), "echo 'dirty' >> '%s/file.txt'", tmpdir); + system(cmd); + + /* Poll without touch — interval blocks (adaptive backoff not yet elapsed) */ + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 0); /* blocked by interval */ + + /* Touch resets timer — next poll proceeds immediately */ + cbm_watcher_touch(w, "tchd-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 1); /* detected */ + + /* Make more changes, touch again — still works */ + snprintf(cmd, sizeof(cmd), "echo 'more dirt' >> '%s/file.txt'", tmpdir); + system(cmd); + cbm_watcher_touch(w, "tchd-repo"); + cbm_watcher_poll_once(w); + ASSERT_EQ(index_call_count, 2); + + cbm_watcher_free(w); + cbm_store_close(store); + snprintf(cmd, sizeof(cmd), "rm -rf '%s'", tmpdir); + system(cmd); + PASS(); +} + +TEST(watcher_null_poll_once) { + /* poll_once(NULL) → 0 */ + int reindexed = cbm_watcher_poll_once(NULL); + ASSERT_EQ(reindexed, 0); + PASS(); +} + +TEST(watcher_null_watch_count) { + /* watch_count(NULL) → 0 */ + int count = cbm_watcher_watch_count(NULL); + ASSERT_EQ(count, 0); + PASS(); +} /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -1616,4 +1775,7 @@ SUITE(watcher) { RUN_TEST(watcher_callback_data_passed); RUN_TEST(watcher_null_poll_once); RUN_TEST(watcher_null_watch_count); + /* Submodule detection + touch reset */ + RUN_TEST(watcher_detects_submodule_dirty); + RUN_TEST(watcher_touch_resets_interval_dedicated); } From de6d504035f096cededcc4f9491aef53de681964 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Mon, 23 Mar 2026 16:36:41 -0500 Subject: [PATCH 5/6] fix(watcher): address QA round 1 - Q1-01: Test now sets ignore=dirty in .gitmodules so parent git status does not report submodule working-tree changes, isolating the submodule foreach second-stage detection path - Q1-02: Fix stale comment referencing --recurse-submodules - Q1-03: cbm_watcher_touch returns bool; handle_touch_project returns error when project is not in the watch list Co-Authored-By: Claude Opus 4.6 (1M context) --- src/mcp/mcp.c | 8 +++++++- src/watcher/watcher.c | 6 ++++-- src/watcher/watcher.h | 5 +++-- tests/test_watcher.c | 15 +++++++++------ 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index e408381..68491ad 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -2859,7 +2859,13 @@ static char *handle_touch_project(cbm_mcp_server_t *srv, const char *args) { free(project); return cbm_mcp_text_result("watcher not running", true); } - cbm_watcher_touch(srv->watcher, project); + bool found = cbm_watcher_touch(srv->watcher, project); + if (!found) { + char msg[256]; + snprintf(msg, sizeof(msg), "project '%s' not found in watch list", project); + free(project); + return cbm_mcp_text_result(msg, true); + } yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); yyjson_mut_val *root = yyjson_mut_obj(doc); diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index 486a928..1402e7f 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -278,15 +278,17 @@ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name) { } } -void cbm_watcher_touch(cbm_watcher_t *w, const char *project_name) { +bool cbm_watcher_touch(cbm_watcher_t *w, const char *project_name) { if (!w || !project_name) { - return; + return false; } project_state_t *s = cbm_ht_get(w->projects, project_name); if (s) { /* Reset backoff — poll immediately on next cycle */ s->next_poll_ns = 0; + return true; } + return false; } int cbm_watcher_watch_count(const cbm_watcher_t *w) { diff --git a/src/watcher/watcher.h b/src/watcher/watcher.h index 2592109..a490be3 100644 --- a/src/watcher/watcher.h +++ b/src/watcher/watcher.h @@ -45,8 +45,9 @@ void cbm_watcher_watch(cbm_watcher_t *w, const char *project_name, const char *r /* Remove a project from the watch list. */ void cbm_watcher_unwatch(cbm_watcher_t *w, const char *project_name); -/* Refresh a project's timestamp (resets adaptive backoff). */ -void cbm_watcher_touch(cbm_watcher_t *w, const char *project_name); +/* Refresh a project's timestamp (resets adaptive backoff). + * Returns true if the project was found, false otherwise. */ +bool cbm_watcher_touch(cbm_watcher_t *w, const char *project_name); /* ── Polling ────────────────────────────────────────────────────── */ diff --git a/tests/test_watcher.c b/tests/test_watcher.c index ffa726e..6c990da 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -1566,9 +1566,9 @@ TEST(watcher_null_watch_count) { TEST(watcher_detects_submodule_dirty) { /* Verify that the watcher detects uncommitted changes inside a git - * submodule. Without --recurse-submodules, git status only reports - * the submodule HEAD pointer change, not uncommitted file edits inside - * the submodule. */ + * submodule even when the parent's git status ignores submodule dirty + * state (ignore=dirty in .gitmodules). The submodule foreach second + * stage in git_is_dirty() is the only detection mechanism here. */ char parent[256], submod[256]; snprintf(submod, sizeof(submod), "/tmp/cbm_watcher_submod_sub_XXXXXX"); snprintf(parent, sizeof(parent), "/tmp/cbm_watcher_submod_par_XXXXXX"); @@ -1589,12 +1589,15 @@ TEST(watcher_detects_submodule_dirty) { SKIP("git not available"); } - /* Init parent repo and add submodule */ + /* Init parent repo, add submodule, and set ignore=dirty so that the + * parent's git status --porcelain does NOT report submodule working-tree + * changes. This isolates the submodule foreach second stage. */ snprintf(cmd, sizeof(cmd), "cd '%s' && git init -q && git config user.email test@test && " "git config user.name test && " "git -c protocol.file.allow=always submodule add '%s' sub 2>/dev/null && " - "git add -A && git commit -q -m 'init parent with submod'", + "git config -f .gitmodules submodule.sub.ignore dirty && " + "git add -A && git commit -q -m 'init parent with submod (ignore=dirty)'", parent, submod); if (system(cmd) != 0) { snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", submod, parent); @@ -1615,7 +1618,7 @@ TEST(watcher_detects_submodule_dirty) { snprintf(cmd, sizeof(cmd), "echo 'modified' >> '%s/sub/lib.c'", parent); system(cmd); - /* Touch + poll → --recurse-submodules should detect the dirty submodule */ + /* Touch + poll → submodule foreach should detect the dirty submodule */ cbm_watcher_touch(w, "par-repo"); cbm_watcher_poll_once(w); ASSERT_EQ(index_call_count, 1); From c5ff2a60b51a7d650c381940d41e7831884c4088 Mon Sep 17 00:00:00 2001 From: Shane McCarron Date: Tue, 24 Mar 2026 07:58:05 -0500 Subject: [PATCH 6/6] docs(phase-21): add plan for tool-routing validation and CLAUDE.md improvement Co-Authored-By: Claude Opus 4.6 (1M context) --- .../21-language-support-tool/21-01-PLAN.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 .vbw-planning/phases/21-language-support-tool/21-01-PLAN.md diff --git a/.vbw-planning/phases/21-language-support-tool/21-01-PLAN.md b/.vbw-planning/phases/21-language-support-tool/21-01-PLAN.md new file mode 100644 index 0000000..2645802 --- /dev/null +++ b/.vbw-planning/phases/21-language-support-tool/21-01-PLAN.md @@ -0,0 +1,145 @@ +--- +plan: 21-01 +phase: 21 +title: "Validate tool-routing behavior and improve CLAUDE.md guidance" +status: ready +date: 2026-03-24 +commit_scope: docs +--- + +# Plan 21-01: Validate Tool-Routing Graceful Degradation + Improve CLAUDE.md + +## Goal + +Validate the Scout research finding that a `list_supported_languages` tool is NOT +needed. CMM already handles misrouted calls gracefully (empty results, no errors). +After hands-on validation, improve CLAUDE.md documentation to close the minor gaps +identified in the research. + +## Background (from research) + +- All structural queries (`search_graph`, `get_code_snippet`, `trace_call_path`) + return `{"total":0, "results":[]}` for text-only or non-indexed file types — no + errors, no crashes. +- JSON, YAML, and Markdown ARE indexed by CMM (Tree-Sitter parsers extract Module + and Variable nodes), so `search_code` works on them. Current CLAUDE.md guidance + says to use Read for these, missing the `search_code` opportunity. +- The cost of a `list_supported_languages` tool (~10ms per session + complexity) + exceeds the cost of try-and-fallback (~1ms empty query). +- User-configured extensions (Phase 5) create a minor knowledge gap, but graceful + fallback handles it. + +## File Ownership (no overlapping edits between tasks) + +| Task | Files Modified | +|------|---------------| +| T1 | None (validation only — reads + CMM tool calls) | +| T2 | `.claude/rules/global-claude-md.md` | + +T1 and T2 can run in parallel — T1 produces validation evidence, T2 edits docs. +T2 does not depend on T1 because the research already provides sufficient evidence +for the doc changes. If T1 discovers unexpected issues, a pivot task would be added. + +--- + +## Tasks + +### T1 — Hands-on validation of graceful degradation + +**Wave:** 1 (no deps) +**Files:** None modified (read-only validation) + +**Steps:** + +1. Call `search_graph` with a `file_pattern` targeting a non-indexed extension + (e.g., `*.txt` or `*.xyz`) on the current CMM project. Verify the response is + `{"total":0, "results":[]}` or equivalent empty result — not an error. + +2. Call `search_graph` with `label_filter="Function"` on a YAML or JSON file in + the project. Verify it returns empty results (these file types have no + Function nodes). + +3. Call `search_code` with a known string pattern scoped to `*.yaml` or `*.json` + files. Verify it returns matches (proving text search works on indexed + text-only formats). + +4. Call `get_code_snippet` with a qualified name that does not exist (e.g., + `nonexistent_module::fake_function`). Verify graceful empty/error response. + +5. Document results: write a brief validation summary as a comment in the + research file or as a standalone validation note. + +**Acceptance criteria:** +- All four CMM calls return graceful responses (empty results or clear error + messages, never crashes or unhandled exceptions) +- Validation results confirm the research finding: no tool needed +- If ANY call produces an unexpected error or crash, escalate immediately — + this would trigger a pivot to tool implementation + +**Commit:** `docs(phase-21): add tool-routing validation results` + +--- + +### T2 — Improve CLAUDE.md guidance for text-only file types and search_code + +**Wave:** 1 (no deps) +**Files:** `.claude/rules/global-claude-md.md` + +**Changes:** + +1. Update the "When Read is Correct" section to clarify that text-only formats + (JSON, YAML, TOML, Markdown) are indexed and searchable via `search_code`, + while `Read` remains correct for full-file context: + +```markdown +### When Read is Correct + +Use `Read` directly when: +- Non-code files (JSON, YAML, TOML, config, HTML templates, Markdown, .env) + **Note**: After indexing, these formats are searchable via `search_code` for + pattern matching (e.g., finding all occurrences of a config key across YAML + files). Use `Read` when you need full-file context; use `search_code` when + searching for specific strings across many config/data files. +- Full file context needed (imports, globals, module-level initialization flow) +- Very small files (under 50 lines) +- Files not yet indexed (new files before `index_repository`) +- Editing 6+ functions in the same file (batch context is more efficient) +- Jupyter notebooks, READMEs, documentation files +``` + +2. Add a `search_code` usage note to the Tool Reference section, after the + existing `search_code` bullet: + +```markdown +- **`search_code`**: Use for text search in source files — string literals, error + messages, TODOs, config values, import statements. Scoped to indexed project with + pagination. Case-insensitive by default. Also works on indexed non-code files + (JSON, YAML, TOML, Markdown) — prefer over `Read` when searching for patterns + across multiple config or data files. +``` + +**Acceptance criteria:** +- "When Read is Correct" section includes the clarifying note about `search_code` + for indexed text-only formats +- `search_code` tool reference mentions non-code file applicability +- No other sections are modified +- The guidance does NOT recommend building a `list_supported_languages` tool + +**Commit:** `docs(claude-md): clarify search_code works on indexed text-only formats` + +--- + +## Wave Summary + +| Wave | Tasks | Parallelizable? | +|------|-------|----------------| +| 1 | T1, T2 | Yes — T1 is read-only, T2 edits docs only | + +## Definition of Done + +- [ ] Hands-on validation confirms all CMM tools degrade gracefully for + text-only and non-indexed file types (T1) +- [ ] `.claude/rules/global-claude-md.md` updated with clarified guidance (T2) +- [ ] No `list_supported_languages` tool implementation (research conclusion + validated) +- [ ] Both commits follow conventional format