From de3dcdcd8da63db5f751fe610786fedaa574233e Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Wed, 25 Mar 2026 21:40:11 -0400 Subject: [PATCH] fix(leaks,depindex,mcp): fix 206 heap leaks, expand ecosystem detection to 17 managers, improve compact output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memory leaks fixed (0 leaks confirmed via leaks --atExit): - mcp.c resolve_store: cbm_project_free_fields was gated on proj.root_path[0] — empty string paths silently skipped free. Separated free from the watcher call; now always frees after successful cbm_store_get_project. - mcp.c handle_index_status: cbm_store_search_free skipped when dep_out.count==0 — cbm_store_search allocates even for empty results. Restructured to free whenever search succeeds. Same fix for cbm_project_free_fields call in ecosystem detection path. - pagerank.c: node_labels leaked on two early return paths (N==0 and id_map_init failure). Both paths now free node_ids and node_labels (with per-element free for strdup'd entries before the N==0 branch assigns any). - pass_envscan.c: 8 static regexes compiled once by compile_patterns() were never freed. Added cbm_envscan_free_patterns() that calls cbm_regfree on each and resets patterns_compiled=0. - pipeline.h/pipeline.c: public cbm_pipeline_global_cleanup() wraps cbm_envscan_free_patterns(). Called in main.c after ALL server threads joined (HTTP + stdio) to avoid racing with autoindex threads. Also called in run_cli() path and test_pipeline.c teardown. Ecosystem detection expanded from 8 to 17 package managers: - depindex.h: added CBM_PKG_MAKE, CBM_PKG_CMAKE, CBM_PKG_MESON, CBM_PKG_CONAN (C/C++ build systems). Expanded CBM_MANIFEST_FILES with build.gradle.kts, bun.lockb, global.json, Directory.Build.props, NuGet.Config, Makefile, GNUmakefile, Makefile.cbm, CMakeLists.txt, meson.build, conanfile.txt, conanfile.py, vcpkg.json. - depindex.c: rewrote cbm_detect_ecosystem to cover all 17 managers using CHECK() macro for exact filename matches and dir_contains_suffix() for wildcard patterns (*.csproj, *.fsproj). Added has_vendored_deps_dir() helper. Added discover_vendored_deps() which scans vendor/ vendored/ third_party/ thirdparty/ deps/ external/ ext/ contrib/ lib/ _vendor/ submodules/ for C/C++ and CBM_PKG_CUSTOM build systems. dep search hint in handle_search_graph: - When a dep project search (project:"dep", expanded to prefix ".dep") returns 0 results, emits a "hint" field with an ecosystem-aware actionable message. If cbm_detect_ecosystem succeeds, the hint names the detected build system and instructs to re-run index_repository. If no ecosystem detected, lists all 17 supported manifest file types. Compact output improvements in mcp.c: - handle_search_graph: skip emitting "name" when it equals the last segment of qualified_name (ends_with_segment check) or when empty. - handle_trace_call_path: same fix for both outbound (callees) and inbound (callers) node arrays. Added callers_total emission to match callees_total (was documented in tool description but never emitted). - build_snippet_response: skip empty name, label, and file_path fields. Compact param now wired through all six call sites in handle_get_code. Zero-value numeric fields skipped in compact mode. - handle_get_architecture / build_resource_architecture: skip redundant name (when equals last qualified_name segment) and empty label/fp in key_functions arrays. Test coverage: - test_token_reduction.c: 504-line new file covering compact suppression of redundant name/label/empty fields, callers_total presence, get_code compact param propagation, architecture key_functions, and dep search hint emission. - test_mcp.c, test_pipeline.c: minor additions for new behaviors. Makefile.cbm: - Added nosan build (CFLAGS_NOSAN, LDFLAGS_NOSAN, MONGOOSE_CFLAGS_NOSAN, per-object NOSAN variants for sqlite3/lsp/grammar/ts_runtime/mongoose). - Added test-leak target: macOS uses leaks --atExit on test-runner-nosan; Linux uses ASAN_OPTIONS=detect_leaks=1 on regular test-runner. - Added test-analyze target: Clang --analyze on production + test sources (skipped with message when IS_GCC=yes). - Updated .PHONY with test-leak, test-analyze, test-runner-nosan. Signed-off-by: Andrew Hundt --- Makefile.cbm | 105 ++++++- src/depindex/depindex.c | 188 ++++++++++-- src/depindex/depindex.h | 45 +-- src/main.c | 8 + src/mcp/mcp.c | 210 ++++++++----- src/pagerank/pagerank.c | 14 +- src/pipeline/pass_envscan.c | 15 + src/pipeline/pipeline.c | 8 + src/pipeline/pipeline.h | 6 + src/pipeline/pipeline_internal.h | 5 + tests/test_mcp.c | 38 ++- tests/test_pipeline.c | 3 + tests/test_token_reduction.c | 504 +++++++++++++++++++++++++++++++ 13 files changed, 1022 insertions(+), 127 deletions(-) diff --git a/Makefile.cbm b/Makefile.cbm index 933a51b7b..dd684fb6e 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -118,6 +118,9 @@ endif LDFLAGS = -lm -lstdc++ -lpthread -lz $(LIBGIT2_LIBS) $(WIN32_LIBS) LDFLAGS_TEST = -lm -lstdc++ -lpthread -lz $(SANITIZE) $(LIBGIT2_LIBS) $(WIN32_LIBS) LDFLAGS_TSAN = -lm -lstdc++ -lpthread -lz -fsanitize=thread $(LIBGIT2_LIBS) $(WIN32_LIBS) +# nosan: no ASan/UBSan — required for macOS 'leaks' tool (incompatible with ASan malloc replacement) +CFLAGS_NOSAN = $(CFLAGS_COMMON) -g -O1 +LDFLAGS_NOSAN = -lm -lstdc++ -lpthread -lz $(LIBGIT2_LIBS) $(WIN32_LIBS) # ── Source files ───────────────────────────────────────────────── @@ -236,8 +239,9 @@ UI_SRCS = \ # Mongoose HTTP library (vendored, compiled with relaxed warnings) MONGOOSE_SRC = vendored/mongoose/mongoose.c MONGOOSE_CFLAGS = -std=c11 -D_DEFAULT_SOURCE -O2 -w -Ivendored -DMG_ENABLE_LOG=0 -MONGOOSE_CFLAGS_TEST = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -Ivendored -DMG_ENABLE_LOG=0 \ - $(SANITIZE) +MONGOOSE_CFLAGS_TEST = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -Ivendored -DMG_ENABLE_LOG=0 \ + $(SANITIZE) +MONGOOSE_CFLAGS_NOSAN = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -Ivendored -DMG_ENABLE_LOG=0 # mimalloc (vendored, global allocator override) MIMALLOC_SRC = vendored/mimalloc/src/static.c @@ -357,7 +361,7 @@ PP_OBJ_TEST = $(BUILD_DIR)/preprocessor.o # ── Targets ────────────────────────────────────────────────────── -.PHONY: test test-foundation test-tsan cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format install +.PHONY: test test-foundation test-tsan test-leak test-analyze cbm cbm-with-ui frontend embed clean-c lint lint-tidy lint-cppcheck lint-format install test-runner-nosan $(BUILD_DIR): mkdir -p $(BUILD_DIR) @@ -430,7 +434,62 @@ $(BUILD_DIR)/prod_tre.o: $(TRE_SRC) | $(BUILD_DIR) $(CC) $(TRE_CFLAGS) -O2 -c -o $@ $< endif -OBJS_VENDORED_TEST = $(MIMALLOC_OBJ_TEST) $(SQLITE3_OBJ_TEST) $(TRE_OBJ_TEST) $(GRAMMAR_OBJS_TEST) $(TS_RUNTIME_OBJ_TEST) $(LSP_OBJ_TEST) $(PP_OBJ_TEST) $(MONGOOSE_OBJ_TEST) +OBJS_VENDORED_TEST = $(MIMALLOC_OBJ_TEST) $(SQLITE3_OBJ_TEST) $(TRE_OBJ_TEST) $(GRAMMAR_OBJS_TEST) $(TS_RUNTIME_OBJ_TEST) $(LSP_OBJ_TEST) $(PP_OBJ_TEST) $(MONGOOSE_OBJ_TEST) + +# ── Nosan build: ASan-free test runner for macOS heap leak detection ───────── +# +# WHY THIS EXISTS: +# 'make test-leak' uses Apple's 'leaks --atExit' tool to find heap leaks. +# But leaks cannot inspect a process that uses a custom malloc (such as ASan). +# The regular test-runner is built with -fsanitize=address,undefined, which +# replaces malloc → leaks aborts with "unable to inspect heap ranges". +# +# HOW IT WORKS: +# We rebuild all ASan-instrumented vendored objects without -fsanitize flags +# into $(NOSAN_DIR), then link test-runner-nosan against them. +# The resulting binary runs the full test suite under Apple's heap profiler. +# Full leak report is written to $(LEAK_LOG) = build/c/leak-report.txt. +# +# HOW TO USE: +# make test-leak # runs full suite + heap check, saves report to LEAK_LOG +# cat build/c/leak-report.txt # review complete leak report after run +# +# WHICH OBJECTS NEED NOSAN VARIANTS (use SANITIZE in their *_TEST flags): +# sqlite3, lsp_all, preprocessor, grammar/*.c, ts_runtime, mongoose +# WHICH ARE REUSED AS-IS (never use SANITIZE): +# mimalloc (MIMALLOC_CFLAGS_TEST has no -fsanitize) +# tre (only on Windows; TRE_CFLAGS has no -fsanitize) +# +NOSAN_DIR = $(BUILD_DIR)/nosan +GRAMMAR_CFLAGS_NOSAN = -std=c11 -D_DEFAULT_SOURCE -g -O1 -w -I$(CBM_DIR) -I$(TS_INCLUDE) -I$(TS_SRC) +GRAMMAR_OBJS_NOSAN = $(patsubst $(CBM_DIR)/%.c,$(NOSAN_DIR)/%.o,$(GRAMMAR_SRCS)) + +$(NOSAN_DIR): + mkdir -p $(NOSAN_DIR) + +# Grammar C files (tree-sitter parsers) — recompiled without ASan/UBSan +$(NOSAN_DIR)/%.o: $(CBM_DIR)/%.c | $(NOSAN_DIR) + $(CC) $(GRAMMAR_CFLAGS_NOSAN) -c -o $@ $< + +$(NOSAN_DIR)/ts_runtime.o: $(CBM_DIR)/ts_runtime.c | $(NOSAN_DIR) + $(CC) $(GRAMMAR_CFLAGS_NOSAN) -c -o $@ $< + +$(NOSAN_DIR)/lsp_all.o: $(CBM_DIR)/lsp_all.c | $(NOSAN_DIR) + $(CC) $(GRAMMAR_CFLAGS_NOSAN) -c -o $@ $< + +$(NOSAN_DIR)/preprocessor.o: $(CBM_DIR)/preprocessor.cpp | $(NOSAN_DIR) + $(CXX) $(CXXFLAGS_COMMON) -g -O1 -w -I$(CBM_DIR)/vendored -c -o $@ $< + +$(NOSAN_DIR)/sqlite3.o: $(SQLITE3_SRC) | $(NOSAN_DIR) + $(CC) $(SQLITE3_CFLAGS_TEST) -c -o $@ $< + +$(NOSAN_DIR)/mongoose.o: $(MONGOOSE_SRC) | $(NOSAN_DIR) + $(CC) $(MONGOOSE_CFLAGS_NOSAN) -c -o $@ $< + +OBJS_VENDORED_NOSAN = $(MIMALLOC_OBJ_TEST) $(NOSAN_DIR)/sqlite3.o $(TRE_OBJ_TEST) \ + $(GRAMMAR_OBJS_NOSAN) $(NOSAN_DIR)/ts_runtime.o \ + $(NOSAN_DIR)/lsp_all.o $(NOSAN_DIR)/preprocessor.o \ + $(NOSAN_DIR)/mongoose.o $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_TEST) | $(BUILD_DIR) $(CC) $(CFLAGS_TEST) -o $@ \ @@ -439,6 +498,13 @@ $(BUILD_DIR)/test-runner: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_ $(OBJS_VENDORED_TEST) \ $(LDFLAGS_TEST) +$(BUILD_DIR)/test-runner-nosan: $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(SQLITE_WRITER_SRC) $(OBJS_VENDORED_NOSAN) | $(BUILD_DIR) $(NOSAN_DIR) + $(CC) $(CFLAGS_NOSAN) -o $@ \ + $(ALL_TEST_SRCS) $(PROD_SRCS) \ + $(EXTRACTION_SRCS) $(AC_LZ4_SRCS) $(SQLITE_WRITER_SRC) \ + $(OBJS_VENDORED_NOSAN) \ + $(LDFLAGS_NOSAN) + test: $(BUILD_DIR)/test-runner cd $(CURDIR) && $(BUILD_DIR)/test-runner @@ -447,6 +513,37 @@ test: $(BUILD_DIR)/test-runner test-tsan: @echo "TSan not yet wired for full extraction tests" +# ── Leak detection ─────────────────────────────────────────────── +# macOS: uses `leaks --atExit` (Apple Clang LSan not available on all versions) +# Linux: ASAN_OPTIONS=detect_leaks=1 (GCC/Clang ASan always includes LSan) +# Note: if false positives appear from system libraries on Linux, create lsan.supp +# and set LSAN_OPTIONS=suppressions=lsan.supp +LEAK_LOG = $(BUILD_DIR)/leak-report.txt +ifeq ($(UNAME_S),Darwin) +# macOS: 'leaks' cannot inspect ASan-instrumented processes (ASan replaces malloc). +# Use test-runner-nosan (no ASan/UBSan) so leaks can walk the heap. +test-leak: $(BUILD_DIR)/test-runner-nosan + @echo "Running heap leak detection via 'leaks --atExit' on nosan build (macOS). May take 2-5 minutes." + @echo "Full report saved to $(LEAK_LOG). Exit 0 = no leaks." + leaks --atExit -- $(BUILD_DIR)/test-runner-nosan 2>&1 | tee $(LEAK_LOG); exit $${PIPESTATUS[0]} +else +test-leak: $(BUILD_DIR)/test-runner + @echo "Running heap leak detection via ASan/LSan (Linux). Full report saved to $(LEAK_LOG). Exit 0 = no leaks." + ASAN_OPTIONS=detect_leaks=1 $(BUILD_DIR)/test-runner 2>&1 | tee $(LEAK_LOG); exit $${PIPESTATUS[0]} +endif + +# ── Static analysis (Clang analyzer only — GCC has no --analyze flag) ────── +ifeq ($(IS_GCC),no) +test-analyze: $(ALL_TEST_SRCS) $(PROD_SRCS) + @echo "Running Clang static analyzer..." + $(CC) --analyze $(CFLAGS_COMMON) \ + $(ALL_TEST_SRCS) $(PROD_SRCS) $(EXTRACTION_SRCS) 2>&1 | \ + grep -E "warning:|error:|note:" || echo "No issues found." +else +test-analyze: + @echo "Static analysis skipped: requires Clang (not GCC). Install clang and re-run." +endif + # ── Production binary ──────────────────────────────────────────── # Grammar/TS/LSP objects for production (compiled with relaxed warnings, -O2) diff --git a/src/depindex/depindex.c b/src/depindex/depindex.c index 4b09c42d7..0bab4ba0e 100644 --- a/src/depindex/depindex.c +++ b/src/depindex/depindex.c @@ -15,6 +15,7 @@ #include #include #include +#include /* ── Package Manager Parse/String ──────────────────────────────── */ @@ -24,19 +25,21 @@ cbm_pkg_manager_t cbm_parse_pkg_manager(const char *s) { const char *name; cbm_pkg_manager_t val; } table[] = { - {"uv", CBM_PKG_UV}, {"pip", CBM_PKG_UV}, - {"poetry", CBM_PKG_UV}, {"pdm", CBM_PKG_UV}, - {"python", CBM_PKG_UV}, {"cargo", CBM_PKG_CARGO}, - {"npm", CBM_PKG_NPM}, {"yarn", CBM_PKG_NPM}, - {"pnpm", CBM_PKG_NPM}, {"bun", CBM_PKG_BUN}, - {"go", CBM_PKG_GO}, {"jvm", CBM_PKG_JVM}, - {"maven", CBM_PKG_JVM}, {"gradle", CBM_PKG_JVM}, + {"uv", CBM_PKG_UV}, {"pip", CBM_PKG_UV}, + {"poetry", CBM_PKG_UV}, {"pdm", CBM_PKG_UV}, + {"python", CBM_PKG_UV}, {"cargo", CBM_PKG_CARGO}, + {"npm", CBM_PKG_NPM}, {"yarn", CBM_PKG_NPM}, + {"pnpm", CBM_PKG_NPM}, {"bun", CBM_PKG_BUN}, + {"go", CBM_PKG_GO}, {"jvm", CBM_PKG_JVM}, + {"maven", CBM_PKG_JVM}, {"gradle", CBM_PKG_JVM}, {"dotnet", CBM_PKG_DOTNET}, {"nuget", CBM_PKG_DOTNET}, - {"ruby", CBM_PKG_RUBY}, {"bundler", CBM_PKG_RUBY}, - {"php", CBM_PKG_PHP}, {"composer", CBM_PKG_PHP}, - {"swift", CBM_PKG_SWIFT}, {"dart", CBM_PKG_DART}, - {"pub", CBM_PKG_DART}, {"mix", CBM_PKG_MIX}, - {"hex", CBM_PKG_MIX}, {"custom", CBM_PKG_CUSTOM}, + {"ruby", CBM_PKG_RUBY}, {"bundler", CBM_PKG_RUBY}, + {"php", CBM_PKG_PHP}, {"composer", CBM_PKG_PHP}, + {"swift", CBM_PKG_SWIFT}, {"dart", CBM_PKG_DART}, + {"pub", CBM_PKG_DART}, {"mix", CBM_PKG_MIX}, + {"hex", CBM_PKG_MIX}, {"make", CBM_PKG_MAKE}, + {"cmake", CBM_PKG_CMAKE}, {"meson", CBM_PKG_MESON}, + {"conan", CBM_PKG_CONAN}, {"custom", CBM_PKG_CUSTOM}, {NULL, CBM_PKG_COUNT}, }; for (int i = 0; table[i].name; i++) { @@ -46,9 +49,12 @@ cbm_pkg_manager_t cbm_parse_pkg_manager(const char *s) { } const char *cbm_pkg_manager_str(cbm_pkg_manager_t mgr) { - static const char *names[] = {"uv", "cargo", "npm", "bun", "go", - "jvm", "dotnet", "ruby", "php", "swift", - "dart", "mix", "custom"}; + static const char *names[] = { + "uv", "cargo", "npm", "bun", "go", + "jvm", "dotnet", "ruby", "php", "swift", + "dart", "mix", "make", "cmake", "meson", + "conan", "custom" + }; return mgr < CBM_PKG_COUNT ? names[mgr] : "unknown"; } @@ -90,26 +96,102 @@ bool cbm_is_manifest_path(const char *file_path) { /* ── Ecosystem Detection ───────────────────────────────────────── */ +/* Scan project_root directory for a file matching any of the given basenames. + * Returns true if any match found — used for wildcard-like detection (e.g. *.csproj). */ +static bool dir_contains_suffix(const char *project_root, const char *suffix) { + cbm_dir_t *d = cbm_opendir(project_root); + if (!d) return false; + cbm_dirent_t *ent; + size_t slen = strlen(suffix); + while ((ent = cbm_readdir(d)) != NULL) { + size_t nlen = strlen(ent->name); + if (nlen >= slen && strcmp(ent->name + nlen - slen, suffix) == 0) { + cbm_closedir(d); + return true; + } + } + cbm_closedir(d); + return false; +} + +/* Check for a vendored dependency directory (vendor/, vendored/, third_party/, etc.). + * Returns true if any conventional vendor dir exists with at least one subdirectory. */ +static bool has_vendored_deps_dir(const char *project_root) { + static const char *vendor_dirs[] = { + "vendor", "vendored", "third_party", "thirdparty", + "deps", "external", "ext", "contrib", "lib", + "_vendor", "submodules", NULL + }; + char path[CBM_PATH_MAX]; + for (int i = 0; vendor_dirs[i]; i++) { + snprintf(path, sizeof(path), "%s/%s", project_root, vendor_dirs[i]); + cbm_dir_t *d = cbm_opendir(path); + if (!d) continue; + cbm_dirent_t *ent; + bool has_subdir = false; + while ((ent = cbm_readdir(d)) != NULL) { + if (ent->name[0] == '.') continue; + char sub[CBM_PATH_MAX]; + snprintf(sub, sizeof(sub), "%s/%s", path, ent->name); + struct stat st; + if (stat(sub, &st) == 0 && S_ISDIR(st.st_mode)) { has_subdir = true; break; } + } + cbm_closedir(d); + if (has_subdir) return true; + } + return false; +} + cbm_pkg_manager_t cbm_detect_ecosystem(const char *project_root) { if (!project_root) return CBM_PKG_COUNT; char path[CBM_PATH_MAX]; - snprintf(path, sizeof(path), "%s/pyproject.toml", project_root); - if (access(path, F_OK) == 0) return CBM_PKG_UV; - snprintf(path, sizeof(path), "%s/setup.py", project_root); - if (access(path, F_OK) == 0) return CBM_PKG_UV; - snprintf(path, sizeof(path), "%s/Cargo.toml", project_root); - if (access(path, F_OK) == 0) return CBM_PKG_CARGO; - snprintf(path, sizeof(path), "%s/package.json", project_root); - if (access(path, F_OK) == 0) return CBM_PKG_NPM; - snprintf(path, sizeof(path), "%s/bun.lockb", project_root); - if (access(path, F_OK) == 0) return CBM_PKG_BUN; - snprintf(path, sizeof(path), "%s/go.mod", project_root); - if (access(path, F_OK) == 0) return CBM_PKG_GO; - snprintf(path, sizeof(path), "%s/pom.xml", project_root); - if (access(path, F_OK) == 0) return CBM_PKG_JVM; - snprintf(path, sizeof(path), "%s/build.gradle", project_root); - if (access(path, F_OK) == 0) return CBM_PKG_JVM; +/* Macro: check file exists → return manager */ +#define CHECK(file, mgr) \ + do { snprintf(path, sizeof(path), "%s/" file, project_root); \ + if (access(path, F_OK) == 0) return (mgr); } while (0) + + /* Interpreted-language ecosystems (highest confidence — unique lockfiles/manifests) */ + CHECK("bun.lockb", CBM_PKG_BUN); /* bun before npm: more specific */ + CHECK("pyproject.toml", CBM_PKG_UV); + CHECK("setup.py", CBM_PKG_UV); + CHECK("requirements.txt",CBM_PKG_UV); + CHECK("Pipfile", CBM_PKG_UV); + CHECK("Cargo.toml", CBM_PKG_CARGO); + CHECK("go.mod", CBM_PKG_GO); + CHECK("pom.xml", CBM_PKG_JVM); + CHECK("build.gradle", CBM_PKG_JVM); + CHECK("build.gradle.kts",CBM_PKG_JVM); + CHECK("package.json", CBM_PKG_NPM); + CHECK("Gemfile", CBM_PKG_RUBY); + CHECK("composer.json", CBM_PKG_PHP); + CHECK("Package.swift", CBM_PKG_SWIFT); + CHECK("pubspec.yaml", CBM_PKG_DART); + CHECK("mix.exs", CBM_PKG_MIX); + + /* .NET: check well-known files first, then scan for *.csproj / *.fsproj */ + CHECK("global.json", CBM_PKG_DOTNET); + CHECK("Directory.Build.props", CBM_PKG_DOTNET); + CHECK("NuGet.Config", CBM_PKG_DOTNET); + if (dir_contains_suffix(project_root, ".csproj") || + dir_contains_suffix(project_root, ".fsproj") || + dir_contains_suffix(project_root, ".vbproj")) return CBM_PKG_DOTNET; + + /* C/C++ build systems */ + CHECK("conanfile.txt", CBM_PKG_CONAN); /* Conan before CMake: conanfile may coexist */ + CHECK("conanfile.py", CBM_PKG_CONAN); + CHECK("vcpkg.json", CBM_PKG_CMAKE); /* vcpkg always used with CMake */ + CHECK("CMakeLists.txt", CBM_PKG_CMAKE); + CHECK("meson.build", CBM_PKG_MESON); + CHECK("Makefile", CBM_PKG_MAKE); + CHECK("GNUmakefile", CBM_PKG_MAKE); + CHECK("BSDmakefile", CBM_PKG_MAKE); + CHECK("Makefile.cbm", CBM_PKG_MAKE); /* non-standard but used by codebase-memory-mcp itself */ + +#undef CHECK + + /* Generic: vendored deps in vendor/ vendored/ etc. (any language with bundled deps) */ + if (has_vendored_deps_dir(project_root)) return CBM_PKG_CUSTOM; return CBM_PKG_COUNT; } @@ -268,6 +350,42 @@ void cbm_dep_discovered_free(cbm_dep_discovered_t *deps, int count) { free(deps); } +/* Discover vendored dependencies by scanning conventional vendor directories. + * Used for C/C++ build systems (Make, CMake, Meson, Conan) and generic CBM_PKG_CUSTOM. + * Each named subdirectory in vendor/ vendored/ third_party/ etc. becomes a dep entry. */ +static int discover_vendored_deps(const char *project_root, cbm_dep_discovered_t **out, + int *count, int max_results) { + static const char *vendor_dirs[] = { + "vendor", "vendored", "third_party", "thirdparty", + "deps", "external", "ext", "contrib", "lib", + "_vendor", "submodules", NULL + }; + + *out = calloc((size_t)max_results, sizeof(cbm_dep_discovered_t)); + if (!*out) return -1; + *count = 0; + + char dir_path[CBM_PATH_MAX]; + for (int vi = 0; vendor_dirs[vi] && *count < max_results; vi++) { + snprintf(dir_path, sizeof(dir_path), "%s/%s", project_root, vendor_dirs[vi]); + cbm_dir_t *d = cbm_opendir(dir_path); + if (!d) continue; + cbm_dirent_t *ent; + while ((ent = cbm_readdir(d)) != NULL && *count < max_results) { + if (ent->name[0] == '.') continue; + char sub[CBM_PATH_MAX]; + snprintf(sub, sizeof(sub), "%s/%s", dir_path, ent->name); + struct stat st; + if (stat(sub, &st) != 0 || !S_ISDIR(st.st_mode)) continue; + (*out)[*count].package = strdup(ent->name); + (*out)[*count].path = strdup(sub); + (*count)++; + } + cbm_closedir(d); + } + return 0; +} + /* Discover installed deps by querying the graph for Variable nodes * in manifest files under dependency sections. * Runtime: O(search_limit) for query + O(N) for filtering + O(N) for resolution. @@ -281,6 +399,14 @@ int cbm_discover_installed_deps(cbm_pkg_manager_t mgr, const char *project_root, *count = 0; if (max_results <= 0) max_results = CBM_DEFAULT_AUTO_DEP_LIMIT; + /* C/C++ build systems and generic vendored deps: scan vendor directories directly. + * These don't have a registry/lockfile to parse; deps live in the source tree. */ + if (mgr == CBM_PKG_MAKE || mgr == CBM_PKG_CMAKE || + mgr == CBM_PKG_MESON || mgr == CBM_PKG_CONAN || + mgr == CBM_PKG_CUSTOM) { + return discover_vendored_deps(project_root, out, count, max_results); + } + cbm_search_params_t params = {0}; params.project = project_name; params.label = "Variable"; diff --git a/src/depindex/depindex.h b/src/depindex/depindex.h index 03830863d..550746152 100644 --- a/src/depindex/depindex.h +++ b/src/depindex/depindex.h @@ -30,10 +30,17 @@ typedef struct cbm_store cbm_store_t; * These are the basenames of files that declare project dependencies. * When adding a new manifest file, add it here — all consumers pick it up. */ static const char *CBM_MANIFEST_FILES[] = { + /* Interpreted languages */ "Cargo.toml", "pyproject.toml", "package.json", "go.mod", - "requirements.txt", "Gemfile", "build.gradle", "pom.xml", - "composer.json", "pubspec.yaml", "mix.exs", "Package.swift", - "setup.py", "Pipfile", NULL + "requirements.txt", "Gemfile", "build.gradle", "build.gradle.kts", + "pom.xml", "composer.json", "pubspec.yaml", "mix.exs", "Package.swift", + "setup.py", "Pipfile", "bun.lockb", + /* .NET */ + "global.json", "Directory.Build.props", "NuGet.Config", + /* C/C++ build systems */ + "Makefile", "GNUmakefile", "Makefile.cbm", "CMakeLists.txt", "meson.build", + "conanfile.txt", "conanfile.py", "vcpkg.json", + NULL }; /* Default limits (convention: -1=unlimited, 0=disabled, >0=limit) */ @@ -48,20 +55,24 @@ static const char *CBM_MANIFEST_FILES[] = { /* ── Package Manager Enum ──────────────────────────────────────── */ typedef enum { - CBM_PKG_UV = 0, - CBM_PKG_CARGO, - CBM_PKG_NPM, - CBM_PKG_BUN, - CBM_PKG_GO, - CBM_PKG_JVM, - CBM_PKG_DOTNET, - CBM_PKG_RUBY, - CBM_PKG_PHP, - CBM_PKG_SWIFT, - CBM_PKG_DART, - CBM_PKG_MIX, - CBM_PKG_CUSTOM, - CBM_PKG_COUNT /* sentinel / invalid */ + CBM_PKG_UV = 0, /* Python: uv/pip/poetry/pdm (pyproject.toml, setup.py, requirements.txt, Pipfile) */ + CBM_PKG_CARGO, /* Rust: cargo (Cargo.toml) */ + CBM_PKG_NPM, /* Node.js: npm/yarn/pnpm (package.json) */ + CBM_PKG_BUN, /* Bun: (bun.lockb) */ + CBM_PKG_GO, /* Go modules: (go.mod) */ + CBM_PKG_JVM, /* JVM: Maven/Gradle (pom.xml, build.gradle, build.gradle.kts) */ + CBM_PKG_DOTNET, /* .NET: NuGet (*.csproj, *.fsproj, global.json, Directory.Build.props) */ + CBM_PKG_RUBY, /* Ruby: Bundler (Gemfile) */ + CBM_PKG_PHP, /* PHP: Composer (composer.json) */ + CBM_PKG_SWIFT, /* Swift: SPM (Package.swift) */ + CBM_PKG_DART, /* Dart: pub (pubspec.yaml) */ + CBM_PKG_MIX, /* Elixir: Mix (mix.exs) */ + CBM_PKG_MAKE, /* C/C++: Make (Makefile, GNUmakefile) */ + CBM_PKG_CMAKE, /* C/C++: CMake (CMakeLists.txt, vcpkg.json) */ + CBM_PKG_MESON, /* C/C++: Meson (meson.build) */ + CBM_PKG_CONAN, /* C/C++: Conan (conanfile.txt, conanfile.py) */ + CBM_PKG_CUSTOM, /* Generic: vendored deps (vendor/, vendored/, third_party/, deps/, etc.) */ + CBM_PKG_COUNT /* sentinel / invalid */ } cbm_pkg_manager_t; /* Parse "uv"/"cargo"/"npm"/"bun"/etc → enum. Returns CBM_PKG_COUNT if unknown. */ diff --git a/src/main.c b/src/main.c index a2939e204..57010e401 100644 --- a/src/main.c +++ b/src/main.c @@ -130,6 +130,8 @@ static int run_cli(int argc, char **argv) { } cbm_mcp_server_free(srv); + /* CLI mode: no background threads, safe to clean up global state now. */ + cbm_pipeline_global_cleanup(); return 0; } @@ -306,6 +308,12 @@ int main(int argc, char **argv) { * cbm_mcp_server_free joins the autoindex thread internally. */ cbm_mcp_server_free(g_server); + /* Release pipeline-level global state (compiled regex patterns etc.). + * Called here — after ALL server threads are joined — to avoid a race between + * the stdio server's autoindex thread (joined above) and the HTTP server's + * cleanup (which ran earlier in cbm_http_server_free). */ + cbm_pipeline_global_cleanup(); + if (watcher_started) { cbm_watcher_stop(g_watcher); cbm_thread_join(&watcher_tid); diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index d74f629b1..e5a8aa314 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -986,10 +986,11 @@ static cbm_store_t *resolve_store(cbm_mcp_server_t *srv, const char *project) { /* Register newly-accessed project with watcher (root_path from DB) */ if (srv->watcher && srv->store) { cbm_project_t proj = {0}; - if (cbm_store_get_project(srv->store, db_project, &proj) == CBM_STORE_OK - && proj.root_path && proj.root_path[0]) { - cbm_watcher_watch(srv->watcher, db_project, proj.root_path); - cbm_project_free_fields(&proj); /* store.h:578 */ + if (cbm_store_get_project(srv->store, db_project, &proj) == CBM_STORE_OK) { + if (proj.root_path && proj.root_path[0]) + cbm_watcher_watch(srv->watcher, db_project, proj.root_path); + /* Always free fields — cbm_store_get_project heap-allocates even empty strings */ + cbm_project_free_fields(&proj); } } @@ -1642,8 +1643,9 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { for (int i = 0; i < out.count; i++) { cbm_search_result_t *sr = &out.results[i]; yyjson_mut_val *item = yyjson_mut_obj(doc); - if (!compact || !ends_with_segment(sr->node.qualified_name, sr->node.name)) { - yyjson_mut_obj_add_str(doc, item, "name", sr->node.name ? sr->node.name : ""); + if ((!compact || !ends_with_segment(sr->node.qualified_name, sr->node.name)) && + sr->node.name && sr->node.name[0]) { + yyjson_mut_obj_add_str(doc, item, "name", sr->node.name); } yyjson_mut_obj_add_str(doc, item, "qualified_name", sr->node.qualified_name ? sr->node.qualified_name : ""); @@ -1695,6 +1697,47 @@ static char *handle_search_graph(cbm_mcp_server_t *srv, const char *args) { } } + /* When searching for dep projects returns nothing, explain why. + * Heuristic: dep search if expanded value ends with ".dep" (from "dep"/"deps" shorthand) + * or project_pattern contains ".dep." — both indicate a dependency project query. */ + if (out.total == 0) { + bool is_dep_search = false; + if (pe.mode == MATCH_PREFIX && pe.value) { + size_t n = strlen(pe.value); + is_dep_search = (n >= 4 && strcmp(pe.value + n - 4, ".dep") == 0); + } else if (pe.mode == MATCH_GLOB && pe.value) { + is_dep_search = (strstr(pe.value, ".dep.") != NULL || + strstr(pe.value, ".dep%") != NULL); + } + if (is_dep_search) { + /* Detect what build system is in use to give an actionable hint */ + cbm_pkg_manager_t eco = CBM_PKG_COUNT; + if (srv->session_root[0]) + eco = cbm_detect_ecosystem(srv->session_root); + char hint[1024]; + if (eco == CBM_PKG_COUNT) { + snprintf(hint, sizeof(hint), + "No dependency sub-projects indexed, and no recognized build system " + "detected in '%s'. Supported: Python/uv (pyproject.toml, requirements.txt), " + "Rust/cargo, npm/bun (package.json), Go (go.mod), JVM/Maven/Gradle, " + ".NET/NuGet (*.csproj), Ruby/Bundler (Gemfile), PHP/Composer, " + "Swift/SPM, Dart/pub, Elixir/Mix, C-Make (Makefile), C-CMake, " + "C-Meson, C-Conan, or generic vendor/ directory. " + "Re-index after adding a manifest file.", + srv->session_root[0] ? srv->session_root : "(unknown project root)"); + } else { + snprintf(hint, sizeof(hint), + "No dependency sub-projects indexed yet for %s build system '%s'. " + "Dep scanning runs automatically on index_repository. " + "If deps are vendored in vendor/ vendored/ third_party/ etc., " + "re-run index_repository(repo_path=\"%s\") to trigger dep discovery.", + cbm_pkg_manager_str(eco), cbm_pkg_manager_str(eco), + srv->session_root[0] ? srv->session_root : ""); + } + yyjson_mut_obj_add_strcpy(doc, root, "hint", hint); + } + } + char *json = yy_doc_to_str(doc); yyjson_mut_doc_free(doc); cbm_store_search_free(&out); @@ -1828,41 +1871,48 @@ static char *handle_index_status(cbm_mcp_server_t *srv, const char *args) { dep_params.project_pattern = dep_like; dep_params.limit = 100; cbm_search_output_t dep_out = {0}; - if (cbm_store_search(store, &dep_params, &dep_out) == 0 && dep_out.count > 0) { + if (cbm_store_search(store, &dep_params, &dep_out) == 0) { /* Collect unique dep project names */ - yyjson_mut_val *dep_arr = yyjson_mut_arr(doc); - const char *last_dep_proj = ""; - int dep_count = 0; - for (int i = 0; i < dep_out.count; i++) { - const char *proj = dep_out.results[i].node.project; - if (!proj || strcmp(proj, last_dep_proj) == 0) continue; - last_dep_proj = proj; - /* Extract package name from "myproj.dep.pandas" */ - const char *dep_sep = strstr(proj, CBM_DEP_SEPARATOR); - if (!dep_sep) continue; - const char *pkg = dep_sep + CBM_DEP_SEPARATOR_LEN; - yyjson_mut_val *d = yyjson_mut_obj(doc); - yyjson_mut_obj_add_strcpy(doc, d, "package", pkg); - int dn = cbm_store_count_nodes(store, proj); - yyjson_mut_obj_add_int(doc, d, "nodes", dn); - yyjson_mut_arr_add_val(dep_arr, d); - dep_count++; - } - if (dep_count > 0) { - yyjson_mut_obj_add_val(doc, root, "dependencies", dep_arr); - yyjson_mut_obj_add_int(doc, root, "dependency_count", dep_count); + if (dep_out.count > 0) { + yyjson_mut_val *dep_arr = yyjson_mut_arr(doc); + const char *last_dep_proj = ""; + int dep_count = 0; + for (int i = 0; i < dep_out.count; i++) { + const char *proj = dep_out.results[i].node.project; + if (!proj || strcmp(proj, last_dep_proj) == 0) continue; + last_dep_proj = proj; + /* Extract package name from "myproj.dep.pandas" */ + const char *dep_sep = strstr(proj, CBM_DEP_SEPARATOR); + if (!dep_sep) continue; + const char *pkg = dep_sep + CBM_DEP_SEPARATOR_LEN; + yyjson_mut_val *d = yyjson_mut_obj(doc); + yyjson_mut_obj_add_strcpy(doc, d, "package", pkg); + int dn = cbm_store_count_nodes(store, proj); + yyjson_mut_obj_add_int(doc, d, "nodes", dn); + yyjson_mut_arr_add_val(dep_arr, d); + dep_count++; + } + if (dep_count > 0) { + yyjson_mut_obj_add_val(doc, root, "dependencies", dep_arr); + yyjson_mut_obj_add_int(doc, root, "dependency_count", dep_count); + } } + /* Always free search results — cbm_store_search allocates even when count==0 */ cbm_store_search_free(&dep_out); } /* Report detected ecosystem */ cbm_project_t proj_info; - if (cbm_store_get_project(store, project, &proj_info) == 0 && proj_info.root_path) { - cbm_pkg_manager_t eco = cbm_detect_ecosystem(proj_info.root_path); - if (eco != CBM_PKG_COUNT) { - yyjson_mut_obj_add_str(doc, root, "detected_ecosystem", - cbm_pkg_manager_str(eco)); + if (cbm_store_get_project(store, project, &proj_info) == 0) { + if (proj_info.root_path) { + cbm_pkg_manager_t eco = cbm_detect_ecosystem(proj_info.root_path); + if (eco != CBM_PKG_COUNT) { + yyjson_mut_obj_add_str(doc, root, "detected_ecosystem", + cbm_pkg_manager_str(eco)); + } } + /* Always free project fields — cbm_store_get_project heap-allocates strings */ + cbm_project_free_fields(&proj_info); } /* Report PageRank stats */ { @@ -2029,10 +2079,11 @@ static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { const char *lbl = (const char *)sqlite3_column_text(kf_stmt, 2); const char *fp = (const char *)sqlite3_column_text(kf_stmt, 3); double rank = sqlite3_column_double(kf_stmt, 4); - if (n) yyjson_mut_obj_add_strcpy(doc, kf, "name", n); - if (qn) yyjson_mut_obj_add_strcpy(doc, kf, "qualified_name", qn); - if (lbl) yyjson_mut_obj_add_strcpy(doc, kf, "label", lbl); - if (fp) yyjson_mut_obj_add_strcpy(doc, kf, "file_path", fp); + if (n && !ends_with_segment(qn, n)) + yyjson_mut_obj_add_strcpy(doc, kf, "name", n); + if (qn) yyjson_mut_obj_add_strcpy(doc, kf, "qualified_name", qn); + if (lbl && lbl[0]) yyjson_mut_obj_add_strcpy(doc, kf, "label", lbl); + if (fp && fp[0]) yyjson_mut_obj_add_strcpy(doc, kf, "file_path", fp); add_pagerank_val(doc, kf, rank); yyjson_mut_arr_add_val(kf_arr, kf); } @@ -2167,10 +2218,10 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { seen_out[seen_out_n++] = tr_out.visited[i].node.id; } yyjson_mut_val *item = yyjson_mut_obj(doc); - if (!compact || !ends_with_segment(tr_out.visited[i].node.qualified_name, - tr_out.visited[i].node.name)) { - yyjson_mut_obj_add_str(doc, item, "name", - tr_out.visited[i].node.name ? tr_out.visited[i].node.name : ""); + if ((!compact || !ends_with_segment(tr_out.visited[i].node.qualified_name, + tr_out.visited[i].node.name)) && + tr_out.visited[i].node.name && tr_out.visited[i].node.name[0]) { + yyjson_mut_obj_add_str(doc, item, "name", tr_out.visited[i].node.name); } yyjson_mut_obj_add_str( doc, item, "qualified_name", @@ -2214,10 +2265,10 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { seen_in[seen_in_n++] = tr_in.visited[i].node.id; } yyjson_mut_val *item = yyjson_mut_obj(doc); - if (!compact || !ends_with_segment(tr_in.visited[i].node.qualified_name, - tr_in.visited[i].node.name)) { - yyjson_mut_obj_add_str(doc, item, "name", - tr_in.visited[i].node.name ? tr_in.visited[i].node.name : ""); + if ((!compact || !ends_with_segment(tr_in.visited[i].node.qualified_name, + tr_in.visited[i].node.name)) && + tr_in.visited[i].node.name && tr_in.visited[i].node.name[0]) { + yyjson_mut_obj_add_str(doc, item, "name", tr_in.visited[i].node.name); } yyjson_mut_obj_add_str( doc, item, "qualified_name", @@ -2240,6 +2291,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) { } free(seen_in); yyjson_mut_obj_add_val(doc, root, "callers", callers); + yyjson_mut_obj_add_int(doc, root, "callers_total", tr_in.visited_count); } if (srv->session_project[0]) @@ -2472,9 +2524,12 @@ static char *snippet_suggestions(const char *input, cbm_node_t *nodes, int count yyjson_mut_val *s = yyjson_mut_obj(doc); yyjson_mut_obj_add_str(doc, s, "qualified_name", nodes[i].qualified_name ? nodes[i].qualified_name : ""); - yyjson_mut_obj_add_str(doc, s, "name", nodes[i].name ? nodes[i].name : ""); - yyjson_mut_obj_add_str(doc, s, "label", nodes[i].label ? nodes[i].label : ""); - yyjson_mut_obj_add_str(doc, s, "file_path", nodes[i].file_path ? nodes[i].file_path : ""); + if (nodes[i].name && nodes[i].name[0]) + yyjson_mut_obj_add_str(doc, s, "name", nodes[i].name); + if (nodes[i].label && nodes[i].label[0]) + yyjson_mut_obj_add_str(doc, s, "label", nodes[i].label); + if (nodes[i].file_path && nodes[i].file_path[0]) + yyjson_mut_obj_add_str(doc, s, "file_path", nodes[i].file_path); yyjson_mut_arr_append(arr, s); } yyjson_mut_obj_add_val(doc, root, "suggestions", arr); @@ -2491,7 +2546,7 @@ static char *snippet_suggestions(const char *input, cbm_node_t *nodes, int count static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node, const char *match_method, bool include_neighbors, cbm_node_t *alternatives, int alt_count, - int max_lines, const char *mode) { + int max_lines, const char *mode, bool compact) { char *root_path = get_project_root(srv, node->project); int start = node->start_line > 0 ? node->start_line : 1; @@ -2537,10 +2592,13 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node, yyjson_mut_val *root_obj = yyjson_mut_obj(doc); yyjson_mut_doc_set_root(doc, root_obj); - yyjson_mut_obj_add_str(doc, root_obj, "name", node->name ? node->name : ""); + if (node->name && node->name[0] && + (!compact || !ends_with_segment(node->qualified_name, node->name))) + yyjson_mut_obj_add_str(doc, root_obj, "name", node->name); yyjson_mut_obj_add_str(doc, root_obj, "qualified_name", node->qualified_name ? node->qualified_name : ""); - yyjson_mut_obj_add_str(doc, root_obj, "label", node->label ? node->label : ""); + if (node->label && node->label[0]) + yyjson_mut_obj_add_str(doc, root_obj, "label", node->label); const char *display_path = ""; if (abs_path) { @@ -2548,7 +2606,8 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node, } else if (node->file_path) { display_path = node->file_path; } - yyjson_mut_obj_add_str(doc, root_obj, "file_path", display_path); + if (display_path[0]) + yyjson_mut_obj_add_str(doc, root_obj, "file_path", display_path); yyjson_mut_obj_add_int(doc, root_obj, "start_line", start); yyjson_mut_obj_add_int(doc, root_obj, "end_line", end); @@ -2605,13 +2664,23 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node, continue; } if (yyjson_is_str(val)) { - yyjson_mut_obj_add_str(doc, root_obj, k, yyjson_get_str(val)); + const char *sv = yyjson_get_str(val); + if (sv && sv[0]) + yyjson_mut_obj_add_str(doc, root_obj, k, sv); } else if (yyjson_is_bool(val)) { - yyjson_mut_obj_add_bool(doc, root_obj, k, yyjson_get_bool(val)); + bool bv = yyjson_get_bool(val); + /* compact: omit false booleans (false = absent/default) */ + if (!compact || bv) + yyjson_mut_obj_add_bool(doc, root_obj, k, bv); } else if (yyjson_is_int(val)) { - yyjson_mut_obj_add_int(doc, root_obj, k, yyjson_get_int(val)); + int64_t iv = yyjson_get_int(val); + /* compact: omit zero integers (0 = absent/default) */ + if (!compact || iv != 0) + yyjson_mut_obj_add_int(doc, root_obj, k, iv); } else if (yyjson_is_real(val)) { - yyjson_mut_obj_add_real(doc, root_obj, k, yyjson_get_real(val)); + double rv = yyjson_get_real(val); + if (!compact || rv != 0.0) + yyjson_mut_obj_add_real(doc, root_obj, k, rv); } } } @@ -2659,8 +2728,8 @@ static char *build_snippet_response(cbm_mcp_server_t *srv, cbm_node_t *node, yyjson_mut_obj_add_str(doc, a, "qualified_name", alternatives[i].qualified_name ? alternatives[i].qualified_name : ""); - yyjson_mut_obj_add_str(doc, a, "file_path", - alternatives[i].file_path ? alternatives[i].file_path : ""); + if (alternatives[i].file_path && alternatives[i].file_path[0]) + yyjson_mut_obj_add_str(doc, a, "file_path", alternatives[i].file_path); yyjson_mut_arr_append(arr, a); } yyjson_mut_obj_add_val(doc, root_obj, "alternatives", arr); @@ -2719,6 +2788,7 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { eff_project = srv->current_project; /* fallback: last-used project */ } } + bool compact = cbm_mcp_get_bool_arg_default(args, "compact", true); bool auto_resolve = cbm_mcp_get_bool_arg(args, "auto_resolve"); bool include_neighbors = cbm_mcp_get_bool_arg(args, "include_neighbors"); int cfg_max_lines = cbm_config_get_int(srv->config, CBM_CONFIG_SNIPPET_MAX_LINES, @@ -2749,7 +2819,7 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { if (rc == CBM_STORE_OK) { char *result = build_snippet_response(srv, &node, NULL /*exact*/, include_neighbors, NULL, 0, - max_lines, snippet_mode); + max_lines, snippet_mode, compact); free_node_contents(&node); free(qn); free(project); @@ -2765,7 +2835,7 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { copy_node(&suffix_nodes[0], &node); cbm_store_free_nodes(suffix_nodes, suffix_count); char *result = build_snippet_response(srv, &node, "suffix", include_neighbors, NULL, 0, - max_lines, snippet_mode); + max_lines, snippet_mode, compact); free_node_contents(&node); free(qn); free(project); @@ -2782,7 +2852,7 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { cbm_store_free_nodes(name_nodes, name_count); cbm_store_free_nodes(suffix_nodes, suffix_count); char *result = build_snippet_response(srv, &node, "name", include_neighbors, NULL, 0, - max_lines, snippet_mode); + max_lines, snippet_mode, compact); free_node_contents(&node); free(qn); free(project); @@ -2822,7 +2892,7 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { free_node_contents(&candidates[0]); free(candidates); char *result = build_snippet_response(srv, &node, "name", include_neighbors, NULL, 0, - max_lines, snippet_mode); + max_lines, snippet_mode, compact); free_node_contents(&node); free(qn); free(project); @@ -2874,7 +2944,7 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { char *result = build_snippet_response(srv, &node, "auto_best", include_neighbors, alts, alt_count, - max_lines, snippet_mode); + max_lines, snippet_mode, compact); free_node_contents(&node); for (int i = 0; i < alt_count; i++) { free_node_contents(&alts[i]); @@ -2931,7 +3001,7 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) { free_node_contents(&fuzzy[0]); free(fuzzy); char *result = build_snippet_response(srv, &node, "fuzzy", include_neighbors, NULL, 0, - max_lines, snippet_mode); + max_lines, snippet_mode, compact); free_node_contents(&node); free(qn); free(project); @@ -3191,7 +3261,8 @@ static char *handle_detect_changes(cbm_mcp_server_t *srv, const char *args) { if (nodes[i].label && strcmp(nodes[i].label, "File") != 0 && strcmp(nodes[i].label, "Folder") != 0 && strcmp(nodes[i].label, "Project") != 0) { yyjson_mut_val *item = yyjson_mut_obj(doc); - yyjson_mut_obj_add_str(doc, item, "name", nodes[i].name ? nodes[i].name : ""); + if (nodes[i].name && nodes[i].name[0]) + yyjson_mut_obj_add_str(doc, item, "name", nodes[i].name); yyjson_mut_obj_add_str(doc, item, "label", nodes[i].label); yyjson_mut_obj_add_str(doc, item, "file", line); yyjson_mut_arr_add_val(impacted, item); @@ -4149,10 +4220,11 @@ static void build_resource_architecture(yyjson_mut_doc *doc, yyjson_mut_val *roo const char *label = (const char *)sqlite3_column_text(stmt, 2); const char *fp = (const char *)sqlite3_column_text(stmt, 3); double rank = sqlite3_column_double(stmt, 4); - if (name) yyjson_mut_obj_add_strcpy(doc, kf, "name", name); - if (qn) yyjson_mut_obj_add_strcpy(doc, kf, "qualified_name", qn); - if (label) yyjson_mut_obj_add_strcpy(doc, kf, "label", label); - if (fp) yyjson_mut_obj_add_strcpy(doc, kf, "file_path", fp); + if (name && !ends_with_segment(qn, name)) + yyjson_mut_obj_add_strcpy(doc, kf, "name", name); + if (qn) yyjson_mut_obj_add_strcpy(doc, kf, "qualified_name", qn); + if (label && label[0]) yyjson_mut_obj_add_strcpy(doc, kf, "label", label); + if (fp && fp[0]) yyjson_mut_obj_add_strcpy(doc, kf, "file_path", fp); add_pagerank_val(doc, kf, rank); yyjson_mut_arr_add_val(kf_arr, kf); } diff --git a/src/pagerank/pagerank.c b/src/pagerank/pagerank.c index cc827afcd..a57fc9526 100644 --- a/src/pagerank/pagerank.c +++ b/src/pagerank/pagerank.c @@ -195,10 +195,20 @@ int cbm_pagerank_compute(cbm_store_t *store, const char *project, sqlite3_finalize(stmt); stmt = NULL; - if (N == 0) { free(node_ids); return 0; } + if (N == 0) { + free(node_ids); + free(node_labels); /* no strdup'd elements since N==0 */ + return 0; + } /* Build id->index map */ - if (id_map_init(&map, N) != 0) { free(node_ids); return -1; } + if (id_map_init(&map, N) != 0) { + free(node_ids); + /* free all strdup'd labels accumulated before the failure */ + for (int i = 0; i < N; i++) free(node_labels[i]); + free(node_labels); + return -1; + } for (int i = 0; i < N; i++) id_map_put(&map, node_ids[i], i); /* ── Step 2: Load weighted edges ──────────────────────── */ diff --git a/src/pipeline/pass_envscan.c b/src/pipeline/pass_envscan.c index 6f0f3cd9c..6dd18ab15 100644 --- a/src/pipeline/pass_envscan.c +++ b/src/pipeline/pass_envscan.c @@ -58,6 +58,21 @@ static void compile_patterns(void) { patterns_compiled = 1; } +/* Free all compiled regex patterns. Safe to call even if never compiled. + * Call this in test teardown or at process exit to suppress leak reports. */ +void cbm_envscan_free_patterns(void) { + if (!patterns_compiled) return; + cbm_regfree(&dockerfile_re); + cbm_regfree(&yaml_kv_re); + cbm_regfree(&yaml_setenv_re); + cbm_regfree(&terraform_re); + cbm_regfree(&shell_re); + cbm_regfree(&envfile_re); + cbm_regfree(&toml_re); + cbm_regfree(&properties_re); + patterns_compiled = 0; +} + #undef W #undef NW diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 3ffe04815..2e2faea99 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -110,6 +110,14 @@ void cbm_pipeline_free(cbm_pipeline_t *p) { free(p); } +void cbm_pipeline_global_cleanup(void) { + /* Release lazily-compiled regex patterns held by pass_envscan. + * These are compiled on first call to cbm_scan_project_env_urls() and + * cached for the process lifetime. Call this once at server shutdown, + * after all pipelines and background indexing threads have finished. */ + cbm_envscan_free_patterns(); +} + void cbm_pipeline_cancel(cbm_pipeline_t *p) { if (p) { atomic_store(&p->cancelled, 1); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 0b4540c3b..e3000cf89 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -45,6 +45,12 @@ cbm_pipeline_t *cbm_pipeline_new(const char *repo_path, const char *db_path, cbm /* Free a pipeline and all its internal state. NULL-safe. */ void cbm_pipeline_free(cbm_pipeline_t *p); +/* Release all process-lifetime global state held by the pipeline subsystem + * (e.g., lazily-compiled regex patterns used by pass_envscan). + * Call once at server shutdown, after all pipelines have been freed and all + * background indexing threads have been joined. Safe to call multiple times. */ +void cbm_pipeline_global_cleanup(void); + /* Run the full indexing pipeline. Returns 0 on success, -1 on error. * Discovers files, extracts, resolves, and dumps to SQLite. */ int cbm_pipeline_run(cbm_pipeline_t *p); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 450ecb6f7..a4bd2416a 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -401,4 +401,9 @@ typedef struct { * Returns number of bindings written to out (up to max_out). */ int cbm_scan_project_env_urls(const char *root_path, cbm_env_binding_t *out, int max_out); +/* Free all compiled regex patterns used by cbm_scan_project_env_urls. + * Patterns are compiled lazily on first use and cached for the process lifetime. + * Call this in test teardown to release ~26KB of regex memory cleanly. */ +void cbm_envscan_free_patterns(void); + #endif /* CBM_PIPELINE_INTERNAL_H */ diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 4d41d7e79..ca8445ad2 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -886,7 +886,9 @@ TEST(snippet_exact_qn) { call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," "\"project\":\"test-project\"}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + /* compact: name omitted when it equals last segment of qualified_name */ + ASSERT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + ASSERT_NOT_NULL(strstr(resp, "\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"")); ASSERT_NOT_NULL(strstr(resp, "\"source\"")); /* Exact match should NOT have match_method */ ASSERT_NULL(strstr(resp, "\"match_method\"")); @@ -903,6 +905,27 @@ TEST(snippet_exact_qn) { PASS(); } +/* ── TestSnippet_CompactFalse: name present when compact=false ── */ + +TEST(snippet_compact_false_name_present) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* compact=false: name must be present even when it equals last segment of QN */ + char *resp = call_snippet(srv, "{\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"," + "\"project\":\"test-project\"," + "\"compact\":false}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + ASSERT_NOT_NULL(strstr(resp, "\"qualified_name\":\"test-project.cmd.server.main.HandleRequest\"")); + free(resp); + + cbm_mcp_server_free(srv); + cleanup_snippet_dir(tmp); + PASS(); +} + /* ── TestSnippet_QNSuffix ─────────────────────────────────────── */ TEST(snippet_qn_suffix) { @@ -913,7 +936,9 @@ TEST(snippet_qn_suffix) { char *resp = call_snippet(srv, "{\"qualified_name\":\"main.HandleRequest\"," "\"project\":\"test-project\"}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + /* compact: name omitted when it equals last segment of qualified_name */ + ASSERT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + ASSERT_NOT_NULL(strstr(resp, "HandleRequest")); /* present in qualified_name */ ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\"")); ASSERT_NOT_NULL(strstr(resp, "\"source\"")); free(resp); @@ -934,7 +959,9 @@ TEST(snippet_unique_short_name) { char *resp = call_snippet(srv, "{\"qualified_name\":\"ProcessOrder\"," "\"project\":\"test-project\"}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"name\":\"ProcessOrder\"")); + /* compact: name omitted when it equals last segment of qualified_name */ + ASSERT_NULL(strstr(resp, "\"name\":\"ProcessOrder\"")); + ASSERT_NOT_NULL(strstr(resp, "ProcessOrder")); /* present in qualified_name */ ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\"")); ASSERT_NOT_NULL(strstr(resp, "\"source\"")); free(resp); @@ -955,7 +982,9 @@ TEST(snippet_name_tier) { char *resp = call_snippet(srv, "{\"qualified_name\":\"HandleRequest\"," "\"project\":\"test-project\"}"); ASSERT_NOT_NULL(resp); - ASSERT_NOT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + /* compact: name omitted when it equals last segment of qualified_name */ + ASSERT_NULL(strstr(resp, "\"name\":\"HandleRequest\"")); + ASSERT_NOT_NULL(strstr(resp, "HandleRequest")); /* present in qualified_name */ ASSERT_NOT_NULL(strstr(resp, "\"match_method\":\"suffix\"")); free(resp); @@ -1247,6 +1276,7 @@ SUITE(mcp) { /* Snippet resolution (port of snippet_test.go) */ RUN_TEST(snippet_exact_qn); + RUN_TEST(snippet_compact_false_name_present); RUN_TEST(snippet_qn_suffix); RUN_TEST(snippet_unique_short_name); RUN_TEST(snippet_name_tier); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index ca894254e..3ec50f864 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4875,4 +4875,7 @@ SUITE(pipeline) { RUN_TEST(githistory_compute_change_coupling); RUN_TEST(githistory_coupling_skips_large_commits); RUN_TEST(githistory_coupling_limits_output); + /* Release pipeline-level global state (compiled regex patterns etc.). + * Patterns are compiled on first use and cached; free once at suite end. */ + cbm_pipeline_global_cleanup(); } diff --git a/tests/test_token_reduction.c b/tests/test_token_reduction.c index 77fde8c42..bd00eb2f0 100644 --- a/tests/test_token_reduction.c +++ b/tests/test_token_reduction.c @@ -20,6 +20,9 @@ /* ── Helpers (reuse patterns from test_mcp.c) ────────────────── */ +/* Forward declaration — definition is in the SEARCH PARAMETERIZATION section */ +static cbm_mcp_server_t *setup_sp_server(void); + static char *extract_text_content_tr(const char *mcp_result) { if (!mcp_result) return NULL; @@ -556,6 +559,56 @@ TEST(trace_compact_omits_redundant_name) { PASS(); } +TEST(search_graph_compact_defaults_to_true) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + /* No compact param -> default is true -> name omitted when name == last qn segment. + * All sp-test nodes satisfy this (e.g. name="main", qn="sp-test.main.main"). */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"include_dependencies\":false}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_GT((int)yyjson_arr_size(results), 0); + yyjson_val *first = yyjson_arr_get(results, 0); + /* compact=true default: name == last qn segment -> name field OMITTED */ + ASSERT_NULL(yyjson_obj_get(first, "name")); + ASSERT_NOT_NULL(yyjson_obj_get(first, "qualified_name")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_graph_compact_false_includes_name) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"include_dependencies\":false," + "\"compact\":false}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_GT((int)yyjson_arr_size(results), 0); + yyjson_val *first = yyjson_arr_get(results, 0); + /* compact=false: name field present even when name matches qn suffix */ + ASSERT_NOT_NULL(yyjson_obj_get(first, "name")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * 1.4 SUMMARY MODE * ══════════════════════════════════════════════════════════════════ */ @@ -783,6 +836,151 @@ TEST(response_includes_meta_fields) { PASS(); } +/* ══════════════════════════════════════════════════════════════════ + * 1.9 FIELD OMISSION (empty label / file_path not emitted) + * ══════════════════════════════════════════════════════════════════ */ + +TEST(search_graph_omits_empty_label_and_file_path) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, "empty-test"); + cbm_store_upsert_project(st, "empty-test", "/tmp"); + + /* Node with empty label and empty file_path */ + cbm_node_t n = {0}; + n.project = "empty-test"; + n.label = ""; + n.name = "anon_func"; + n.qualified_name = "empty-test.mod.anon_func"; + n.file_path = ""; + n.properties_json = "{}"; + cbm_store_upsert_node(st, &n); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"empty-test\",\"compact\":false}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_EQ((int)yyjson_arr_size(results), 1); + yyjson_val *item = yyjson_arr_get(results, 0); + /* Empty label and file_path must be omitted, not emitted as "" */ + ASSERT_NULL(yyjson_obj_get(item, "label")); + ASSERT_NULL(yyjson_obj_get(item, "file_path")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +TEST(search_graph_includes_nonempty_label_and_file_path) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, "nonempty-test"); + cbm_store_upsert_project(st, "nonempty-test", "/tmp"); + + cbm_node_t n = {0}; + n.project = "nonempty-test"; + n.label = "Function"; + n.name = "do_work"; + n.qualified_name = "nonempty-test.worker.do_work"; + n.file_path = "worker.py"; + n.properties_json = "{}"; + cbm_store_upsert_node(st, &n); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"nonempty-test\",\"compact\":false}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_EQ((int)yyjson_arr_size(results), 1); + yyjson_val *item = yyjson_arr_get(results, 0); + /* Non-empty label and file_path must be present with correct values */ + ASSERT_NOT_NULL(yyjson_obj_get(item, "label")); + ASSERT_NOT_NULL(yyjson_obj_get(item, "file_path")); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item, "label")), "Function"); + ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item, "file_path")), "worker.py"); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* TDD: Zero in_degree/out_degree fields omitted when no edges. + * RED until Change 3 (zero degree omission) is implemented in mcp.c. */ +TEST(search_graph_omits_zero_degrees) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, "degree-test"); + cbm_store_upsert_project(st, "degree-test", "/tmp"); + + /* Node with no edges -> in_degree=0, out_degree=0 */ + cbm_node_t n = {0}; + n.project = "degree-test"; + n.label = "Function"; + n.name = "isolated"; + n.qualified_name = "degree-test.mod.isolated"; + n.file_path = "mod.py"; + n.properties_json = "{}"; + cbm_store_upsert_node(st, &n); + + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"degree-test\",\"compact\":false}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_EQ((int)yyjson_arr_size(results), 1); + yyjson_val *item = yyjson_arr_get(results, 0); + /* Zero in_degree and out_degree must be omitted, not emitted as 0 */ + ASSERT_NULL(yyjson_obj_get(item, "in_degree")); + ASSERT_NULL(yyjson_obj_get(item, "out_degree")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Non-zero degrees must still be present (regression guard for Change 3). */ +TEST(search_graph_includes_nonzero_degrees) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + /* process_request has in_degree=2 (CALLS from main, HTTP_CALLS from fetch_data) */ + char *raw = cbm_mcp_handle_tool(srv, "search_graph", + "{\"project\":\"sp-test\"," + "\"qn_pattern\":\".*process_request.*\"," + "\"include_dependencies\":false," + "\"compact\":false}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *results = yyjson_obj_get(yyjson_doc_get_root(doc), "results"); + ASSERT_NOT_NULL(results); + ASSERT_EQ((int)yyjson_arr_size(results), 1); + yyjson_val *item = yyjson_arr_get(results, 0); + /* process_request has non-zero in_degree -> must be present */ + ASSERT_NOT_NULL(yyjson_obj_get(item, "in_degree")); + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * SEARCH PARAMETERIZATION ACCURACY * TDD: Tests written BEFORE implementation. @@ -1179,6 +1377,285 @@ TEST(trace_call_path_default_edge_types_calls_only) { PASS(); } +/* ══════════════════════════════════════════════════════════════════ + * 2.0 JSON OUTPUT MINIFICATION + * All tool responses must be single-line minified JSON. + * yy_doc_to_str uses YYJSON_WRITE_ALLOW_INVALID_UNICODE (no PRETTY). + * Tests verify this contract holds across the full API surface. + * ══════════════════════════════════════════════════════════════════ */ + +TEST(all_mcp_responses_are_minified_json) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + + const char *tools[] = {"search_graph", "trace_call_path", "get_architecture", "query_graph"}; + const char *args[] = { + "{\"project\":\"sp-test\",\"limit\":3}", + "{\"function_name\":\"main\",\"project\":\"sp-test\"}", + "{\"project\":\"sp-test\"}", + "{\"query\":\"MATCH (n) RETURN n.name LIMIT 3\",\"project\":\"sp-test\"}" + }; + for (int t = 0; t < 4; t++) { + char *raw = cbm_mcp_handle_tool(srv, tools[t], args[t]); + char *text = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(text); + /* Pretty-printed JSON always contains newlines — must be absent */ + ASSERT_NULL(strstr(text, "\n")); + free(text); + } + + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.1 trace_call_path FIELD OMISSION (TDD) + * Candidates block uses empty-string fallback for file_path (mcp.c:2116). + * RED until candidates block is fixed like search_graph. + * ══════════════════════════════════════════════════════════════════ */ + +/* Empty file_path in a candidate must be omitted, not emitted as "". */ +TEST(trace_call_path_candidates_omits_empty_file_path) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + + /* Second "main" node with empty file_path forces ambiguity */ + cbm_node_t dup = {0}; + dup.project = "sp-test"; + dup.label = "Function"; + dup.name = "main"; + dup.qualified_name = "sp-test.alt.main"; + dup.file_path = ""; + dup.properties_json = "{}"; + cbm_store_upsert_node(st, &dup); + + char *raw = cbm_mcp_handle_tool(srv, "trace_call_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"candidates\"")); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *candidates = yyjson_obj_get(yyjson_doc_get_root(doc), "candidates"); + ASSERT_NOT_NULL(candidates); + + bool found = false; + for (size_t i = 0; i < yyjson_arr_size(candidates); i++) { + yyjson_val *c = yyjson_arr_get(candidates, i); + yyjson_val *qn = yyjson_obj_get(c, "qualified_name"); + if (qn && strcmp(yyjson_get_str(qn), "sp-test.alt.main") == 0) { + /* Candidate with empty file_path must NOT have the key */ + ASSERT_NULL(yyjson_obj_get(c, "file_path")); + found = true; + } + } + ASSERT_TRUE(found); + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* Non-empty file_path in candidates must still be present (regression guard). */ +TEST(trace_call_path_candidates_includes_nonempty_file_path) { + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + + cbm_node_t dup = {0}; + dup.project = "sp-test"; + dup.label = "Function"; + dup.name = "main"; + dup.qualified_name = "sp-test.alt.main"; + dup.file_path = "alt.py"; + dup.properties_json = "{}"; + cbm_store_upsert_node(st, &dup); + + char *raw = cbm_mcp_handle_tool(srv, "trace_call_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *candidates = yyjson_obj_get(yyjson_doc_get_root(doc), "candidates"); + ASSERT_NOT_NULL(candidates); + + /* All candidates here have non-empty file_path -> key must be present */ + for (size_t i = 0; i < yyjson_arr_size(candidates); i++) { + yyjson_val *c = yyjson_arr_get(candidates, i); + ASSERT_NOT_NULL(yyjson_obj_get(c, "file_path")); + } + + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.2 get_architecture COMPACT COVERAGE + * key_functions already uses null-guards (if (n), if (lbl), if (fp)). + * Tests verify the contract and that output remains minified. + * ══════════════════════════════════════════════════════════════════ */ + +TEST(get_architecture_output_is_minified_and_no_empty_fields) { + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, "arch-test"); + cbm_store_upsert_project(st, "arch-test", "/tmp"); + + cbm_node_t n = {0}; + n.project = "arch-test"; + n.label = "Function"; + n.name = "entry_point"; + n.qualified_name = "arch-test.main.entry_point"; + n.file_path = "main.py"; + n.properties_json = "{}"; + cbm_store_upsert_node(st, &n); + + char *raw = cbm_mcp_handle_tool(srv, "get_architecture", + "{\"project\":\"arch-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Must be minified */ + ASSERT_NULL(strstr(resp, "\n")); + + /* key_functions block must never emit empty-string values */ + ASSERT_NULL(strstr(resp, "\"name\":\"\"")); + ASSERT_NULL(strstr(resp, "\"label\":\"\"")); + ASSERT_NULL(strstr(resp, "\"file_path\":\"\"")); + ASSERT_NULL(strstr(resp, "\"qualified_name\":\"\"")); + + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.3 trace_call_path callers_total field + * ══════════════════════════════════════════════════════════════════ */ + +TEST(trace_call_path_response_includes_callers_total) { + /* TDD RED: callers_total never emitted (Bug C) — becomes GREEN after fix */ + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + /* direction=both triggers do_inbound=true; main has no callers but + * callers_total must still appear in the response */ + char *raw = cbm_mcp_handle_tool(srv, "trace_call_path", + "{\"function_name\":\"main\"," + "\"project\":\"sp-test\"," + "\"direction\":\"both\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + /* callers_total must be present even when callers array is empty */ + ASSERT_NOT_NULL(strstr(resp, "\"callers_total\"")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.4 get_code_snippet empty field omission + * ══════════════════════════════════════════════════════════════════ */ + +TEST(get_code_snippet_omits_empty_name_label) { + /* TDD RED: name/label emitted as "" when NULL/empty (Bug B) */ + cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); + ASSERT_NOT_NULL(srv); + cbm_store_t *st = cbm_mcp_server_store(srv); + cbm_mcp_server_set_project(srv, "snip-test"); + cbm_store_upsert_project(st, "snip-test", "/tmp"); + + /* Node with empty name and empty label — exercises the "" guard */ + cbm_node_t n = {0}; + n.project = "snip-test"; + n.name = ""; /* empty — should NOT appear as "name":"" */ + n.label = ""; /* empty — should NOT appear as "label":"" */ + n.qualified_name = "snip-test.mod.empty_node"; + n.file_path = ""; /* empty — should NOT appear as "file_path":"" */ + n.start_line = 1; + n.end_line = 2; + n.properties_json = "{}"; + cbm_store_upsert_node(st, &n); + + char *raw = cbm_mcp_handle_tool(srv, "get_code_snippet", + "{\"qualified_name\":\"snip-test.mod.empty_node\"," + "\"project\":\"snip-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + ASSERT_NULL(strstr(resp, "\"name\":\"\"")); + ASSERT_NULL(strstr(resp, "\"label\":\"\"")); + ASSERT_NULL(strstr(resp, "\"file_path\":\"\"")); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + +/* ══════════════════════════════════════════════════════════════════ + * 2.5 get_architecture compact applied to key_functions + * ══════════════════════════════════════════════════════════════════ */ + +TEST(get_architecture_compact_omits_redundant_name_in_key_functions) { + /* TDD RED: key_functions always emits name (Bug A) — becomes GREEN after fix. + * All sp-test nodes have name == last segment of qualified_name, so + * compact should omit every name field in key_functions. */ + cbm_mcp_server_t *srv = setup_sp_server(); + ASSERT_NOT_NULL(srv); + char *raw = cbm_mcp_handle_tool(srv, "get_architecture", + "{\"project\":\"sp-test\"}"); + char *resp = extract_text_content_tr(raw); + free(raw); + ASSERT_NOT_NULL(resp); + + /* Parse key_functions and assert no entry has a "name" key that equals + * the last segment of its "qualified_name" */ + yyjson_doc *doc = yyjson_read(resp, strlen(resp), 0); + ASSERT_NOT_NULL(doc); + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *kfs = yyjson_obj_get(root, "key_functions"); + if (kfs && yyjson_is_arr(kfs)) { + size_t idx, max; + yyjson_val *kf; + yyjson_arr_foreach(kfs, idx, max, kf) { + yyjson_val *name_val = yyjson_obj_get(kf, "name"); + yyjson_val *qn_val = yyjson_obj_get(kf, "qualified_name"); + if (name_val && qn_val) { + const char *nm = yyjson_get_str(name_val); + const char *qn = yyjson_get_str(qn_val); + /* If name is present, it must NOT equal the last segment of qn */ + if (nm && qn) { + size_t qn_len = strlen(qn); + size_t nm_len = strlen(nm); + bool is_suffix = (nm_len < qn_len) && + (qn[qn_len - nm_len - 1] == '.' || + qn[qn_len - nm_len - 1] == ':' || + qn[qn_len - nm_len - 1] == '/') && + strcmp(qn + qn_len - nm_len, nm) == 0; + ASSERT_FALSE(is_suffix); /* compact must have omitted this */ + } + } + } + } + yyjson_doc_free(doc); + free(resp); + cbm_mcp_server_free(srv); + PASS(); +} + /* ══════════════════════════════════════════════════════════════════ * SUITE * ══════════════════════════════════════════════════════════════════ */ @@ -1202,6 +1679,8 @@ SUITE(token_reduction) { /* 1.3 Compact Mode */ RUN_TEST(search_graph_compact_omits_redundant_name); + RUN_TEST(search_graph_compact_defaults_to_true); + RUN_TEST(search_graph_compact_false_includes_name); RUN_TEST(trace_compact_omits_redundant_name); /* 1.4 Summary Mode */ @@ -1220,6 +1699,22 @@ SUITE(token_reduction) { /* 1.8 Token Metadata */ RUN_TEST(response_includes_meta_fields); + /* 1.9 Field Omission */ + RUN_TEST(search_graph_omits_empty_label_and_file_path); + RUN_TEST(search_graph_includes_nonempty_label_and_file_path); + RUN_TEST(search_graph_omits_zero_degrees); + RUN_TEST(search_graph_includes_nonzero_degrees); + + /* 2.0 JSON Output Minification */ + RUN_TEST(all_mcp_responses_are_minified_json); + + /* 2.1 trace_call_path Field Omission */ + RUN_TEST(trace_call_path_candidates_omits_empty_file_path); + RUN_TEST(trace_call_path_candidates_includes_nonempty_file_path); + + /* 2.2 get_architecture Compact Coverage */ + RUN_TEST(get_architecture_output_is_minified_and_no_empty_fields); + /* Search Parameterization Accuracy */ RUN_TEST(search_graph_qn_pattern_filters_results); RUN_TEST(search_graph_qn_pattern_no_match_returns_empty); @@ -1233,4 +1728,13 @@ SUITE(token_reduction) { RUN_TEST(trace_call_path_compact_false_includes_name); RUN_TEST(trace_call_path_edge_types_http_calls_traverses_http_edges); RUN_TEST(trace_call_path_default_edge_types_calls_only); + + /* 2.3 callers_total field completeness */ + RUN_TEST(trace_call_path_response_includes_callers_total); + + /* 2.4 get_code_snippet empty field omission */ + RUN_TEST(get_code_snippet_omits_empty_name_label); + + /* 2.5 get_architecture compact key_functions */ + RUN_TEST(get_architecture_compact_omits_redundant_name_in_key_functions); }