fix(leaks,depindex,mcp): fix 206 heap leaks, expand ecosystem detection to 17 managers, improve compact output

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
  "<session>.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 <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-03-25 21:40:11 -04:00
parent 2ef29ace6d
commit de3dcdcd8d
13 changed files with 1022 additions and 127 deletions
+101 -4
View File
@@ -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)
+157 -31
View File
@@ -15,6 +15,7 @@
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
/* ── 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";
+28 -17
View File
@@ -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. */
+8
View File
@@ -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);
+141 -69
View File
@@ -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 : "<repo_path>");
}
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);
}
+12 -2
View File
@@ -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 ──────────────────────── */
+15
View File
@@ -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
+8
View File
@@ -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);
+6
View File
@@ -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);
+5
View File
@@ -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 */
+34 -4
View File
@@ -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);
+3
View File
@@ -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();
}
+504
View File
@@ -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);
}