From 902f85b0f9566dea5a73a5dc2a724a282dc25fcb Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Thu, 27 Aug 2026 04:05:48 +0200
Subject: [PATCH 01/31] fix(install): forward clients selector
Signed-off-by: Martin Vogel
---
install.sh | 55 ++++++---
tests/test_smoke_fixture_contract.sh | 168 +++++++++++++++++++++++++++
2 files changed, 208 insertions(+), 15 deletions(-)
diff --git a/install.sh b/install.sh
index 094c04a43..9f4b4ec99 100755
--- a/install.sh
+++ b/install.sh
@@ -19,6 +19,8 @@ main() {
REPO="DeusData/codebase-memory-mcp"
INSTALL_DIR="$HOME/.local/bin"
SKIP_CONFIG=false
+CLIENTS_SET=false
+CLIENTS=""
CBM_DOWNLOAD_URL="${CBM_DOWNLOAD_URL:-https://github.com/${REPO}/releases/latest/download}"
# Security: every remote hop must remain HTTPS. Plain HTTP is accepted only
@@ -79,26 +81,46 @@ download_file() {
fi
}
-for arg in "$@"; do
- case "$arg" in
- --dir=*) INSTALL_DIR="${arg#--dir=}" ;;
- --skip-config) SKIP_CONFIG=true ;;
+while [ "$#" -gt 0 ]; do
+ case "$1" in
+ --dir=*)
+ INSTALL_DIR="${1#--dir=}"
+ shift
+ ;;
+ --dir)
+ if [ "$#" -lt 2 ] || [[ "$2" == -* ]]; then
+ echo "install.sh: '--dir' needs a value. Please consult --help." >&2
+ exit 2
+ fi
+ INSTALL_DIR="$2"
+ shift 2
+ ;;
+ --clients=*)
+ CLIENTS_SET=true
+ CLIENTS="${1#--clients=}"
+ shift
+ ;;
+ --skip-config)
+ SKIP_CONFIG=true
+ shift
+ ;;
--help|-h)
- echo "Usage: install.sh [--dir=] [--skip-config]"
- echo " --dir PATH Install directory (default: ~/.local/bin)"
- echo " --skip-config Skip automatic agent configuration"
+ echo "Usage: install.sh [--dir=] [--clients=] [--skip-config]"
+ echo " --dir PATH Install directory (default: ~/.local/bin)"
+ echo " --clients LIST Configure only comma-separated clients"
+ echo " --skip-config Skip automatic agent configuration"
exit 0
;;
+ -*)
+ echo "install.sh: unknown option '$1'. Please consult --help." >&2
+ exit 2
+ ;;
+ *)
+ echo "install.sh: unexpected argument '$1'. Please consult --help." >&2
+ exit 2
+ ;;
esac
done
-# Handle --dir (space-separated)
-prev=""
-for arg in "$@"; do
- if [ "$prev" = "--dir" ]; then
- INSTALL_DIR="$arg"
- fi
- prev="$arg"
-done
detect_os() {
case "$(uname -s)" in
@@ -314,6 +336,9 @@ echo "Verified candidate: $CANDIDATE_VERSION"
DEST="$INSTALL_DIR/codebase-memory-mcp"
INSTALL_ARGS=(-y --force "--dir=$INSTALL_DIR")
+if [ "$CLIENTS_SET" = true ]; then
+ INSTALL_ARGS+=("--clients=$CLIENTS")
+fi
if [ "$SKIP_CONFIG" = true ]; then
INSTALL_ARGS+=(--skip-config)
fi
diff --git a/tests/test_smoke_fixture_contract.sh b/tests/test_smoke_fixture_contract.sh
index 43fb248fd..9bd88d941 100755
--- a/tests/test_smoke_fixture_contract.sh
+++ b/tests/test_smoke_fixture_contract.sh
@@ -8,10 +8,13 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd)"
python3 - "$ROOT" <<'PY'
from __future__ import annotations
+import hashlib
+import os
import pathlib
import re
import subprocess
import sys
+import tarfile
import tempfile
import time
import urllib.request
@@ -413,6 +416,171 @@ require(
"scripts/test.sh must run the smoke fixture contract",
)
+# Functional wrapper contract: install.sh must pass its wrapper-only selectors
+# to the downloaded candidate, while retaining the existing dir/skip controls.
+# Native Windows ships and exercises install.ps1; the Unix wrapper is covered
+# on both macOS and Linux venue legs.
+if sys.platform != "win32":
+ with tempfile.TemporaryDirectory(prefix="cbm-install-wrapper-") as temp:
+ temp_path = pathlib.Path(temp)
+ fixture = temp_path / "fixture"
+ payload = temp_path / "payload"
+ fixture.mkdir()
+ payload.mkdir()
+
+ uname_s = subprocess.check_output(["uname", "-s"], text=True).strip()
+ uname_m = subprocess.check_output(["uname", "-m"], text=True).strip()
+ os_name = "darwin" if uname_s == "Darwin" else "linux"
+ if uname_m in ("arm64", "aarch64"):
+ arch_name = "arm64"
+ else:
+ arch_name = "amd64"
+ portable = "-portable" if os_name == "linux" else ""
+ archive_name = (
+ f"codebase-memory-mcp-{os_name}-{arch_name}{portable}.tar.gz"
+ )
+ archive = fixture / archive_name
+
+ candidate = payload / "codebase-memory-mcp"
+ candidate.write_text(
+ "#!/usr/bin/env bash\n"
+ "set -euo pipefail\n"
+ "if [ \"${1:-}\" = --version ]; then echo 'cbm fixture 0'; exit 0; fi\n"
+ "if [ \"${1:-}\" != install ]; then exit 64; fi\n"
+ "printf '%s\\n' \"$@\" > \"$CBM_INSTALL_ARG_LOG\"\n"
+ "target=''\n"
+ "for arg in \"$@\"; do case \"$arg\" in --dir=*) target=${arg#--dir=} ;; esac; done\n"
+ "[ -n \"$target\" ] || exit 65\n"
+ "mkdir -p \"$target\"\n"
+ "cp \"$0\" \"$target/codebase-memory-mcp\"\n",
+ encoding="utf-8",
+ )
+ candidate.chmod(0o755)
+ (payload / "LICENSE").write_text("fixture license\n", encoding="utf-8")
+ (payload / "install.sh").write_text(install_script, encoding="utf-8")
+ (payload / "THIRD_PARTY_NOTICES.md").write_text(
+ "fixture notices\n", encoding="utf-8"
+ )
+ with tarfile.open(archive, "w:gz") as bundle:
+ for name in (
+ "codebase-memory-mcp",
+ "LICENSE",
+ "install.sh",
+ "THIRD_PARTY_NOTICES.md",
+ ):
+ bundle.add(payload / name, arcname=name)
+ digest = hashlib.sha256(archive.read_bytes()).hexdigest()
+ (fixture / "checksums.txt").write_text(
+ f"{digest} {archive_name}\n", encoding="ascii"
+ )
+
+ # Replace curl only inside the subprocess environment. The wrapper still
+ # performs its real archive/checksum/extraction flow, but all bytes come
+ # from this generated fixture and no socket is opened.
+ fake_bin = temp_path / "fake-bin"
+ fake_bin.mkdir()
+ fake_curl = fake_bin / "curl"
+ fake_curl.write_text(
+ "#!/usr/bin/env python3\n"
+ "import os, pathlib, shutil, sys, urllib.parse\n"
+ "args = sys.argv[1:]\n"
+ "target = pathlib.Path(args[args.index('-o') + 1])\n"
+ "name = pathlib.PurePosixPath(urllib.parse.urlparse(args[-1]).path).name\n"
+ "shutil.copyfile(pathlib.Path(os.environ['CBM_INSTALL_FIXTURE']) / name, target)\n",
+ encoding="utf-8",
+ )
+ fake_curl.chmod(0o755)
+
+ wrapper = root / "install.sh"
+ help_result = subprocess.run(
+ [str(wrapper), "--help"],
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ timeout=10,
+ check=False,
+ )
+ require(
+ help_result.returncode == 0
+ and "--dir" in help_result.stdout
+ and "--skip-config" in help_result.stdout
+ and "--clients" in help_result.stdout,
+ "install.sh --help must document dir, skip-config, and clients",
+ )
+
+ base_env = dict(os.environ)
+ base_env.update(
+ CBM_DOWNLOAD_URL="http://127.0.0.1:9",
+ CBM_INSTALL_FIXTURE=str(fixture),
+ HOME=str(temp_path / "home"),
+ PATH=f"{fake_bin}{os.pathsep}{base_env['PATH']}",
+ )
+ pathlib.Path(base_env["HOME"]).mkdir()
+
+ def run_wrapper(
+ log_name: str, *arguments: str
+ ) -> tuple[subprocess.CompletedProcess[str], list[str]]:
+ argument_log = temp_path / log_name
+ env = dict(base_env)
+ env["CBM_INSTALL_ARG_LOG"] = str(argument_log)
+ result = subprocess.run(
+ [str(wrapper), *arguments],
+ env=env,
+ text=True,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ timeout=20,
+ check=False,
+ )
+ logged = (
+ argument_log.read_text(encoding="utf-8").splitlines()
+ if argument_log.is_file()
+ else []
+ )
+ return result, logged
+
+ unknown, unknown_args = run_wrapper(
+ "unknown-args.log", "--definitely-not-a-wrapper-flag"
+ )
+ require(
+ unknown.returncode == 2
+ and "Please consult --help." in unknown.stdout
+ and not unknown_args,
+ "install.sh must reject unknown flags before executing the candidate",
+ )
+
+ selected_dir = temp_path / "selected-bin"
+ selected, selected_args = run_wrapper(
+ "selected-args.log",
+ f"--dir={selected_dir}",
+ "--clients=claude,codex",
+ "--skip-config",
+ )
+ require(
+ selected.returncode == 0
+ and selected_args
+ == [
+ "install",
+ "-y",
+ "--force",
+ f"--dir={selected_dir}",
+ "--clients=claude,codex",
+ "--skip-config",
+ ],
+ "install.sh must forward the explicit clients selector with dir and skip-config",
+ )
+
+ ordinary_dir = temp_path / "ordinary-bin"
+ ordinary, ordinary_args = run_wrapper(
+ "ordinary-args.log", f"--dir={ordinary_dir}"
+ )
+ require(
+ ordinary.returncode == 0
+ and ordinary_args
+ == ["install", "-y", "--force", f"--dir={ordinary_dir}"],
+ "install.sh without selectors must preserve the ordinary install arguments",
+ )
+
# Functional check: the helper must publish a live kernel-assigned port and
# serve the exact expected artifact. This is intentionally build-free.
if helper.is_file():
From 787f87d81c47b8f9355c69638c0f3a473d899c7d Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Wed, 26 Aug 2026 18:15:09 +0200
Subject: [PATCH 02/31] fix: block sensitive Windows program roots
Signed-off-by: Martin Vogel
---
src/foundation/workspace.c | 44 ++++++++-
src/mcp/mcp.c | 35 +++++++
src/mcp/mcp_internal.h | 8 ++
tests/repro/repro_issue403.c | 177 +++++++----------------------------
tests/test_mcp.c | 82 ++++++++++++++++
tests/test_workspace.c | 118 +++++++++++++++++++++++
6 files changed, 315 insertions(+), 149 deletions(-)
diff --git a/src/foundation/workspace.c b/src/foundation/workspace.c
index a85e17822..cb254f3d9 100644
--- a/src/foundation/workspace.c
+++ b/src/foundation/workspace.c
@@ -211,6 +211,34 @@ static bool ws_any_component_matches(const char *path, const char *const *names,
return false;
}
+/* The default per-user Windows application prefix. Match from the drive root,
+ * by whole segments, so a project merely containing the same sequence (or a
+ * sibling such as Programs-src) is not captured. The second segment is the one
+ * and only user name; its spelling is deliberately unrestricted. */
+static bool ws_is_windows_user_programs_tree(const char *path) {
+ if (!ws_is_windows_style(path)) {
+ return false;
+ }
+ static const char *const expected[] = {"Users", NULL, "AppData", "Local", "Programs"};
+ const char *p = path + ws_volume_prefix_len(path);
+ for (size_t i = 0; i < sizeof(expected) / sizeof(expected[0]); i++) {
+ while (ws_is_sep(*p)) {
+ p++;
+ }
+ if (!*p) {
+ return false;
+ }
+ const char *start = p;
+ while (*p && !ws_is_sep(*p)) {
+ p++;
+ }
+ if (expected[i] && !ws_component_equals(start, (size_t)(p - start), expected[i], true)) {
+ return false;
+ }
+ }
+ return true;
+}
+
/* True when b is a or lives under a. Compares on a separator boundary so
* "/a/bc" is not treated as living under "/a/b". */
static bool ws_is_ancestor_or_equal(const char *a, const char *b) {
@@ -297,6 +325,10 @@ cbm_ws_verdict_t cbm_workspace_classify_root(const char *canonical_path, const c
return CBM_WS_DENY_SENSITIVE;
}
+ if (ws_is_windows_user_programs_tree(canonical_path)) {
+ return CBM_WS_DENY_SENSITIVE;
+ }
+
/* "C:/Users" is the user tree itself — refuse it as a root, but leave the
* projects below it alone; that is where Windows users actually work. */
if (windows_style && depth == 1 &&
@@ -316,7 +348,7 @@ const char *cbm_workspace_verdict_reason(cbm_ws_verdict_t verdict) {
case CBM_WS_DENY_ABSOLUTE:
return "path is a volume root or holds the codebase-memory cache; it cannot be indexed";
case CBM_WS_DENY_SENSITIVE:
- return "path is a home or credential directory";
+ return "path is a home, credential, system, or application-install directory";
default:
break;
}
@@ -455,10 +487,15 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir,
}
}
- /* Already present is success, not a duplicate error. */
+ bool sensitive = verdict == CBM_WS_DENY_SENSITIVE;
+
+ /* Already present is success, not a duplicate error. A sensitive approval
+ * is narrower: an old ordinary grant (or an ordinary ancestor) establishes
+ * containment, but does not record the exact human-approved exception that
+ * cbm_workspace_root_allowed requires. Append that exact marked entry once. */
ws_match_t existing = {canonical_path, false, false};
(void)ws_grant_walk(cache_dir, ws_match_visit, &existing);
- if (existing.contained) {
+ if (existing.contained && (!sensitive || existing.exact_sensitive)) {
return true;
}
@@ -476,7 +513,6 @@ bool cbm_workspace_grant_add(const char *cache_dir, const char *home_dir,
}
return false;
}
- bool sensitive = verdict == CBM_WS_DENY_SENSITIVE;
(void)fprintf(f, "%s%s\n", sensitive ? "!" : "", canonical_path);
bool ok = fclose(f) == 0;
if (!ok && err) {
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index 04316627b..4c4982929 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -1600,6 +1600,10 @@ struct cbm_mcp_server {
void *quarantine_test_context;
cbm_mcp_command_test_hook_fn command_test_hook;
void *command_test_context;
+#ifdef CBM_ENABLE_TEST_SEAMS
+ cbm_mcp_auto_index_count_test_hook_fn auto_index_count_test_hook;
+ void *auto_index_count_test_context;
+#endif
size_t search_output_limit_override;
cbm_thread_t autoindex_tid;
bool autoindex_active; /* true if auto-index thread was started */
@@ -1669,6 +1673,17 @@ void cbm_mcp_server_set_config(cbm_mcp_server_t *srv, struct cbm_config *cfg) {
}
}
+#ifdef CBM_ENABLE_TEST_SEAMS
+void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv,
+ cbm_mcp_auto_index_count_test_hook_fn hook,
+ void *context) {
+ if (srv) {
+ srv->auto_index_count_test_hook = hook;
+ srv->auto_index_count_test_context = context;
+ }
+}
+#endif
+
bool cbm_mcp_server_set_session_context(cbm_mcp_server_t *srv, const char *session_root,
const char *allowed_root) {
if (!srv || !session_root || session_root[0] == '\0' ||
@@ -11624,6 +11639,21 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) {
return; /* no session root detected */
}
+ /* Automatic work must honor the same shared workspace boundary as the
+ * explicit index_repository worker. Do this before the existing-DB watcher
+ * branch and before bounded discovery, so a refused session root begins no
+ * automatic observation or indexing. An exact sensitive-root grant remains
+ * the shared policy's authenticated override. */
+ const char *allowed_root =
+ srv->allowed_root_policy_set ? srv->allowed_root : getenv("CBM_ALLOWED_ROOT");
+ char boundary_err[CBM_SZ_1K];
+ if (!cbm_workspace_root_allowed(srv->session_root, cbm_workspace_home_dir(),
+ cbm_workspace_cache_dir(), allowed_root, boundary_err,
+ sizeof(boundary_err))) {
+ cbm_log_warn("autoindex.skip", "reason", "workspace_boundary", "detail", boundary_err);
+ return;
+ }
+
/* Check if project already has a DB */
const char *home = cbm_get_home_dir();
if (home) {
@@ -11656,6 +11686,11 @@ static void maybe_auto_index(cbm_mcp_server_t *srv) {
/* Quick tracked-file count check to avoid OOM on massive repos. */
int file_count = -1;
+#ifdef CBM_ENABLE_TEST_SEAMS
+ if (srv->auto_index_count_test_hook) {
+ srv->auto_index_count_test_hook(srv->auto_index_count_test_context);
+ }
+#endif
if (!cbm_mcp_auto_index_within_file_limit(srv->session_root, file_limit, &file_count)) {
char files[32];
(void)snprintf(files, sizeof(files), "%d", file_count);
diff --git a/src/mcp/mcp_internal.h b/src/mcp/mcp_internal.h
index d9321c595..61b9383c9 100644
--- a/src/mcp/mcp_internal.h
+++ b/src/mcp/mcp_internal.h
@@ -9,12 +9,20 @@
* safety tests. This header is internal and is not part of the MCP API. */
typedef bool (*cbm_mcp_quarantine_test_hook_fn)(void *context, const char *step);
typedef bool (*cbm_mcp_command_test_hook_fn)(void *context, const char *command);
+#ifdef CBM_ENABLE_TEST_SEAMS
+typedef void (*cbm_mcp_auto_index_count_test_hook_fn)(void *context);
+#endif
void cbm_mcp_server_set_quarantine_test_hook(cbm_mcp_server_t *srv,
cbm_mcp_quarantine_test_hook_fn hook, void *context);
void cbm_mcp_server_set_command_test_hook(cbm_mcp_server_t *srv, cbm_mcp_command_test_hook_fn hook,
void *context);
void cbm_mcp_server_set_search_output_limit_for_test(cbm_mcp_server_t *srv, size_t limit);
+#ifdef CBM_ENABLE_TEST_SEAMS
+void cbm_mcp_server_set_auto_index_count_test_hook(cbm_mcp_server_t *srv,
+ cbm_mcp_auto_index_count_test_hook_fn hook,
+ void *context);
+#endif
/* Release only the constructor-created pristine in-memory store. Public
* cbm_mcp_server_new(NULL) semantics remain unchanged; daemon sessions use
diff --git a/tests/repro/repro_issue403.c b/tests/repro/repro_issue403.c
index 09f4e4bbe..c2451b9ea 100644
--- a/tests/repro/repro_issue403.c
+++ b/tests/repro/repro_issue403.c
@@ -1,159 +1,46 @@
/*
- * repro_issue403.c -- Reproduce-first case for OPEN bug #403.
+ * repro_issue403.c -- Reproduce-first boundary case for issue #403.
*
- * Issue: #403 -- "The IDE's installation directory is unnecessarily indexed"
- * https://github.com/DeusData/codebase-memory-mcp/issues/403
- *
- * Wrongly-indexed directory: AppData/Local/Programs/Antigravity
- * (the Antigravity IDE install tree; reported name confirmed in issue comments)
- *
- * Root cause (src/discover/discover.c):
- * cbm_should_skip_dir() (line 339) tests only the BARE directory name
- * (entry->name, the last path component) against ALWAYS_SKIP_DIRS and
- * FAST_SKIP_DIRS. None of "AppData", "Local", "Programs", or "Antigravity"
- * appears in either list. Therefore cbm_discover() walks straight into the
- * IDE install tree and indexes every source-like file it contains.
- *
- * There is no install-directory guard at ANY layer:
- * - ALWAYS_SKIP_DIRS covers VCS, build tools, and caches -- not IDE
- * install prefixes (Programs, AppData/Local/Programs, etc.).
- * - The .gitignore path is only loaded when a .git directory is present
- * (is_git_repo gate, line 777 of discover.c). An IDE install dir does
- * not contain .git, so .gitignore exclusions never fire.
- * - The cbmignore path (opts->ignore_file or .cbmignore at root) is
- * similarly absent from an install dir by default.
- * Result: any source-extension file found under Antigravity/ is returned
- * as a discovered file, bloating the graph with IDE internals.
- *
- * Expected (correct) behaviour:
- * When cbm_discover() is called on a directory that contains an
- * "Antigravity" subdirectory (or more generally any IDE install subtree),
- * files under that subdirectory must NOT appear in the discovered file list.
- * The correct fix (per the issue owner's comment) is to add "Antigravity"
- * (and the broader "Programs" / install-dir pattern) to the exclusion layer,
- * OR to extend the exclusion to root-path patterns so auto-index never picks
- * an install dir as a project root in the first place.
- *
- * Actual (buggy) behaviour:
- * cbm_discover() returns files under Antigravity/ as normal discovered
- * files because the bare dirname "Antigravity" is absent from ALWAYS_SKIP_DIRS.
- *
- * Why RED on current code:
- * The fixture creates a temp dir with:
- * normal.py -- a legitimate source file (control: MUST appear)
- * Antigravity/ide.py -- sentinel inside the IDE install dir (MUST NOT appear)
- * cbm_discover() is called on the temp dir. The loop below asserts that
- * ide.py is NOT in the result. On current code "Antigravity" is not skipped,
- * so ide.py IS discovered and the ASSERT_FALSE fires RED.
- *
- * Fix location (not implemented here):
- * src/discover/discover.c, ALWAYS_SKIP_DIRS array:
- * Add "Antigravity" (and any other IDE install dir names to be excluded)
- * to the NULL-terminated list. The broader fix is to extend the list with
- * install-path components ("Programs", "AppData") or, per the issue owner,
- * to implement a root-path exclusion in the auto-index root-selection logic
- * so directories under AppData/Local/Programs are never chosen as repo roots.
- *
- * Exclusion is NOT config-driven in the current code. The closest knob is a
- * .cbmignore file at the repo root (loaded unconditionally, unlike .gitignore
- * which requires .git/). Passing opts->ignore_file also works. However,
- * neither is set in this test -- we assert on the default behaviour, which is
- * what the bug reporter experiences.
+ * Automatic session discovery must not treat the default per-user Windows
+ * application install tree as a workspace. This is a root-selection policy,
+ * not a vendor-name exclusion: ordinary projects elsewhere remain eligible.
*/
-#include
#include "test_framework.h"
-#include "test_helpers.h"
-#include "discover/discover.h"
+#include "foundation/workspace.h"
-#include
-#include
-#include
+TEST(repro_issue403_windows_user_programs_root_is_sensitive) {
+ static const char *const roots[] = {
+ "C:/Users/dev/AppData/Local/Programs",
+ "C:/Users/dev/AppData/Local/Programs/Antigravity",
+ "D:/Users/runneradmin/AppData/Local/Programs/Editor",
+ "c:\\uSeRs\\Dev\\aPpDaTa\\LoCaL\\pRoGrAmS\\IDE",
+ };
-/* ── Fixture ────────────────────────────────────────────────────────────────
- *
- * Directory layout (NOT a git repo -- no .git/ subdir):
- *
- * /
- * normal.py <- legitimate source file; MUST be discovered
- * Antigravity/
- * ide.py <- sentinel inside IDE install dir; must NOT appear
- *
- * cbm_discover() is called on with no opts (NULL) so all default
- * exclusions apply and no extra ignore file is consulted.
- *
- * Control assertion (expected GREEN even on buggy code):
- * normal.py IS in the result -- proves discovery ran at all.
- *
- * Primary assertion (RED on buggy code):
- * ide.py is NOT in the result -- the Antigravity subtree was skipped.
- */
-
-TEST(repro_issue403_install_dir_excluded) {
- /* --- set up temp directory --- */
- char tmpdir[256];
- snprintf(tmpdir, sizeof(tmpdir), "%s/cbm_repro403_XXXXXX", cbm_tmpdir());
- ASSERT_NOT_NULL(cbm_mkdtemp(tmpdir));
-
- /* Control file: a normal Python source at the repo root. */
- ASSERT_EQ(0, th_write_file(TH_PATH(tmpdir, "normal.py"),
- "def hello(): return 1\n"));
-
- /* Sentinel file: a Python source inside the Antigravity install dir.
- * This is the file that MUST be absent from discovery results.
- * th_write_file creates intermediate directories automatically. */
- ASSERT_EQ(0, th_write_file(TH_PATH(tmpdir, "Antigravity/ide.py"),
- "# Antigravity IDE internal module\ndef _internal(): pass\n"));
-
- /* --- Run discovery (default opts: no .git, no .cbmignore, no opts) --- */
- cbm_file_info_t *files = NULL;
- int count = 0;
- int rc = cbm_discover(tmpdir, NULL, &files, &count);
- ASSERT_EQ(0, rc);
-
- /* --- Scan results --- */
- bool normal_found = false;
- bool ide_file_found = false;
- for (int i = 0; i < count; i++) {
- if (strcmp(files[i].rel_path, "normal.py") == 0) {
- normal_found = true;
- }
- /* Match any path that descends into the Antigravity directory. */
- if (strncmp(files[i].rel_path, "Antigravity/", 12) == 0 ||
- strcmp(files[i].rel_path, "Antigravity") == 0) {
- ide_file_found = true;
- printf(" BUG #403 reproduced: IDE install-dir file indexed: %s\n",
- files[i].rel_path);
- }
+ for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) {
+ ASSERT_EQ(cbm_workspace_classify_root(roots[i], NULL, NULL), CBM_WS_DENY_SENSITIVE);
}
-
- cbm_discover_free(files, count);
- th_rmtree(tmpdir);
-
- /* Control: normal.py must be discovered -- discovery ran correctly. */
- ASSERT_TRUE(normal_found);
-
- /*
- * PRIMARY assertion (RED on buggy code):
- *
- * No file under Antigravity/ may appear in the discovered set.
- * On current code, "Antigravity" is absent from ALWAYS_SKIP_DIRS so
- * cbm_should_skip_dir("Antigravity", ...) returns false and the walk
- * descends into it. ide.py is discovered, ide_file_found is true, and
- * this ASSERT_FALSE fires RED.
- *
- * After the fix -- "Antigravity" added to ALWAYS_SKIP_DIRS (or an
- * equivalent install-path exclusion applied) -- cbm_should_skip_dir
- * returns true, the subtree is skipped, ide_file_found stays false,
- * and this assertion passes GREEN.
- */
- ASSERT_FALSE(ide_file_found);
-
+ ASSERT_TRUE(cbm_workspace_verdict_is_overridable(CBM_WS_DENY_SENSITIVE));
PASS();
}
-/* ── Suite ──────────────────────────────────────────────────────────────── */
+TEST(repro_issue403_nearby_projects_are_not_false_positives) {
+ static const char *const roots[] = {
+ "C:/Users/dev/projects/app",
+ "C:/Users/dev/AppData/Local",
+ "C:/Users/dev/AppData/Local/Programs-src",
+ "C:/Users/dev/AppData/Local/ProgramsBackup",
+ "C:/workspace/Users/dev/AppData/Local/Programs",
+ "C:/Users/dev/team/AppData/Local/Programs",
+ };
+
+ for (size_t i = 0; i < sizeof(roots) / sizeof(roots[0]); i++) {
+ ASSERT_EQ(cbm_workspace_classify_root(roots[i], NULL, NULL), CBM_WS_ALLOW);
+ }
+ PASS();
+}
SUITE(repro_issue403) {
- RUN_TEST(repro_issue403_install_dir_excluded);
+ RUN_TEST(repro_issue403_windows_user_programs_root_is_sensitive);
+ RUN_TEST(repro_issue403_nearby_projects_are_not_false_positives);
}
diff --git a/tests/test_mcp.c b/tests/test_mcp.c
index 278d33e68..df19badef 100644
--- a/tests/test_mcp.c
+++ b/tests/test_mcp.c
@@ -10,6 +10,7 @@
#include "../src/foundation/log.h"
#include "../src/foundation/platform.h" /* cbm_file_size */
#include "../src/foundation/subprocess.h"
+#include "../src/foundation/workspace.h"
#include "../src/mcp/compact_out.h"
#include "test_framework.h"
#include "test_helpers.h"
@@ -1293,6 +1294,83 @@ TEST(server_handle_initialized_notification) {
PASS();
}
+#ifdef CBM_ENABLE_TEST_SEAMS
+static void issue403_count_started(void *context) {
+ int *calls = context;
+ (*calls)++;
+}
+
+static int issue403_initialize_count_calls(const char *session_root, bool approve_sensitive) {
+ char *cache = th_mktempdir("cbm_mcp_403");
+ if (!cache) {
+ return -1;
+ }
+ const char *saved_cache = getenv("CBM_CACHE_DIR");
+ char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
+ cbm_setenv("CBM_CACHE_DIR", cache, 1);
+
+ char err[1024];
+ bool approved = !approve_sensitive ||
+ cbm_workspace_grant_add(cache, cbm_workspace_home_dir(), session_root, true,
+ err, sizeof(err));
+ cbm_config_t *cfg = approved ? cbm_config_open(cache) : NULL;
+ cbm_mcp_server_t *srv = cfg ? cbm_mcp_server_new(NULL) : NULL;
+ int calls = -2;
+ if (srv) {
+ cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX, "true");
+ /* The hook proves entry to the count path; keep the actual discovery
+ * call fail-closed so this unit test can never launch an index thread. */
+ cbm_config_set(cfg, CBM_CONFIG_AUTO_INDEX_LIMIT, "-1");
+ if (cbm_mcp_server_set_session_context(srv, session_root, NULL)) {
+ calls = 0;
+ cbm_mcp_server_set_config(srv, cfg);
+ cbm_mcp_server_set_auto_index_count_test_hook(srv, issue403_count_started, &calls);
+ char *response = cbm_mcp_server_handle(
+ srv, "{\"jsonrpc\":\"2.0\",\"id\":403,\"method\":\"initialize\",\"params\":{}}");
+ free(response);
+ }
+ cbm_mcp_server_free(srv);
+ }
+ if (cfg) {
+ cbm_config_close(cfg);
+ }
+
+ restore_cache_dir(saved_cache_copy);
+ free(saved_cache_copy);
+ th_cleanup(cache);
+ return calls;
+}
+
+TEST(mcp_issue403_sensitive_root_stops_before_discovery_count) {
+ int sensitive = issue403_initialize_count_calls(
+ "C:/Users/dev/AppData/Local/Programs/Antigravity", false);
+ int ordinary = issue403_initialize_count_calls("C:/Users/dev/projects/app", false);
+ ASSERT_EQ(sensitive, 0);
+ ASSERT_EQ(ordinary, 1);
+ PASS();
+}
+
+TEST(mcp_issue403_explicit_approval_preserves_auto_index) {
+ char *sensitive_home = th_mktempdir("cbm_mcp_403_home");
+ ASSERT_NOT_NULL(sensitive_home);
+ const char *saved_home = getenv("HOME");
+ char *saved_home_copy = saved_home ? strdup(saved_home) : NULL;
+ cbm_setenv("HOME", sensitive_home, 1);
+
+ int approved = issue403_initialize_count_calls(sensitive_home, true);
+
+ if (saved_home_copy) {
+ cbm_setenv("HOME", saved_home_copy, 1);
+ } else {
+ cbm_unsetenv("HOME");
+ }
+ free(saved_home_copy);
+ th_cleanup(sensitive_home);
+ ASSERT_EQ(approved, 1);
+ PASS();
+}
+#endif
+
TEST(server_handle_tools_list) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
@@ -11291,6 +11369,10 @@ SUITE(mcp) {
/* Server protocol handling */
RUN_TEST(server_handle_initialize);
RUN_TEST(server_handle_initialized_notification);
+#ifdef CBM_ENABLE_TEST_SEAMS
+ RUN_TEST(mcp_issue403_sensitive_root_stops_before_discovery_count);
+ RUN_TEST(mcp_issue403_explicit_approval_preserves_auto_index);
+#endif
RUN_TEST(server_handle_tools_list);
RUN_TEST(server_handle_tools_list_defaults_to_all_tools_and_accepts_cursor);
RUN_TEST(server_handle_analysis_profile_filters_and_rejects_mutators);
diff --git a/tests/test_workspace.c b/tests/test_workspace.c
index 736b4477f..faf936e1b 100644
--- a/tests/test_workspace.c
+++ b/tests/test_workspace.c
@@ -132,6 +132,120 @@ TEST(ws_windows_system_trees_are_sensitive) {
PASS();
}
+TEST(ws_windows_user_programs_tree_has_exact_sensitive_boundaries) {
+ static const char *const sensitive[] = {
+ "C:/Users/dev/AppData/Local/Programs",
+ "C:/Users/dev/AppData/Local/Programs/Antigravity",
+ "D:/Users/runneradmin/AppData/Local/Programs/Editor",
+ "c:\\uSeRs\\Dev\\aPpDaTa\\LoCaL\\pRoGrAmS\\IDE",
+ };
+ for (size_t i = 0; i < sizeof(sensitive) / sizeof(sensitive[0]); i++) {
+ ASSERT_EQ(cbm_workspace_classify_root(sensitive[i], HOME, CACHE),
+ CBM_WS_DENY_SENSITIVE);
+ }
+
+ static const char *const allowed[] = {
+ "C:/Users/dev/projects/app",
+ "C:/Users/dev/AppData/Local",
+ "C:/Users/dev/AppData/Local/Programs-src",
+ "C:/Users/dev/AppData/Local/ProgramsBackup",
+ "C:/workspace/Users/dev/AppData/Local/Programs",
+ "C:/Users/dev/team/AppData/Local/Programs",
+ };
+ for (size_t i = 0; i < sizeof(allowed) / sizeof(allowed[0]); i++) {
+ ASSERT_EQ(cbm_workspace_classify_root(allowed[i], HOME, CACHE), CBM_WS_ALLOW);
+ }
+ PASS();
+}
+
+TEST(ws_sensitive_root_explicit_approval_is_preserved) {
+ char root[256];
+ char *created = th_mktempdir("cbm_ws_403_home");
+ ASSERT_NOT_NULL(created);
+ snprintf(root, sizeof(root), "%s", created);
+
+ char cache[256];
+ created = th_mktempdir("cbm_ws_403");
+ ASSERT_NOT_NULL(created);
+ snprintf(cache, sizeof(cache), "%s", created);
+
+ char err[1024];
+ ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err)));
+ ASSERT_NOT_NULL(strstr(err, "--approve-sensitive"));
+ ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err)));
+ ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err)));
+
+ th_cleanup(root);
+ th_cleanup(cache);
+ PASS();
+}
+
+TEST(ws_sensitive_approval_upgrades_existing_ordinary_exact_grant) {
+ char root[256];
+ char *created = th_mktempdir("cbm_ws_403_exact");
+ ASSERT_NOT_NULL(created);
+ snprintf(root, sizeof(root), "%s", created);
+
+ char cache[256];
+ created = th_mktempdir("cbm_ws_403_exact_cache");
+ ASSERT_NOT_NULL(created);
+ snprintf(cache, sizeof(cache), "%s", created);
+
+ char err[1024];
+ ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, root, false, err, sizeof(err)));
+ ASSERT_FALSE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err)));
+
+ ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err)));
+ ASSERT_TRUE(cbm_workspace_root_allowed(root, root, cache, NULL, err, sizeof(err)));
+ /* A repeated explicit approval must recognize the marked exact grant rather
+ * than append another exception. */
+ ASSERT_TRUE(cbm_workspace_grant_add(cache, root, root, true, err, sizeof(err)));
+
+ char listed[4096];
+ char expected[4096];
+ ASSERT_TRUE(cbm_workspace_grant_list(cache, listed, sizeof(listed)));
+ snprintf(expected, sizeof(expected), "%s\n(approved) %s\n", root, root);
+ ASSERT_STR_EQ(listed, expected);
+
+ th_cleanup(root);
+ th_cleanup(cache);
+ PASS();
+}
+
+TEST(ws_sensitive_approval_adds_exact_exception_under_ordinary_ancestor) {
+ char ancestor[256];
+ char *created = th_mktempdir("cbm_ws_403_ancestor");
+ ASSERT_NOT_NULL(created);
+ snprintf(ancestor, sizeof(ancestor), "%s", created);
+
+ char cache[256];
+ created = th_mktempdir("cbm_ws_403_ancestor_cache");
+ ASSERT_NOT_NULL(created);
+ snprintf(cache, sizeof(cache), "%s", created);
+
+ char sensitive[1024];
+ snprintf(sensitive, sizeof(sensitive), "%s/private-project", ancestor);
+ ASSERT_EQ(cbm_mkdir(sensitive), 0);
+ char err[1024];
+ ASSERT_TRUE(cbm_workspace_grant_add(cache, NULL, ancestor, false, err, sizeof(err)));
+ ASSERT_FALSE(cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err)));
+
+ ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err)));
+ ASSERT_TRUE(
+ cbm_workspace_root_allowed(sensitive, sensitive, cache, NULL, err, sizeof(err)));
+ ASSERT_TRUE(cbm_workspace_grant_add(cache, sensitive, sensitive, true, err, sizeof(err)));
+
+ char listed[4096];
+ char expected[4096];
+ ASSERT_TRUE(cbm_workspace_grant_list(cache, listed, sizeof(listed)));
+ snprintf(expected, sizeof(expected), "%s\n(approved) %s\n", ancestor, sensitive);
+ ASSERT_STR_EQ(listed, expected);
+
+ th_cleanup(ancestor);
+ th_cleanup(cache);
+ PASS();
+}
+
/* POSIX is case-sensitive, so a differently-cased directory is a different one
* and must not be refused. */
TEST(ws_posix_matching_is_case_sensitive) {
@@ -275,6 +389,10 @@ SUITE(workspace) {
RUN_TEST(ws_home_itself_is_sensitive_but_subdirs_are_fine);
RUN_TEST(ws_credential_directories_are_sensitive_at_any_depth);
RUN_TEST(ws_windows_system_trees_are_sensitive);
+ RUN_TEST(ws_windows_user_programs_tree_has_exact_sensitive_boundaries);
+ RUN_TEST(ws_sensitive_root_explicit_approval_is_preserved);
+ RUN_TEST(ws_sensitive_approval_upgrades_existing_ordinary_exact_grant);
+ RUN_TEST(ws_sensitive_approval_adds_exact_exception_under_ordinary_ancestor);
RUN_TEST(ws_posix_matching_is_case_sensitive);
RUN_TEST(ws_null_context_disables_dependent_checks);
RUN_TEST(ws_every_verdict_has_a_reason);
From 31724178b2623b074f1ab87b1588f91a0dd5760d Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Wed, 26 Aug 2026 18:25:31 +0200
Subject: [PATCH 03/31] docs: preserve savings measurement guide
Signed-off-by: Martin Vogel
---
README.md | 2 +
docs/MEASURING_SAVINGS.md | 296 ++++++++++++++++++++++++++++++++++++++
2 files changed, 298 insertions(+)
create mode 100644 docs/MEASURING_SAVINGS.md
diff --git a/README.md b/README.md
index ca39ef418..ef766813e 100644
--- a/README.md
+++ b/README.md
@@ -287,6 +287,8 @@ Benchmarked on Apple M3 Pro:
**Token efficiency**: Five structural queries consumed ~3,400 tokens via codebase-memory-mcp versus ~412,000 tokens via file-by-file grep exploration — a **99.2% reduction**.
+To measure comparable quality, latency, and agent-efficiency metrics on your own workload, see [Measuring quality, latency, and agent savings](docs/MEASURING_SAVINGS.md). Exact reproduction of the figures above requires the original inputs and raw artifacts.
+
## Troubleshooting & Diagnostics
codebase-memory-mcp runs **100% locally and collects no telemetry** — your code, queries, environment, and usage never leave your machine. That privacy guarantee also means that when you hit something we can't reproduce on our side (a slow memory climb over hours, a performance regression, a leak that only appears after days of real use), **we have no data at all unless you choose to send it.** Here is how to capture it yourself.
diff --git a/docs/MEASURING_SAVINGS.md b/docs/MEASURING_SAVINGS.md
new file mode 100644
index 000000000..60c0fdbdd
--- /dev/null
+++ b/docs/MEASURING_SAVINGS.md
@@ -0,0 +1,296 @@
+# Measuring quality, latency, and agent savings
+
+This guide describes how to measure codebase-memory-mcp (CBM) on your own
+repository without mixing three different questions:
+
+1. Does the agent give a better answer?
+2. Is CBM fast and stable under the workload?
+3. Does graph-assisted exploration use fewer model tokens or tool calls than
+ file-by-file exploration?
+
+Measure and report those classes separately. A fast graph query does not prove
+that the final answer is correct, and CBM's query counters do not reveal the
+agent's model-token or total tool-call consumption. The README's published
+[performance figures](../README.md#performance), the
+[language benchmark](BENCHMARK.md) and the more detailed
+[evaluation plan](EVALUATION_PLAN.md) provide the grading concepts and the
+larger comparative methodology behind this smaller recipe.
+
+## Freeze the experiment
+
+The shell examples in this guide require a POSIX-compatible shell on macOS or
+Linux, or Git Bash on Windows. They are not native PowerShell syntax.
+Run the command blocks in one dedicated shell: the setup and preflight blocks
+enable fail-fast handling, and they capture Git command output before testing it
+so a failed inner command cannot be mistaken for a clean worktree.
+
+Before either condition runs, record:
+
+- the repository URL or local identity and exact Git commit SHA;
+- the question set and expected scope of each answer;
+- the CBM version, index mode, operating system, and machine details;
+- the agent model/version, system prompt, tool instructions, context limits,
+ and per-question budget;
+- which condition runs first, plus any warm-up policy;
+- the client or evaluation harness used to record tokens and tool calls.
+
+Use the same repository SHA, questions, model, prompt, budget, and clean-session
+policy for both conditions. Do not let the second condition see the first
+condition's answers or tool results. If runs are repeated, choose the repeat
+count and aggregation method before looking at results and retain every run,
+including failures and zero-result queries.
+
+Do not run either condition in a merely clean-looking working copy. A frozen
+experiment includes untracked files, so create separate clean detached
+worktrees for the two conditions:
+
+```bash
+set -eu
+SOURCE_REPO=/absolute/path/to/source-repository
+COMMITISH=main
+SHA=$(git -C "$SOURCE_REPO" rev-parse "$COMMITISH^{commit}") || exit 1
+MODE=full
+RUN_ROOT=$(mktemp -d) || exit 1
+GRAPH_REPO="$RUN_ROOT/graph"
+BASELINE_REPO="$RUN_ROOT/baseline"
+ARTIFACT_ROOT="$RUN_ROOT/artifacts"
+
+git -C "$SOURCE_REPO" worktree add --detach "$GRAPH_REPO" "$SHA" || exit 1
+git -C "$SOURCE_REPO" worktree add --detach "$BASELINE_REPO" "$SHA" || exit 1
+mkdir "$ARTIFACT_ROOT" || exit 1
+
+GRAPH_SHA=$(git -C "$GRAPH_REPO" rev-parse HEAD) || exit 1
+GRAPH_STATUS=$(git -C "$GRAPH_REPO" status --porcelain=v1 --untracked-files=all) || exit 1
+BASELINE_SHA=$(git -C "$BASELINE_REPO" rev-parse HEAD) || exit 1
+BASELINE_STATUS=$(git -C "$BASELINE_REPO" status --porcelain=v1 --untracked-files=all) || exit 1
+test "$GRAPH_SHA" = "$SHA" || exit 1
+test -z "$GRAPH_STATUS" || exit 1
+test "$BASELINE_SHA" = "$SHA" || exit 1
+test -z "$BASELINE_STATUS" || exit 1
+```
+
+Repeat the matching SHA and cleanliness assertions immediately before indexing
+and immediately before each condition. If either assertion fails, stop and
+create a new detached worktree; do not erase unknown files to make a reused
+checkout appear clean. Write run artifacts under `ARTIFACT_ROOT`, outside both
+condition worktrees.
+
+## Preflight the graph
+
+Immediately before every Graph-condition measurement, repeat its clean-worktree
+assertions and require a successful fresh index of that exact worktree and mode:
+
+```bash
+set -eu
+GRAPH_SHA=$(git -C "$GRAPH_REPO" rev-parse HEAD) || exit 1
+GRAPH_STATUS=$(git -C "$GRAPH_REPO" status --porcelain=v1 --untracked-files=all) || exit 1
+test "$GRAPH_SHA" = "$SHA" || exit 1
+test -z "$GRAPH_STATUS" || exit 1
+codebase-memory-mcp cli index_repository \
+ --repo-path "$GRAPH_REPO" \
+ --mode "$MODE" || exit 1
+GRAPH_SHA_AFTER=$(git -C "$GRAPH_REPO" rev-parse HEAD) || exit 1
+GRAPH_STATUS_AFTER=$(git -C "$GRAPH_REPO" status --porcelain=v1 --untracked-files=all) || exit 1
+test "$GRAPH_SHA_AFTER" = "$SHA" || exit 1
+test -z "$GRAPH_STATUS_AFTER" || exit 1
+```
+
+Take `PROJECT_NAME` from the successful indexing response. A verbose status call
+is useful only as a root/current-HEAD cross-check:
+
+```bash
+codebase-memory-mcp cli list_projects
+codebase-memory-mcp cli index_status --project PROJECT_NAME --verbose
+```
+
+Confirm that `root_path` is `GRAPH_REPO`, `git.head_sha` is `SHA`, and the Git
+context is detached. `index_status` describes the project root and its current
+filesystem Git context; it does **not** by itself prove that indexed records
+were produced from that revision. The immediately preceding successful fresh
+`index_repository` call is the freshness requirement.
+
+Finally, run one or more representative queries whose expected symbols you
+have verified directly at the recorded SHA:
+
+```bash
+codebase-memory-mcp cli search_graph \
+ --project PROJECT_NAME \
+ --name-pattern 'KNOWN_SYMBOL_PATTERN' \
+ --limit 10
+```
+
+Do not start measurement until the fresh index succeeds, the status cross-check
+names the intended detached checkout and current SHA, and the representative
+queries return the expected symbols. Save the indexing response, status, and
+query outputs with the run artifacts. Immediately before the file-by-file
+condition, repeat the corresponding SHA and cleanliness assertions for
+`BASELINE_REPO`.
+
+## 1. Measure answer quality
+
+Create a fixed set of real developer questions. Include a mix of definition
+discovery, relationships or call paths, targeted source retrieval,
+architecture, and cross-cutting questions where those dimensions apply. Record
+the expected scope or an independently derived ground truth for each question.
+
+Run two isolated conditions:
+
+| Condition | Allowed exploration tools |
+|-----------|---------------------------|
+| Graph | CBM graph tools such as `search_graph`, `trace_path`, `query_graph`, `get_code_snippet`, `get_architecture`, and `search_code` |
+| File-by-file baseline | File listing, text search, and targeted file reads only |
+
+Grade the final answers against the source at the frozen SHA, not against how
+plausible they sound. The compact rubric in [BENCHMARK.md](BENCHMARK.md) uses
+PASS (1.0), PARTIAL (0.5), and FAIL (0.0), excluding truly inapplicable
+questions from the denominator. For a finer comparison, follow
+[EVALUATION_PLAN.md](EVALUATION_PLAN.md): score correctness, completeness, and
+specificity separately, blind the grader to which condition produced each
+answer, randomize A/B order, and require source evidence for the grade.
+
+Keep quality scores beside, but separate from, efficiency metrics. A token
+reduction is only useful when the answer still meets the chosen quality bar.
+
+## 2. Measure latency and stability
+
+Record indexing time separately from query latency, and classify the index run
+as full-source, artifact-assisted, or incremental. When no local project
+database exists, `index_repository` can import a compatible
+`.codebase-memory/graph.db.zst` and then take the incremental-manifest route.
+Before timing, record whether that artifact exists in `GRAPH_REPO`, retain the
+index log, and require the later successful `artifact.import` record containing
+`db` and `size_mb` before classifying the run as artifact-assisted. A bootstrap
+attempt alone, or an `artifact.import` record containing `skip` or `err`, does
+not prove that the artifact was used. For queries, use the same fixed workload
+in the same order, identify warm-up calls in advance, and retain per-call
+durations and exit status rather than only a single average. Report the machine,
+OS, CBM version, repository SHA, index mode, question set, index-run class, and
+whether query results are cold or warm.
+
+### Built-in diagnostics
+
+For a daemon-backed run, enable diagnostics before the first session starts.
+The daemon captures its environment at startup; if it is already running,
+close all daemon-backed sessions before changing the setting. See
+[Configuration](CONFIGURATION.md#4-environment-variables) for the complete
+environment contract.
+
+```bash
+export CBM_DIAGNOSTICS=1
+```
+
+CBM creates a fresh randomized, owner-private diagnostics directory below the
+system temporary directory. Do not assume or construct an old predictable
+`/tmp` filename. Discover the exact `snapshot` and `trajectory` paths from the
+`diagnostics.start` JSON record in
+`${CBM_CACHE_DIR}/logs/cbm-daemon.log` (the default cache directory is
+`~/.cache/codebase-memory-mcp`). The record is emitted even when the configured
+log level suppresses ordinary logging. The README's
+[diagnostics section](../README.md#troubleshooting--diagnostics) explains the
+files and retention behavior.
+
+The live `snapshot.json` includes CBM-side `query_count`, `query_errors`,
+`query_total_us`, `query_avg_us`, and `query_max_us`, along with process resource
+counters. The retained `trajectory.ndjson` provides the resource and query-count
+trend over time. These are useful for CBM latency and stability analysis, but
+they are not a record of an agent's model usage or of non-CBM tools.
+Because a daemon can be shared by multiple sessions, use an otherwise idle
+daemon and record before/after values (or start a dedicated run) when attributing
+its counters to one workload.
+
+### Canonical soak workload
+
+From a source checkout, the canonical endurance entry point is:
+
+```bash
+scripts/soak-legs.sh build/c/codebase-memory-mcp 10
+```
+
+It runs the quick mixed workload and the read-only query-leak workload, checks
+for a valid completion summary, and writes per-call latency plus resource
+results. The quick leg writes `soak-results/`; the query-leak leg writes
+`soak-results-query-leak/`. Each directory contains `latency.csv`, `metrics.csv`,
+and `summary.txt`. The two CSV files begin with these exact headers:
+
+```text
+timestamp,tool,duration_ms,exit_code
+timestamp,uptime_s,rss_bytes,heap_committed,fd_count,query_count,query_max_us
+```
+
+Do not invoke `scripts/soak-test.sh` directly; `soak-legs.sh` owns the
+release-gating sequence. Treat these artifacts as stability and CBM latency
+evidence, not as answer-quality or model-token evidence.
+
+## 3. Measure token and tool-call savings
+
+Run the Graph and file-by-file conditions on the same frozen inputs described
+above. The MCP client or evaluation harness must capture agent usage because
+CBM cannot know the final model input/output token count or the agent's total
+tool-call consumption.
+
+Define each `run_id` as one paired experimental replicate. Each
+`(run_id, condition)` is exactly one isolated client session that answers one
+question. Capture one row for every usage window the client directly measures:
+
+```text
+run_id,condition,repo_sha,question_id,window,input_tokens,output_tokens,total_tokens,tool_calls,wall_time_ms,answer_artifact,quality_score
+```
+
+In each row, `tool_calls` is the count of every client tool invocation inside
+that same window, including orchestration calls and the graph or file tools
+permitted for that condition, plus retries, errors, and zero-result calls. Do
+not count only CBM calls. Candidate windows are:
+
+- **Answering tokens:** input plus output tokens between fixed markers around
+ the question-answering phase.
+- **Full-session tokens:** the entire isolated session, including orientation,
+ initial probes, dead ends, and answer formatting.
+
+The full-session value best represents an adopter's total cost; the answering
+window helps explain where a difference arose. Do not substitute CBM's
+`query_count` for either `tool_calls` value: it cannot see file searches, file
+reads, or other client tools.
+
+For each Graph/baseline pair, compare only a window that both clients report
+directly, with at most one row per `(run_id, condition, window)`. If either
+client cannot expose a window, omit that window or record its usage fields as
+N/A and exclude it from the comparison. Never infer a missing window, partition
+a session total across questions, or duplicate one session total into multiple
+question rows.
+
+Select one window `W` and calculate from the Graph and baseline rows for that
+same window only:
+
+```text
+token reduction (%) = 100 * (baseline tokens - Graph tokens) / baseline tokens
+tool-call reduction (%) = 100 * (baseline calls - Graph calls) / baseline calls
+token ratio = baseline tokens / Graph tokens
+tool-call ratio = baseline calls / Graph calls
+```
+
+Calculate and label every common measured window separately; never mix their
+numerators or denominators. If baseline tokens or calls are zero, the
+corresponding reduction percentage is N/A. If Graph tokens or calls are zero,
+the corresponding ratio is N/A. Do not add pseudocounts. Publish the raw paired
+counts alongside ratios and percentages. Also publish the quality result for
+each pair, the number of runs, aggregation method, failures, and experimental
+controls. Never turn a result from one repository, question set, model, or
+machine into a universal savings claim.
+
+## Reproducibility checklist
+
+- Repository identity and exact SHA are recorded; each condition uses a clean,
+ detached worktree with no tracked or untracked changes.
+- CBM version, index mode, project name, and preflight outputs are retained.
+- Every Graph run follows a successful fresh index of the frozen worktree and
+ mode; verbose status is only a root/current-HEAD cross-check.
+- Questions, ground truth, prompts, budgets, and condition order are frozen.
+- Graph and file-by-file runs use isolated sessions and the same controls.
+- Quality, CBM latency/stability, and agent savings are reported separately.
+- Usage counts come directly from the client or evaluation harness; each
+ condition run is one isolated question/session, common windows use separate
+ rows, and unsupported windows are omitted or N/A rather than inferred.
+- Diagnostics paths come from `diagnostics.start`, not a guessed temp path.
+- Raw outputs, errors, zero results, and calculation inputs are retained.
+- Claims identify their repository, SHA, question set, model, machine, and run
+ count, with no invented or extrapolated benchmark numbers.
From 3a8c0d9758e5337d3878b2099dad3e2001bb81d9 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Thu, 27 Aug 2026 05:38:15 +0200
Subject: [PATCH 04/31] fix: enforce workspace policy in daemon background work
Signed-off-by: Martin Vogel
---
src/daemon/application.c | 30 ++++
tests/test_daemon_application.c | 263 ++++++++++++++++++++++++++++++++
2 files changed, 293 insertions(+)
diff --git a/src/daemon/application.c b/src/daemon/application.c
index 7e4a35be7..12543808e 100644
--- a/src/daemon/application.c
+++ b/src/daemon/application.c
@@ -14,6 +14,7 @@
#include "foundation/secure_random.h"
#include "foundation/sha256.h"
#include "foundation/subprocess.h"
+#include "foundation/workspace.h"
#include "mcp/index_supervisor.h"
#include "mcp/mcp.h"
#include "mcp/mcp_internal.h"
@@ -429,6 +430,22 @@ static void application_release_session_watch_locked(cbm_daemon_application_sess
}
}
+static bool application_session_workspace_allowed(const cbm_daemon_application_session_t *session,
+ const char *operation) {
+ const char *root = session ? cbm_mcp_server_session_root(session->mcp) : NULL;
+ char boundary_error[CBM_SZ_1K];
+ bool allowed =
+ root && root[0] &&
+ cbm_workspace_root_allowed(root, cbm_workspace_home_dir(), cbm_workspace_cache_dir(),
+ cbm_mcp_server_allowed_root(session->mcp), boundary_error,
+ sizeof(boundary_error));
+ if (!allowed) {
+ cbm_log_warn("daemon.workspace.skipped", "operation", operation, "detail",
+ root && root[0] ? boundary_error : "session root is unavailable");
+ }
+ return allowed;
+}
+
/* Caller holds application->mutex. */
static void application_refresh_watch_locked(cbm_daemon_application_session_t *session) {
cbm_daemon_application_t *application = session->application;
@@ -442,6 +459,10 @@ static void application_refresh_watch_locked(cbm_daemon_application_session_t *s
if (!project || !project[0] || !root || !root[0]) {
return;
}
+ if (!application_session_workspace_allowed(session, "watch")) {
+ application_release_session_watch_locked(session);
+ return;
+ }
bool enabled = !application->config ||
cbm_config_get_bool(application->config, CBM_CONFIG_AUTO_WATCH, true);
bool db_exists = application_regular_db_exists(project);
@@ -1394,6 +1415,11 @@ static void application_auto_index_retry_pending_locked(cbm_daemon_application_t
session->auto_index_retry_pending = false;
continue;
}
+ if (!application_session_workspace_allowed(session, "auto_index_retry")) {
+ session->auto_index_retry_pending = false;
+ application_refresh_watch_locked(session);
+ continue;
+ }
if (application_regular_db_exists(project)) {
session->auto_index_retry_pending = false;
application_refresh_watch_locked(session);
@@ -1940,6 +1966,10 @@ static void application_background_initialize_impl(cbm_daemon_application_sessio
: CBM_MCP_DEFAULT_AUTO_INDEX_LIMIT;
int tracked_files = -1;
bool auto_index_candidate = auto_index && !db_exists;
+ if (auto_index_candidate &&
+ !application_session_workspace_allowed(session, "auto_index_discovery")) {
+ auto_index_candidate = false;
+ }
bool within_auto_index_limit =
!auto_index_candidate ||
cbm_mcp_auto_index_within_file_limit(root_path, auto_index_limit, &tracked_files);
diff --git a/tests/test_daemon_application.c b/tests/test_daemon_application.c
index c639ace2f..e0ef6b459 100644
--- a/tests/test_daemon_application.c
+++ b/tests/test_daemon_application.c
@@ -15,6 +15,7 @@
#include "foundation/platform.h"
#include "foundation/sha256.h"
#include "foundation/subprocess.h"
+#include "foundation/workspace.h"
#include "mcp/mcp.h"
#include "mcp/mcp_internal.h"
#include "pipeline/pipeline.h"
@@ -2089,6 +2090,266 @@ TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions) {
PASS();
}
+TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls) {
+ app_env_backup_t cache_environment;
+ app_env_backup_t home_environment;
+ bool environments_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR") &&
+ app_env_backup_capture(&home_environment, "HOME");
+ char sensitive_raw[APP_TEST_PATH_CAP];
+ char ordinary_raw[APP_TEST_PATH_CAP];
+ char cache_raw[APP_TEST_PATH_CAP];
+ (void)snprintf(sensitive_raw, sizeof(sensitive_raw), "%s/cbm-app-sensitive-auto-home-XXXXXX",
+ cbm_tmpdir());
+ (void)snprintf(ordinary_raw, sizeof(ordinary_raw), "%s/cbm-app-sensitive-auto-project-XXXXXX",
+ cbm_tmpdir());
+ (void)snprintf(cache_raw, sizeof(cache_raw), "%s/cbm-app-sensitive-auto-cache-XXXXXX",
+ cbm_tmpdir());
+ bool dirs_ok = cbm_mkdtemp(sensitive_raw) != NULL && cbm_mkdtemp(ordinary_raw) != NULL &&
+ cbm_mkdtemp(cache_raw) != NULL;
+ char sensitive[APP_TEST_PATH_CAP] = {0};
+ char ordinary[APP_TEST_PATH_CAP] = {0};
+ char cache[APP_TEST_PATH_CAP] = {0};
+ bool canonical = dirs_ok && cbm_canonical_path(sensitive_raw, sensitive, sizeof(sensitive)) &&
+ cbm_canonical_path(ordinary_raw, ordinary, sizeof(ordinary)) &&
+ cbm_canonical_path(cache_raw, cache, sizeof(cache));
+ char sensitive_source[APP_TEST_PATH_CAP];
+ char ordinary_source[APP_TEST_PATH_CAP];
+ (void)snprintf(sensitive_source, sizeof(sensitive_source), "%s/tracked.c", sensitive);
+ (void)snprintf(ordinary_source, sizeof(ordinary_source), "%s/tracked.c", ordinary);
+ bool files_ok = canonical && app_test_create_empty_file(sensitive_source) &&
+ app_test_create_empty_file(ordinary_source);
+ bool environments_set = environments_saved && files_ok &&
+ cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 &&
+ cbm_setenv("HOME", sensitive, 1) == 0;
+ cbm_config_t *stored_config = environments_set ? cbm_config_open(cache) : NULL;
+ bool config_ready = stored_config &&
+ cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "true") == 0 &&
+ cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "false") == 0;
+
+ app_fake_worker_context_t fake;
+ app_fake_worker_context_init(&fake);
+ cbm_daemon_application_worker_ops_t worker_ops = {
+ .context = &fake,
+ .start = app_fake_worker_start,
+ .poll = app_fake_worker_poll,
+ .cancel = app_fake_worker_cancel,
+ .log_path = app_fake_worker_log_path,
+ .destroy = app_fake_worker_destroy,
+ };
+ cbm_daemon_application_config_t config = {
+ .config = stored_config,
+ .worker_ops = &worker_ops,
+ };
+ cbm_daemon_application_t *application =
+ config_ready ? cbm_daemon_application_new(&config) : NULL;
+ cbm_daemon_runtime_application_callbacks_t callbacks =
+ cbm_daemon_application_runtime_callbacks(application);
+ cbm_daemon_runtime_application_session_t *sensitive_session =
+ application ? app_test_open(&callbacks, 4030) : NULL;
+ cbm_daemon_runtime_application_session_t *ordinary_session =
+ application ? app_test_open(&callbacks, 4031) : NULL;
+ cbm_daemon_runtime_application_session_t *approved_session =
+ application ? app_test_open(&callbacks, 4032) : NULL;
+
+ int background_baseline = cbm_daemon_application_background_initializes_for_test();
+ bool sensitive_initialized = app_test_initialize_profile(
+ &callbacks, sensitive_session, sensitive, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL);
+ bool sensitive_evaluated = app_wait_for_background_initializes(background_baseline + 1);
+ bool sensitive_blocked = sensitive_initialized && sensitive_evaluated &&
+ atomic_load(&fake.starts) == 0 &&
+ cbm_daemon_application_active_jobs(application) == 0;
+
+ bool ordinary_initialized = app_test_initialize_profile(&callbacks, ordinary_session, ordinary,
+ CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL);
+ bool ordinary_admitted = ordinary_initialized && app_wait_for_atomic_int(&fake.starts, 1) &&
+ cbm_daemon_application_active_jobs(application) == 1;
+ if (ordinary_session) {
+ callbacks.session_cancel(callbacks.context, ordinary_session);
+ callbacks.session_close(callbacks.context, ordinary_session);
+ ordinary_session = NULL;
+ }
+ bool ordinary_reaped = ordinary_admitted && app_wait_for_atomic_int(&fake.cancels, 1) &&
+ app_wait_for_atomic_int(&fake.destroys, 1) &&
+ app_wait_for_active_jobs(application, 0);
+
+ char grant_error[APP_TEST_PATH_CAP];
+ bool approved = ordinary_reaped && cbm_workspace_grant_add(cache, sensitive, sensitive, true,
+ grant_error, sizeof(grant_error));
+ bool approved_initialized =
+ approved && app_test_initialize_profile(&callbacks, approved_session, sensitive,
+ CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL);
+ bool approved_admitted = approved_initialized && app_wait_for_atomic_int(&fake.starts, 2) &&
+ cbm_daemon_application_active_jobs(application) == 1;
+
+ if (sensitive_session) {
+ callbacks.session_cancel(callbacks.context, sensitive_session);
+ callbacks.session_close(callbacks.context, sensitive_session);
+ }
+ if (approved_session) {
+ callbacks.session_cancel(callbacks.context, approved_session);
+ callbacks.session_close(callbacks.context, approved_session);
+ }
+ if (ordinary_session) {
+ callbacks.session_cancel(callbacks.context, ordinary_session);
+ callbacks.session_close(callbacks.context, ordinary_session);
+ }
+ bool approved_reaped = approved_admitted && app_wait_for_atomic_int(&fake.cancels, 2) &&
+ app_wait_for_atomic_int(&fake.destroys, 2) &&
+ app_wait_for_active_jobs(application, 0);
+ if (!approved_reaped) {
+ atomic_store(&fake.allow_completion, true);
+ }
+ bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS);
+ cbm_daemon_application_free(application);
+ cbm_config_close(stored_config);
+ (void)th_rmtree(sensitive);
+ (void)th_rmtree(ordinary);
+ (void)th_rmtree(cache);
+ bool cache_restored = app_env_backup_restore(&cache_environment);
+ bool home_restored = app_env_backup_restore(&home_environment);
+
+ ASSERT_TRUE(environments_saved);
+ ASSERT_TRUE(dirs_ok);
+ ASSERT_TRUE(canonical);
+ ASSERT_TRUE(files_ok);
+ ASSERT_TRUE(environments_set);
+ ASSERT_TRUE(config_ready);
+ ASSERT_TRUE(sensitive_blocked);
+ ASSERT_TRUE(ordinary_admitted);
+ ASSERT_TRUE(ordinary_reaped);
+ ASSERT_TRUE(approved);
+ ASSERT_TRUE(approved_admitted);
+ ASSERT_TRUE(approved_reaped);
+ ASSERT_TRUE(stopped);
+ ASSERT_TRUE(cache_restored);
+ ASSERT_TRUE(home_restored);
+ PASS();
+}
+
+TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls) {
+ app_env_backup_t cache_environment;
+ app_env_backup_t home_environment;
+ bool environments_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR") &&
+ app_env_backup_capture(&home_environment, "HOME");
+ char sensitive_raw[APP_TEST_PATH_CAP];
+ char ordinary_raw[APP_TEST_PATH_CAP];
+ char cache_raw[APP_TEST_PATH_CAP];
+ (void)snprintf(sensitive_raw, sizeof(sensitive_raw), "%s/cbm-app-sensitive-watch-home-XXXXXX",
+ cbm_tmpdir());
+ (void)snprintf(ordinary_raw, sizeof(ordinary_raw), "%s/cbm-app-sensitive-watch-project-XXXXXX",
+ cbm_tmpdir());
+ (void)snprintf(cache_raw, sizeof(cache_raw), "%s/cbm-app-sensitive-watch-cache-XXXXXX",
+ cbm_tmpdir());
+ bool dirs_ok = cbm_mkdtemp(sensitive_raw) != NULL && cbm_mkdtemp(ordinary_raw) != NULL &&
+ cbm_mkdtemp(cache_raw) != NULL;
+ char sensitive[APP_TEST_PATH_CAP] = {0};
+ char ordinary[APP_TEST_PATH_CAP] = {0};
+ char cache[APP_TEST_PATH_CAP] = {0};
+ bool canonical = dirs_ok && cbm_canonical_path(sensitive_raw, sensitive, sizeof(sensitive)) &&
+ cbm_canonical_path(ordinary_raw, ordinary, sizeof(ordinary)) &&
+ cbm_canonical_path(cache_raw, cache, sizeof(cache));
+ bool environments_set = environments_saved && canonical &&
+ cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0 &&
+ cbm_setenv("HOME", sensitive, 1) == 0;
+ cbm_config_t *stored_config = environments_set ? cbm_config_open(cache) : NULL;
+ bool config_ready = stored_config &&
+ cbm_config_set(stored_config, CBM_CONFIG_AUTO_INDEX, "false") == 0 &&
+ cbm_config_set(stored_config, CBM_CONFIG_AUTO_WATCH, "true") == 0;
+ char *sensitive_project = canonical ? cbm_project_name_from_path(sensitive) : NULL;
+ char *ordinary_project = canonical ? cbm_project_name_from_path(ordinary) : NULL;
+ char sensitive_db[APP_TEST_PATH_CAP] = {0};
+ char ordinary_db[APP_TEST_PATH_CAP] = {0};
+ if (sensitive_project) {
+ (void)snprintf(sensitive_db, sizeof(sensitive_db), "%s/%s.db", cache, sensitive_project);
+ }
+ if (ordinary_project) {
+ (void)snprintf(ordinary_db, sizeof(ordinary_db), "%s/%s.db", cache, ordinary_project);
+ }
+ bool db_ready = config_ready && sensitive_project && ordinary_project &&
+ app_test_create_empty_file(sensitive_db) &&
+ app_test_create_empty_file(ordinary_db);
+
+ cbm_store_t *store = cbm_store_open_memory();
+ cbm_watcher_t *watcher = cbm_watcher_new(store, app_test_index_noop, NULL);
+ cbm_daemon_application_config_t config = {
+ .watcher = watcher,
+ .config = stored_config,
+ };
+ cbm_daemon_application_t *application =
+ db_ready && watcher ? cbm_daemon_application_new(&config) : NULL;
+ cbm_daemon_runtime_application_callbacks_t callbacks =
+ cbm_daemon_application_runtime_callbacks(application);
+ cbm_daemon_runtime_application_session_t *sensitive_session =
+ application ? app_test_open(&callbacks, 4033) : NULL;
+ cbm_daemon_runtime_application_session_t *ordinary_session =
+ application ? app_test_open(&callbacks, 4034) : NULL;
+ cbm_daemon_runtime_application_session_t *approved_session =
+ application ? app_test_open(&callbacks, 4035) : NULL;
+
+ bool sensitive_initialized = app_test_initialize_profile(
+ &callbacks, sensitive_session, sensitive, CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL);
+ bool sensitive_blocked = sensitive_initialized && cbm_watcher_watch_count(watcher) == 0;
+ bool ordinary_initialized = app_test_initialize_profile(&callbacks, ordinary_session, ordinary,
+ CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL);
+ bool ordinary_watched = ordinary_initialized && cbm_watcher_watch_count(watcher) == 1;
+ if (ordinary_session) {
+ callbacks.session_close(callbacks.context, ordinary_session);
+ ordinary_session = NULL;
+ }
+ bool ordinary_released = ordinary_watched && cbm_watcher_watch_count(watcher) == 0;
+
+ char grant_error[APP_TEST_PATH_CAP];
+ bool approved = ordinary_released && cbm_workspace_grant_add(cache, sensitive, sensitive, true,
+ grant_error, sizeof(grant_error));
+ bool approved_initialized =
+ approved && app_test_initialize_profile(&callbacks, approved_session, sensitive,
+ CBM_MCP_TOOL_PROFILE_ALL, NULL, NULL);
+ bool approved_watched = approved_initialized && cbm_watcher_watch_count(watcher) == 1;
+
+ if (sensitive_session) {
+ callbacks.session_close(callbacks.context, sensitive_session);
+ }
+ if (approved_session) {
+ callbacks.session_close(callbacks.context, approved_session);
+ }
+ if (ordinary_session) {
+ callbacks.session_close(callbacks.context, ordinary_session);
+ }
+ bool watches_released = watcher && cbm_watcher_watch_count(watcher) == 0;
+ bool stopped = application && cbm_daemon_application_shutdown(application, APP_TEST_TIMEOUT_MS);
+ cbm_daemon_application_free(application);
+ if (watcher) {
+ cbm_watcher_stop(watcher);
+ cbm_watcher_free(watcher);
+ }
+ cbm_store_close(store);
+ cbm_config_close(stored_config);
+ free(sensitive_project);
+ free(ordinary_project);
+ (void)th_rmtree(sensitive);
+ (void)th_rmtree(ordinary);
+ (void)th_rmtree(cache);
+ bool cache_restored = app_env_backup_restore(&cache_environment);
+ bool home_restored = app_env_backup_restore(&home_environment);
+
+ ASSERT_TRUE(environments_saved);
+ ASSERT_TRUE(dirs_ok);
+ ASSERT_TRUE(canonical);
+ ASSERT_TRUE(environments_set);
+ ASSERT_TRUE(config_ready);
+ ASSERT_TRUE(db_ready);
+ ASSERT_TRUE(sensitive_blocked);
+ ASSERT_TRUE(ordinary_watched);
+ ASSERT_TRUE(ordinary_released);
+ ASSERT_TRUE(approved);
+ ASSERT_TRUE(approved_watched);
+ ASSERT_TRUE(watches_released);
+ ASSERT_TRUE(stopped);
+ ASSERT_TRUE(cache_restored);
+ ASSERT_TRUE(home_restored);
+ PASS();
+}
+
TEST(daemon_application_auto_index_honors_tracked_file_limit) {
app_env_backup_t cache_environment;
bool cache_saved = app_env_backup_capture(&cache_environment, "CBM_CACHE_DIR");
@@ -5097,6 +5358,8 @@ SUITE(daemon_application) {
RUN_TEST(daemon_application_free_releases_live_watch_once);
RUN_TEST(daemon_application_prune_clears_logical_watch_for_reregistration);
RUN_TEST(daemon_application_initialize_coalesces_auto_index_for_full_sessions);
+ RUN_TEST(daemon_application_sensitive_root_blocks_auto_index_but_preserves_controls);
+ RUN_TEST(daemon_application_sensitive_root_blocks_watch_but_preserves_controls);
RUN_TEST(daemon_application_auto_index_honors_tracked_file_limit);
RUN_TEST(daemon_application_auto_index_file_count_handles_literal_metacharacter_path);
RUN_TEST(daemon_application_auto_index_file_count_supports_non_git_roots);
From fde1695de2849862fff267fd5d6a3f144b26b07c Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Thu, 27 Aug 2026 12:39:30 +0200
Subject: [PATCH 05/31] fix: ignore trailing Dockerfile whitespace at EOF
Signed-off-by: Martin Vogel
---
internal/cbm/cbm.c | 36 +++++++++++++++++++------
tests/test_parse_coverage.c | 53 +++++++++++++++++++++++++++++++++++++
2 files changed, 81 insertions(+), 8 deletions(-)
diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c
index 6c46d7c91..85c0ba97b 100644
--- a/internal/cbm/cbm.c
+++ b/internal/cbm/cbm.c
@@ -810,17 +810,37 @@ static void cbm_error_regions_push(cbm_error_regions_t *acc, TSNode n) {
* Deliberately narrow — ZERO-WIDTH AT EOF ONLY. A MISSING or ERROR node with
* WIDTH still counts even at EOF (a Makefile whose last recipe line is
* unterminated really does lose the recipe), and anything before EOF is
- * untouched. */
-static bool cbm_is_eof_terminator_miss(TSNode n, int source_len) {
+ * untouched.
+ *
+ * #1746: the Dockerfile grammar places that zero-width missing newline before
+ * trailing horizontal whitespace rather than at raw EOF. Preserve the broad
+ * exact-EOF rule above; only extend it past spaces/tabs when the missing token
+ * is specifically a newline. */
+static bool cbm_is_eof_terminator_miss(TSNode n, const char *source, int source_len) {
if (!ts_node_is_missing(n) || source_len < 0) {
return false;
}
uint32_t start = ts_node_start_byte(n);
uint32_t end = ts_node_end_byte(n);
- return start == end && end == (uint32_t)source_len;
+ if (start != end || end > (uint32_t)source_len) {
+ return false;
+ }
+ if (end == (uint32_t)source_len) {
+ return true;
+ }
+ if (!source || strcmp(ts_node_type(n), "\n") != 0) {
+ return false;
+ }
+ for (uint32_t i = end; i < (uint32_t)source_len; i++) {
+ if (source[i] != ' ' && source[i] != '\t') {
+ return false;
+ }
+ }
+ return true;
}
-static void cbm_collect_error_regions(TSNode n, cbm_error_regions_t *acc, int source_len) {
+static void cbm_collect_error_regions(TSNode n, cbm_error_regions_t *acc, const char *source,
+ int source_len) {
if (acc->count >= CBM_MAX_ERROR_REGIONS) {
return;
}
@@ -828,12 +848,12 @@ static void cbm_collect_error_regions(TSNode n, cbm_error_regions_t *acc, int so
for (uint32_t i = 0; i < k && acc->count < CBM_MAX_ERROR_REGIONS; i++) {
TSNode c = ts_node_child(n, i);
if (ts_node_is_missing(c) || strcmp(ts_node_type(c), "ERROR") == 0) {
- if (cbm_is_eof_terminator_miss(c, source_len)) {
+ if (cbm_is_eof_terminator_miss(c, source, source_len)) {
continue; /* absent final newline only — nothing was dropped */
}
cbm_error_regions_push(acc, c); /* top-most region; do not descend */
} else if (ts_node_has_error(c)) {
- cbm_collect_error_regions(c, acc, source_len);
+ cbm_collect_error_regions(c, acc, source, source_len);
}
}
}
@@ -1456,7 +1476,7 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
* already extract. */
if (ts_node_has_error(root)) {
cbm_error_regions_t raw_regs = {{0}, {0}, 0};
- cbm_collect_error_regions(root, &raw_regs, source_len);
+ cbm_collect_error_regions(root, &raw_regs, source, source_len);
if (raw_regs.count > 0) {
int defs_before = result->defs.count;
cbm_extract_definitions(&pp_ctx);
@@ -1614,7 +1634,7 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
if (strcmp(ts_node_type(root), "ERROR") == 0) {
cbm_error_regions_push(®s, root); /* whole file unparseable */
} else {
- cbm_collect_error_regions(root, ®s, source_len);
+ cbm_collect_error_regions(root, ®s, source, source_len);
}
cbm_subtract_recovered_regions(®s, &result->defs);
/* #1071: don't flag a benign function-like-macro call (defined in-file)
diff --git a/tests/test_parse_coverage.c b/tests/test_parse_coverage.c
index 1c0720c94..423406612 100644
--- a/tests/test_parse_coverage.c
+++ b/tests/test_parse_coverage.c
@@ -295,6 +295,55 @@ TEST(dockerfile_with_final_newline_still_clean_issue1610) {
PASS();
}
+/* #1746: on Windows, a Dockerfile whose final instruction is followed by one
+ * ASCII space and then EOF was reported as parse_partial. */
+TEST(dockerfile_trailing_space_at_eof_not_flagged_issue1746) {
+ const char *src = "FROM scratch\nENTRYPOINT [\"a\"] ";
+ CBMFileResult *r = do_extract(src, CBM_LANG_DOCKERFILE, "Dockerfile");
+ ASSERT_NOT_NULL(r);
+ bool flagged = r->parse_incomplete;
+ if (flagged) {
+ fprintf(stderr, " exact issue #1746 fixture flagged: ranges=%s\n",
+ r->error_ranges ? r->error_ranges : "(none)");
+ }
+ cbm_free_result(r);
+ if (flagged) {
+ FAIL("trailing horizontal whitespace at Dockerfile EOF must not be parse_partial");
+ }
+ PASS();
+}
+
+/* The same bytes WITH the LF must stay clean. This is a separate test so the
+ * control executes even while the exact affected fixture is RED. */
+TEST(dockerfile_trailing_space_with_final_newline_clean_issue1746) {
+ const char *src = "FROM scratch\nENTRYPOINT [\"a\"] \n";
+ CBMFileResult *r = do_extract(src, CBM_LANG_DOCKERFILE, "Dockerfile");
+ ASSERT_NOT_NULL(r);
+ ASSERT_FALSE(r->parse_incomplete);
+ cbm_free_result(r);
+ PASS();
+}
+
+/* Removing the trailing space while retaining EOF must also stay clean. */
+TEST(dockerfile_without_trailing_space_at_eof_clean_issue1746) {
+ const char *src = "FROM scratch\nENTRYPOINT [\"a\"]";
+ CBMFileResult *r = do_extract(src, CBM_LANG_DOCKERFILE, "Dockerfile");
+ ASSERT_NOT_NULL(r);
+ ASSERT_FALSE(r->parse_incomplete);
+ cbm_free_result(r);
+ PASS();
+}
+
+/* The reporter also observed the same failure when the first line uses CRLF. */
+TEST(dockerfile_crlf_trailing_space_at_eof_not_flagged_issue1746) {
+ const char *src = "FROM scratch\r\nENTRYPOINT [\"a\"] ";
+ CBMFileResult *r = do_extract(src, CBM_LANG_DOCKERFILE, "Dockerfile");
+ ASSERT_NOT_NULL(r);
+ ASSERT_FALSE(r->parse_incomplete);
+ cbm_free_result(r);
+ PASS();
+}
+
/* Language-general, not a Dockerfile patch: these four were each proven to flag
* on a stripped trailing newline. */
TEST(missing_final_newline_not_flagged_across_grammars_issue1610) {
@@ -381,6 +430,10 @@ SUITE(parse_coverage) {
RUN_TEST(c_trailing_recovered_defs_keep_flag);
RUN_TEST(dockerfile_missing_final_newline_not_flagged_issue1610);
RUN_TEST(dockerfile_with_final_newline_still_clean_issue1610);
+ RUN_TEST(dockerfile_trailing_space_at_eof_not_flagged_issue1746);
+ RUN_TEST(dockerfile_trailing_space_with_final_newline_clean_issue1746);
+ RUN_TEST(dockerfile_without_trailing_space_at_eof_clean_issue1746);
+ RUN_TEST(dockerfile_crlf_trailing_space_at_eof_not_flagged_issue1746);
RUN_TEST(missing_final_newline_not_flagged_across_grammars_issue1610);
RUN_TEST(real_error_before_eof_still_flagged_without_final_newline_issue1610);
RUN_TEST(width_bearing_error_at_eof_still_flagged_issue1610);
From ee32816e3ce59855262df3d738b96e13931da131 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Thu, 27 Aug 2026 14:03:22 +0200
Subject: [PATCH 06/31] fix(pi): send tool arguments over stdin
Signed-off-by: Martin Vogel
---
scripts/smoke-test.sh | 100 +++++++++++++++++++++++++++++++++----
src/cli/client_adapter.c | 10 +++-
src/cli/client_adapter.h | 5 +-
tests/test_agent_clients.c | 39 +++++++++++++++
4 files changed, 141 insertions(+), 13 deletions(-)
diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
index 53dc130ae..7eb24864c 100755
--- a/scripts/smoke-test.sh
+++ b/scripts/smoke-test.sh
@@ -838,15 +838,16 @@ else
echo; echo "-- B3 first bytes (od) --"; echo "$IM_ARR" | od -c | head -6; exit 1
fi
-# B4: STDIN — piped JSON resolves; this path must NOT emit a deprecation warning.
-IM_STDIN=$(echo "{\"project\":\"$PROJECT\"}" | "$BINARY" cli get_graph_schema 2>"$CLI_STDERR")
-if ! echo "$IM_STDIN" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if 'node_labels' in d else 1)" 2>/dev/null; then
- echo "FAIL B4: stdin get_graph_schema did not resolve"; echo "$IM_STDIN" | head -c 300; cat "$CLI_STDERR"; exit 1
+# B4: STDIN + --json is the generated-client transport. It must return the
+# complete MCP result envelope and must NOT emit a deprecation warning.
+IM_STDIN=$(printf '%s' "{\"project\":\"$PROJECT\"}" | "$BINARY" cli --json get_graph_schema 2>"$CLI_STDERR")
+if ! printf '%s' "$IM_STDIN" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); c=d.get('content'); p=json.loads(c[0].get('text','')) if isinstance(c,list) and c and c[0].get('type') == 'text' else None; sys.exit(0 if d.get('isError') is not True and isinstance(p,dict) and isinstance(p.get('node_labels'),list) else 1)" 2>/dev/null; then
+ echo "FAIL B4: compact stdin + --json did not return a successful get_graph_schema MCP payload"; echo "$IM_STDIN" | head -c 300; cat "$CLI_STDERR"; exit 1
fi
if grep -qi 'deprecated' "$CLI_STDERR"; then
echo "FAIL B4: stdin path wrongly emitted a deprecation warning"; cat "$CLI_STDERR"; exit 1
fi
-echo "OK B4: STDIN input resolves, no deprecation warning"
+echo "OK B4: compact STDIN + --json returns a successful schema MCP envelope, no deprecation warning"
# B5: --args-file — JSON read from a file resolves; must NOT warn deprecated.
IM_ARGS_FILE=$(smoke_mktemp_file)
@@ -890,7 +891,8 @@ else
echo "FAIL B6c: 'notatool --help' did not report 'unknown tool'"; cat "$CLI_STDERR"; exit 1
fi
-# B7: DEPRECATION guard — one raw-JSON call MUST warn on stderr; flag form must NOT.
+# B7: DEPRECATION control — positional raw JSON MUST warn on stderr; this is
+# retained only to prove B4 is exercising the non-deprecated stdin transport.
cli search_graph "{\"project\":\"$PROJECT\",\"name_pattern\":\"compute\"}" >/dev/null || true
if grep -qi 'deprecated' "$CLI_STDERR"; then
echo "OK B7a: raw-JSON cli emits deprecation warning on stderr"
@@ -2302,16 +2304,96 @@ if ! path_match "$CMD" "$SELF_PATH" ||
fi
echo "OK 8ak: custom KIMI_CODE_HOME MCP + durable context + UserPromptSubmit hook"
-# 8al: Pi has documented instructions and skill, but no invented MCP config.
+# 8al: Pi has documented instructions and skill, no invented MCP config, and
+# an installed generated extension that uses the non-deprecated stdin bridge.
PI_INSTRUCTIONS="$FAKE_HOME/.pi/agent/AGENTS.md"
PI_SKILL="$FAKE_HOME/.pi/agent/skills/codebase-memory/SKILL.md"
+PI_EXTENSION="$FAKE_HOME/.pi/agent/extensions/cbmem.ts"
if ! grep -q 'search_graph' "$PI_INSTRUCTIONS" 2>/dev/null ||
! grep -q 'Sessions and Subagents' "$PI_SKILL" 2>/dev/null ||
+ ! grep -Fq "spawn(BIN, ['cli', '--json', tool], {" "$PI_EXTENSION" 2>/dev/null ||
+ ! grep -Fq "stdio: ['pipe', 'pipe', 'pipe']" "$PI_EXTENSION" 2>/dev/null ||
+ ! grep -Fq "child.stdin.on('error'" "$PI_EXTENSION" 2>/dev/null ||
+ ! grep -Fq 'child.stdin.end(JSON.stringify(args ?? {}));' "$PI_EXTENSION" 2>/dev/null ||
+ grep -Fq 'tool, JSON.stringify(args ?? {})]' "$PI_EXTENSION" 2>/dev/null ||
+ grep -Fq "stdio: ['ignore', 'pipe', 'pipe']" "$PI_EXTENSION" 2>/dev/null ||
[ -e "$FAKE_HOME/.pi/agent/mcp.json" ]; then
- echo "FAIL 8al: Pi durable context missing or unsupported MCP config created"
+ echo "FAIL 8al: Pi durable context or stdin client bridge missing, or unsupported MCP config created"
exit 1
fi
-echo "OK 8al: Pi durable context only (no MCP config)"
+echo "OK 8al: Pi durable context + stdin client bridge (no MCP config)"
+
+# 8al-node: execute the generated extension against a child that exits without
+# consuming a deliberately over-pipe-capacity payload. This is the lifecycle
+# Node implements: without an stdin error listener, the late EPIPE is an
+# unhandled EventEmitter error and crashes the Pi host. A second call emits a
+# valid MCP envelope before the same early exit, proving parsed JSON remains
+# authoritative over a retained stdin transport error.
+#
+# SKIP_WHITELIST: the minimal C-only Linux image intentionally has no Node.
+# What was tried: making Node a universal core-suite prerequisite would widen
+# the product's C build dependencies. The exact generated-source assertions
+# above still run there; this live probe gates every Node-equipped smoke venue.
+if command -v node >/dev/null 2>&1; then
+ PI_NODE=$(command -v node)
+ PI_PROBE_DIR="$TMPDIR/pi-node-probe"
+ mkdir -p "$PI_PROBE_DIR"
+ python3 - "$PI_EXTENSION" "$PI_PROBE_DIR/cbmem.mjs" <<'PYPIADAPTER'
+import pathlib
+import sys
+
+source, destination = map(pathlib.Path, sys.argv[1:])
+text = source.read_text(encoding="utf-8")
+lines = text.splitlines()
+matches = [i for i, line in enumerate(lines) if line.startswith("const BIN = ")]
+if len(matches) != 1:
+ raise SystemExit("generated Pi extension has no unique BIN declaration")
+lines[matches[0]] = "const BIN = process.execPath;"
+destination.write_text("\n".join(lines) + "\n", encoding="utf-8")
+PYPIADAPTER
+ cat >"$PI_PROBE_DIR/cli" <<'PICHILD'
+const fs = require('node:fs');
+if (process.argv[3] === 'get_graph_schema') {
+ fs.writeSync(1, JSON.stringify({ content: [{ type: 'text', text: 'schema-ok' }] }) + '\n');
+}
+process.exit(0);
+PICHILD
+ if ! grep -Fxq 'const BIN = process.execPath;' "$PI_PROBE_DIR/cbmem.mjs" ||
+ ! grep -Fxq "const fs = require('node:fs');" "$PI_PROBE_DIR/cli"; then
+ echo "FAIL 8al-node: generated lifecycle probe is not portable across Node launch environments"
+ exit 1
+ fi
+ cat >"$PI_PROBE_DIR/probe.mjs" <<'PIPROBE'
+import extension from './cbmem.mjs';
+
+const tools = [];
+extension({ registerTool: (definition) => tools.push(definition) });
+const byName = (name) => tools.find((tool) => tool.name === name);
+const args = { padding: 'x'.repeat(16 * 1024 * 1024) };
+
+const successful = await byName('get_graph_schema').execute('probe-ok', args);
+if (successful?.content?.[0]?.text !== 'schema-ok') {
+ throw new Error('valid child JSON was not authoritative over stdin EPIPE');
+}
+
+let transportError = '';
+try {
+ await byName('search_graph').execute('probe-error', args);
+} catch (error) {
+ transportError = String(error?.message ?? error);
+}
+if (!/(EPIPE|broken pipe|write)/i.test(transportError)) {
+ throw new Error(`missing surfaced stdin transport error: ${transportError || ''}`);
+}
+PIPROBE
+ if ! (cd "$PI_PROBE_DIR" && "$PI_NODE" probe.mjs); then
+ echo "FAIL 8al-node: generated Pi extension did not contain early-exit stdin errors"
+ exit 1
+ fi
+ echo "OK 8al-node: generated Pi extension contains EPIPE and keeps valid JSON authoritative"
+else
+ echo "SKIP 8al-node: Node unavailable in this C-only smoke venue (whitelisted above)"
+fi
# 8am: Warp receives the documented shared skill; MCP remains user/UI-managed.
WARP_SKILL="$FAKE_HOME/.agents/skills/codebase-memory/SKILL.md"
diff --git a/src/cli/client_adapter.c b/src/cli/client_adapter.c
index cc7454793..4d136bb5e 100644
--- a/src/cli/client_adapter.c
+++ b/src/cli/client_adapter.c
@@ -168,14 +168,16 @@ char *cbm_client_adapter_pi(const char *binary_path) {
sb_append(
&sb, "async function call(tool, args, signal) {\n"
" return new Promise((resolve) => {\n"
- " const child = spawn(BIN, ['cli', '--json', tool, JSON.stringify(args ?? {})], {\n"
- " stdio: ['ignore', 'pipe', 'pipe'],\n"
+ " const child = spawn(BIN, ['cli', '--json', tool], {\n"
+ " stdio: ['pipe', 'pipe', 'pipe'],\n"
" env: { ...process.env, CBM_LOG_LEVEL: 'error' },\n"
" });\n"
" let out = '';\n"
+ " let stdinError;\n"
" const onAbort = () => { if (!child.killed) child.kill(); };\n"
" signal?.addEventListener('abort', onAbort, { once: true });\n"
" child.stdout.on('data', (d) => (out += d.toString()));\n"
+ " child.stdin.on('error', (e) => { stdinError = e; });\n"
" child.on('error', (e) => {\n"
" signal?.removeEventListener('abort', onAbort);\n"
" resolve({ error: String(e && e.message ? e.message : e) });\n"
@@ -186,8 +188,12 @@ char *cbm_client_adapter_pi(const char *binary_path) {
" for (let i = lines.length - 1; i >= 0; i--) {\n"
" try { return resolve(JSON.parse(lines[i])); } catch { /* keep scanning */ }\n"
" }\n"
+ " if (stdinError) {\n"
+ " return resolve({ error: String(stdinError.message || stdinError) });\n"
+ " }\n"
" resolve({ error: 'no JSON response from codebase-memory-mcp' });\n"
" });\n"
+ " child.stdin.end(JSON.stringify(args ?? {}));\n"
" });\n"
"}\n\n");
diff --git a/src/cli/client_adapter.h b/src/cli/client_adapter.h
index f0c812e62..c1bfb652e 100644
--- a/src/cli/client_adapter.h
+++ b/src/cli/client_adapter.h
@@ -30,8 +30,9 @@
/* Emit a pi extension registering every tool in the MCP registry.
*
* pi has no MCP client, so this bridge is the only route to the graph for it.
- * Each registered tool shells out to ` cli ''`, which
- * is an existing public entry point rather than a private one.
+ * Each registered tool shells out to ` cli --json ` and writes
+ * its JSON arguments to stdin, using an existing public entry point without
+ * the deprecated positional-JSON transport.
*
* `binary_path` is embedded as a JS string literal and is escaped; a Windows
* path such as C:\Users\x\bin\cbm.exe must not corrupt the module or, worse,
diff --git a/tests/test_agent_clients.c b/tests/test_agent_clients.c
index ceb717e18..519cbed93 100644
--- a/tests/test_agent_clients.c
+++ b/tests/test_agent_clients.c
@@ -1139,6 +1139,44 @@ TEST(client_adapter_pi_emits_parameters_and_execute) {
PASS();
}
+/* #1834: raw JSON in argv selects the deprecated compatibility path. The Pi
+ * bridge must keep arguments out of process listings and feed the existing
+ * stdin transport only after all child handlers are attached. */
+TEST(client_adapter_pi_sends_arguments_over_stdin_issue1834) {
+ char *js = cbm_client_adapter_pi("/usr/local/bin/codebase-memory-mcp");
+ ASSERT_NOT_NULL(js);
+
+ const char *spawn = strstr(js, "spawn(BIN, ['cli', '--json', tool], {");
+ const char *stdio = strstr(js, "stdio: ['pipe', 'pipe', 'pipe']");
+ const char *stdout_handler = strstr(js, "child.stdout.on('data'");
+ const char *stdin_error_handler = strstr(js, "child.stdin.on('error'");
+ const char *error_handler = strstr(js, "child.on('error'");
+ const char *close_handler = strstr(js, "child.on('close'");
+ const char *stdin_end = strstr(js, "child.stdin.end(JSON.stringify(args ?? {}));");
+
+ ASSERT_NOT_NULL(spawn);
+ ASSERT_NOT_NULL(stdio);
+ ASSERT_NOT_NULL(stdout_handler);
+ ASSERT_NOT_NULL(stdin_error_handler);
+ ASSERT_NOT_NULL(error_handler);
+ ASSERT_NOT_NULL(close_handler);
+ ASSERT_NOT_NULL(stdin_end);
+ ASSERT(stdout_handler < stdin_end);
+ ASSERT(stdin_error_handler < stdin_end);
+ ASSERT(error_handler < stdin_end);
+ ASSERT(close_handler < stdin_end);
+ ASSERT_NOT_NULL(strstr(js, "let stdinError;"));
+ ASSERT_NOT_NULL(strstr(js, "stdinError = e;"));
+ ASSERT_NOT_NULL(strstr(js, "if (stdinError)"));
+
+ /* These are the exact deprecated transport shapes being retired. */
+ ASSERT_NULL(strstr(js, "tool, JSON.stringify(args ?? {})]"));
+ ASSERT_NULL(strstr(js, "stdio: ['ignore', 'pipe', 'pipe']"));
+
+ free(js);
+ PASS();
+}
+
/* Result wrap + abort: Pi's TUI reads result.content; a spawn/parse failure
* must throw rather than return a result without content; AbortSignal must
* kill the cli child. String-shape only; a live Pi process is not gated. */
@@ -1248,6 +1286,7 @@ SUITE(agent_clients) {
RUN_TEST(client_adapter_pi_registers_every_registry_tool);
RUN_TEST(client_adapter_pi_default_exports_its_factory_issue1550);
RUN_TEST(client_adapter_pi_emits_parameters_and_execute);
+ RUN_TEST(client_adapter_pi_sends_arguments_over_stdin_issue1834);
RUN_TEST(client_adapter_pi_wraps_result_and_honors_abort);
RUN_TEST(client_adapter_escapes_windows_paths_and_quotes);
RUN_TEST(client_adapter_opencode_sends_the_required_hook_event);
From 7710f86e927c5221c395ecbdab9d0f3b75edcb71 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Thu, 27 Aug 2026 02:49:11 +0200
Subject: [PATCH 07/31] fix(daemon): avoid duplicate idle maintenance polling
Signed-off-by: Martin Vogel
---
src/daemon/frontend.c | 109 ++++++++++++-----
src/daemon/frontend.h | 12 ++
tests/test_daemon_frontend.c | 222 +++++++++++++++++++++++++++++++++++
3 files changed, 316 insertions(+), 27 deletions(-)
diff --git a/src/daemon/frontend.c b/src/daemon/frontend.c
index f5116934c..17e0a8d2f 100644
--- a/src/daemon/frontend.c
+++ b/src/daemon/frontend.c
@@ -93,23 +93,83 @@ struct cbm_daemon_maintenance_monitor {
cbm_daemon_maintenance_cancel_fn cancel;
void *cancel_context;
atomic_bool stopping;
+ bool exit_when_cancel_not_needed;
int exit_code;
char participant[FRONTEND_PARTICIPANT_NAME_CAP];
};
+#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
+static atomic_bool frontend_test_hold_monitor = ATOMIC_VAR_INIT(false);
+static atomic_bool frontend_test_monitor_is_waiting = ATOMIC_VAR_INIT(false);
+static atomic_uint_fast64_t frontend_test_monitor_observation_count = ATOMIC_VAR_INIT(0);
+static atomic_uint_fast64_t frontend_test_worker_observation_count = ATOMIC_VAR_INIT(0);
+static atomic_uint_fast64_t frontend_test_worker_idle_count = ATOMIC_VAR_INIT(0);
+
+void cbm_daemon_frontend_test_observer_reset(bool hold_monitor) {
+ atomic_store_explicit(&frontend_test_monitor_observation_count, 0, memory_order_release);
+ atomic_store_explicit(&frontend_test_worker_observation_count, 0, memory_order_release);
+ atomic_store_explicit(&frontend_test_worker_idle_count, 0, memory_order_release);
+ atomic_store_explicit(&frontend_test_monitor_is_waiting, false, memory_order_release);
+ atomic_store_explicit(&frontend_test_hold_monitor, hold_monitor, memory_order_release);
+}
+
+void cbm_daemon_frontend_test_observer_release(void) {
+ atomic_store_explicit(&frontend_test_hold_monitor, false, memory_order_release);
+}
+
+bool cbm_daemon_frontend_test_monitor_waiting(void) {
+ return atomic_load_explicit(&frontend_test_monitor_is_waiting, memory_order_acquire);
+}
+
+uint64_t cbm_daemon_frontend_test_monitor_observations(void) {
+ return atomic_load_explicit(&frontend_test_monitor_observation_count, memory_order_acquire);
+}
+
+uint64_t cbm_daemon_frontend_test_worker_observations(void) {
+ return atomic_load_explicit(&frontend_test_worker_observation_count, memory_order_acquire);
+}
+
+uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void) {
+ return atomic_load_explicit(&frontend_test_worker_idle_count, memory_order_acquire);
+}
+#endif
+
+static cbm_version_cohort_maintenance_presence_t frontend_observe_maintenance(
+ cbm_version_cohort_manager_t *manager, bool independent_monitor) {
+#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
+ if (independent_monitor) {
+ atomic_fetch_add_explicit(&frontend_test_monitor_observation_count, 1,
+ memory_order_release);
+ if (atomic_load_explicit(&frontend_test_hold_monitor, memory_order_acquire)) {
+ atomic_store_explicit(&frontend_test_monitor_is_waiting, true, memory_order_release);
+ while (atomic_load_explicit(&frontend_test_hold_monitor, memory_order_acquire)) {
+ cbm_usleep(FRONTEND_WAIT_US);
+ }
+ atomic_store_explicit(&frontend_test_monitor_is_waiting, false, memory_order_release);
+ }
+ } else {
+ atomic_fetch_add_explicit(&frontend_test_worker_observation_count, 1, memory_order_release);
+ }
+#else
+ (void)independent_monitor;
+#endif
+ return cbm_version_cohort_maintenance_presence_terminal(manager);
+}
+
static void *frontend_maintenance_monitor_worker(void *opaque) {
cbm_daemon_maintenance_monitor_t *monitor = opaque;
while (!atomic_load_explicit(&monitor->stopping, memory_order_acquire)) {
cbm_version_cohort_maintenance_presence_t presence =
- cbm_version_cohort_maintenance_presence_terminal(monitor->manager);
+ frontend_observe_maintenance(monitor->manager, true);
if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) {
cbm_usleep(FRONTEND_MAINTENANCE_POLL_MS * 1000U);
continue;
}
if (presence == CBM_VERSION_COHORT_MAINTENANCE_REQUESTED) {
+ bool cancel_requested = false;
if (monitor->cancel) {
- (void)monitor->cancel(monitor->cancel_context);
+ cancel_requested = monitor->cancel(monitor->cancel_context);
}
/* Never log, write to, or flush agent stdio from this monitor.
* Structured logging itself writes to stderr, and this thread's
@@ -117,6 +177,9 @@ static void *frontend_maintenance_monitor_worker(void *opaque) {
* thread is blocked on a full stdout/stderr pipe. The activation
* owner records the maintenance event durably. */
+ if (monitor->exit_when_cancel_not_needed && !cancel_requested) {
+ _Exit(monitor->exit_code);
+ }
uint64_t now = cbm_now_ms();
uint64_t deadline = now > UINT64_MAX - FRONTEND_MAINTENANCE_GRACE_MS
? UINT64_MAX
@@ -139,9 +202,10 @@ static void *frontend_maintenance_monitor_worker(void *opaque) {
return NULL;
}
-cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start(
+static cbm_daemon_maintenance_monitor_t *frontend_maintenance_monitor_start(
cbm_version_cohort_manager_t *manager, cbm_daemon_maintenance_cancel_fn cancel,
- void *cancel_context, int exit_code, const char *participant) {
+ void *cancel_context, int exit_code, const char *participant,
+ bool exit_when_cancel_not_needed) {
if (!manager || exit_code < 0 || !participant || !participant[0]) {
return NULL;
}
@@ -152,6 +216,7 @@ cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start(
monitor->manager = manager;
monitor->cancel = cancel;
monitor->cancel_context = cancel_context;
+ monitor->exit_when_cancel_not_needed = exit_when_cancel_not_needed;
monitor->exit_code = exit_code;
atomic_init(&monitor->stopping, false);
int written = snprintf(monitor->participant, sizeof(monitor->participant), "%s", participant);
@@ -163,6 +228,13 @@ cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start(
return monitor;
}
+cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start(
+ cbm_version_cohort_manager_t *manager, cbm_daemon_maintenance_cancel_fn cancel,
+ void *cancel_context, int exit_code, const char *participant) {
+ return frontend_maintenance_monitor_start(manager, cancel, cancel_context, exit_code,
+ participant, false);
+}
+
bool cbm_daemon_maintenance_monitor_stop(cbm_daemon_maintenance_monitor_t **monitor_io) {
if (!monitor_io || !*monitor_io) {
return false;
@@ -177,19 +249,6 @@ bool cbm_daemon_maintenance_monitor_stop(cbm_daemon_maintenance_monitor_t **moni
return true;
}
-static void frontend_exit_for_maintenance(frontend_state_t *state) {
- cbm_version_cohort_maintenance_presence_t presence =
- cbm_version_cohort_maintenance_presence_terminal(state->cohort_manager);
- if (presence == CBM_VERSION_COHORT_MAINTENANCE_ABSENT) {
- return;
- }
- /* Do not fclose stdin across threads. Process exit closes the authenticated
- * kernel IPC handle, and daemon ownership then cancels only this session.
- * Agent stdout/stderr may both be backpressured, so terminal paths must not
- * log, write, or flush before fail-stop. */
- _Exit(presence == CBM_VERSION_COHORT_MAINTENANCE_REQUESTED ? EXIT_SUCCESS : EXIT_FAILURE);
-}
-
static void frontend_item_free(frontend_item_t *item) {
if (item) {
free(item->message);
@@ -414,20 +473,15 @@ static frontend_cancellation_route_t frontend_route_cancellation(
static void *frontend_worker(void *opaque) {
frontend_state_t *state = opaque;
- uint64_t next_maintenance_check_ms = 0;
for (;;) {
- uint64_t now_ms = cbm_now_ms();
- if (now_ms >= next_maintenance_check_ms) {
- frontend_exit_for_maintenance(state);
- next_maintenance_check_ms = now_ms > UINT64_MAX - FRONTEND_MAINTENANCE_POLL_MS
- ? UINT64_MAX
- : now_ms + FRONTEND_MAINTENANCE_POLL_MS;
- }
frontend_item_t item = {0};
if (!frontend_pop_begin(state, &item)) {
if (frontend_should_stop(state)) {
break;
}
+#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
+ atomic_fetch_add_explicit(&frontend_test_worker_idle_count, 1, memory_order_release);
+#endif
cbm_usleep(FRONTEND_IDLE_WAIT_US);
continue;
}
@@ -583,8 +637,9 @@ int cbm_daemon_frontend_mcp_run(cbm_daemon_runtime_client_t *client,
};
cbm_mutex_init(&state.mutex);
atomic_init(&state.completed_items, 0);
- cbm_daemon_maintenance_monitor_t *maintenance_monitor = cbm_daemon_maintenance_monitor_start(
- cohort_manager, frontend_cancel_for_maintenance, &state, EXIT_SUCCESS, "MCP frontend");
+ cbm_daemon_maintenance_monitor_t *maintenance_monitor =
+ frontend_maintenance_monitor_start(cohort_manager, frontend_cancel_for_maintenance, &state,
+ EXIT_SUCCESS, "MCP frontend", true);
if (!maintenance_monitor) {
cbm_mutex_destroy(&state.mutex);
(void)cbm_daemon_runtime_client_close(client, FRONTEND_CLOSE_TIMEOUT_MS);
diff --git a/src/daemon/frontend.h b/src/daemon/frontend.h
index d114c8f9c..f23395774 100644
--- a/src/daemon/frontend.h
+++ b/src/daemon/frontend.h
@@ -42,6 +42,18 @@ cbm_daemon_maintenance_monitor_t *cbm_daemon_maintenance_monitor_start(
void *cancel_context, int exit_code, const char *participant);
bool cbm_daemon_maintenance_monitor_stop(cbm_daemon_maintenance_monitor_t **monitor_io);
+#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
+/* Deterministic observer accounting for the idle-frontend regression test.
+ * The hold is inert until reset(true), and production builds contain neither
+ * these controls nor their counters. */
+void cbm_daemon_frontend_test_observer_reset(bool hold_monitor);
+void cbm_daemon_frontend_test_observer_release(void);
+bool cbm_daemon_frontend_test_monitor_waiting(void);
+uint64_t cbm_daemon_frontend_test_monitor_observations(void);
+uint64_t cbm_daemon_frontend_test_worker_observations(void);
+uint64_t cbm_daemon_frontend_test_worker_idle_cycles(void);
+#endif
+
/* Takes ownership of client and borrows cohort_manager for the complete call.
* A dedicated reader keeps observing stdin while one joinable worker performs
* daemon requests. An independent maintenance monitor remains runnable when
diff --git a/tests/test_daemon_frontend.c b/tests/test_daemon_frontend.c
index d6b066b3b..ed0e868e9 100644
--- a/tests/test_daemon_frontend.c
+++ b/tests/test_daemon_frontend.c
@@ -34,6 +34,23 @@ typedef struct {
cbm_version_cohort_manager_t *manager;
} frontend_maintenance_fixture_t;
+#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
+typedef struct {
+ frontend_maintenance_fixture_t maintenance;
+ char conflict_log[FRONTEND_TEST_PATH_CAP];
+ cbm_daemon_runtime_service_t *service;
+ cbm_daemon_runtime_client_t *client;
+} frontend_idle_fixture_t;
+
+typedef struct {
+ cbm_daemon_runtime_client_t *client;
+ cbm_version_cohort_manager_t *manager;
+ FILE *input;
+ FILE *output;
+ int result;
+} frontend_idle_run_t;
+#endif
+
static bool frontend_maintenance_fixture_start(frontend_maintenance_fixture_t *fixture,
const char *tag) {
memset(fixture, 0, sizeof(*fixture));
@@ -59,6 +76,148 @@ static void frontend_maintenance_fixture_finish(frontend_maintenance_fixture_t *
memset(fixture, 0, sizeof(*fixture));
}
+#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
+static cbm_daemon_runtime_application_session_t *frontend_idle_session_open(
+ void *context, cbm_daemon_client_id_t client_id, uint64_t authenticated_process_id) {
+ return context && client_id != CBM_DAEMON_CLIENT_ID_INVALID && authenticated_process_id != 0
+ ? (cbm_daemon_runtime_application_session_t *)context
+ : NULL;
+}
+
+static cbm_daemon_runtime_application_status_t frontend_idle_request(
+ void *context, cbm_daemon_runtime_application_session_t *session,
+ cbm_daemon_runtime_application_token_t request_token, const uint8_t *request,
+ uint32_t request_length, uint8_t **response_out, uint32_t *response_length_out) {
+ (void)request_token;
+ if (!context || session != (cbm_daemon_runtime_application_session_t *)context ||
+ !response_out || !response_length_out || (request_length > 0 && !request)) {
+ return CBM_DAEMON_RUNTIME_APPLICATION_HANDLER_ERROR;
+ }
+ *response_out = NULL;
+ *response_length_out = 0;
+ return CBM_DAEMON_RUNTIME_APPLICATION_OK;
+}
+
+static void frontend_idle_request_cancel(
+ void *context, cbm_daemon_runtime_application_session_t *session,
+ cbm_daemon_runtime_application_token_t request_token) {
+ (void)context;
+ (void)session;
+ (void)request_token;
+}
+
+static void frontend_idle_session_cancel(
+ void *context, cbm_daemon_runtime_application_session_t *session) {
+ (void)context;
+ (void)session;
+}
+
+static void frontend_idle_session_close(
+ void *context, cbm_daemon_runtime_application_session_t *session) {
+ (void)context;
+ (void)session;
+}
+
+static uint64_t frontend_test_process_id(void) {
+#ifdef _WIN32
+ return (uint64_t)GetCurrentProcessId();
+#else
+ return (uint64_t)getpid();
+#endif
+}
+
+static bool frontend_idle_fixture_start(frontend_idle_fixture_t *fixture) {
+ static const char cache[] =
+ "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd";
+ memset(fixture, 0, sizeof(*fixture));
+ if (!frontend_maintenance_fixture_start(&fixture->maintenance, "idle-observer")) {
+ return false;
+ }
+ char build[CBM_DAEMON_BUILD_FINGERPRINT_SIZE];
+ int log_written = snprintf(fixture->conflict_log, sizeof(fixture->conflict_log),
+ "%s/conflicts.ndjson", fixture->maintenance.parent);
+ if (log_written <= 0 || log_written >= (int)sizeof(fixture->conflict_log) ||
+ !cbm_daemon_runtime_process_build_fingerprint(frontend_test_process_id(), build)) {
+ return false;
+ }
+ cbm_daemon_build_identity_t identity = {
+ .semantic_version = "2.4.0",
+ .build_fingerprint = build,
+ .cache_fingerprint = cache,
+ .protocol_abi = 3,
+ .store_abi = 11,
+ .feature_abi = 7,
+ };
+ cbm_daemon_runtime_application_callbacks_t callbacks = {
+ .context = fixture,
+ .session_open = frontend_idle_session_open,
+ .request = frontend_idle_request,
+ .request_cancel = frontend_idle_request_cancel,
+ .session_cancel = frontend_idle_session_cancel,
+ .session_close = frontend_idle_session_close,
+ };
+ cbm_daemon_runtime_service_config_t config = {
+ .endpoint = fixture->maintenance.endpoint,
+ .identity = identity,
+ .conflict_log_path = fixture->conflict_log,
+ .conflict_log_cap_bytes = 64U * 1024U,
+ .max_clients = 2,
+ .lease_timeout_ms = 5000,
+ .request_timeout_ms = 30000,
+ .shutdown_timeout_ms = 30000,
+ .application = callbacks,
+ };
+ fixture->service = cbm_daemon_runtime_service_start(&config);
+ cbm_daemon_runtime_connect_result_t connect_result = {0};
+ fixture->client = fixture->service
+ ? cbm_daemon_runtime_client_connect(fixture->maintenance.endpoint, &identity,
+ 30000, &connect_result)
+ : NULL;
+ return fixture->client && connect_result.status == CBM_DAEMON_RUNTIME_CONNECT_ACCEPTED;
+}
+
+static bool frontend_idle_fixture_finish(frontend_idle_fixture_t *fixture) {
+ bool ok = true;
+ if (fixture->client) {
+ ok = cbm_daemon_runtime_client_close(fixture->client, 30000) && ok;
+ fixture->client = NULL;
+ }
+ if (fixture->service) {
+ if (cbm_daemon_runtime_service_state(fixture->service) !=
+ CBM_DAEMON_RUNTIME_SERVICE_EXITED) {
+ ok = cbm_daemon_runtime_service_stop(fixture->service, 30000) && ok;
+ }
+ ok = cbm_daemon_runtime_service_free(fixture->service) && ok;
+ fixture->service = NULL;
+ }
+ frontend_maintenance_fixture_finish(&fixture->maintenance);
+ return ok;
+}
+
+static FILE *frontend_test_fdopen_read(int fd) {
+#ifdef _WIN32
+ return _fdopen(fd, "rb");
+#else
+ return fdopen(fd, "rb");
+#endif
+}
+
+static int frontend_test_close_fd(int fd) {
+#ifdef _WIN32
+ return _close(fd);
+#else
+ return close(fd);
+#endif
+}
+
+static void *frontend_idle_run(void *opaque) {
+ frontend_idle_run_t *run = opaque;
+ run->result =
+ cbm_daemon_frontend_mcp_run(run->client, run->manager, run->input, run->output);
+ return NULL;
+}
+#endif
+
#ifndef _WIN32
static const char FRONTEND_TEST_BUILD[] =
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
@@ -1142,6 +1301,66 @@ TEST(daemon_frontend_rejects_non_notification_cancellation_shapes) {
PASS();
}
+#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
+/* An idle frontend has one independent maintenance observer. Hold that
+ * observer before its first secure presence read, then require the real worker
+ * to complete an idle cycle. Any worker-side presence read is therefore an
+ * exact duplicate, independent of elapsed time or scheduler cadence. */
+TEST(daemon_frontend_idle_uses_one_maintenance_observer) {
+ frontend_idle_fixture_t fixture;
+ bool fixture_ready = frontend_idle_fixture_start(&fixture);
+ int input_pipe[2] = {-1, -1};
+ bool pipe_ready = fixture_ready && cbm_pipe(input_pipe) == 0;
+ FILE *input = pipe_ready ? frontend_test_fdopen_read(input_pipe[0]) : NULL;
+ FILE *output = input ? tmpfile() : NULL;
+ frontend_idle_run_t run = {
+ .client = fixture.client,
+ .manager = fixture.maintenance.manager,
+ .input = input,
+ .output = output,
+ .result = -1,
+ };
+ if (output) {
+ fixture.client = NULL; /* cbm_daemon_frontend_mcp_run consumes it. */
+ }
+ cbm_daemon_frontend_test_observer_reset(true);
+ cbm_thread_t frontend_thread;
+ bool thread_started =
+ output && cbm_thread_create(&frontend_thread, 0, frontend_idle_run, &run) == 0;
+ uint64_t deadline = cbm_now_ms() + 10000U;
+ while (thread_started && cbm_now_ms() < deadline &&
+ (!cbm_daemon_frontend_test_monitor_waiting() ||
+ cbm_daemon_frontend_test_worker_idle_cycles() == 0)) {
+ cbm_usleep(1000);
+ }
+ bool monitor_waiting = cbm_daemon_frontend_test_monitor_waiting();
+ uint64_t monitor_observations = cbm_daemon_frontend_test_monitor_observations();
+ uint64_t worker_observations = cbm_daemon_frontend_test_worker_observations();
+ uint64_t idle_cycles = cbm_daemon_frontend_test_worker_idle_cycles();
+ cbm_daemon_frontend_test_observer_release();
+ bool input_closed = !pipe_ready || frontend_test_close_fd(input_pipe[1]) == 0;
+ bool joined = thread_started && cbm_thread_join(&frontend_thread) == 0;
+ bool input_stream_closed = !input || fclose(input) == 0;
+ bool output_closed = !output || fclose(output) == 0;
+ bool fixture_closed = frontend_idle_fixture_finish(&fixture);
+
+ ASSERT_TRUE(fixture_ready);
+ ASSERT_TRUE(pipe_ready);
+ ASSERT_TRUE(thread_started);
+ ASSERT_TRUE(monitor_waiting);
+ ASSERT_EQ(monitor_observations, 1);
+ ASSERT_TRUE(idle_cycles > 0);
+ ASSERT_EQ(worker_observations, 0);
+ ASSERT_TRUE(input_closed);
+ ASSERT_TRUE(joined);
+ ASSERT_EQ(run.result, 0);
+ ASSERT_TRUE(input_stream_closed);
+ ASSERT_TRUE(output_closed);
+ ASSERT_TRUE(fixture_closed);
+ PASS();
+}
+#endif
+
#ifndef _WIN32
/* RED: an MCP frontend's main thread can remain blocked forever in stdio while
* install/update/uninstall owns maintenance intent. The already-present
@@ -1442,6 +1661,9 @@ SUITE(daemon_frontend) {
RUN_TEST(daemon_frontend_correlates_cancellation_to_exact_request);
RUN_TEST(daemon_frontend_ignores_cancellation_text_in_string_content);
RUN_TEST(daemon_frontend_rejects_non_notification_cancellation_shapes);
+#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
+ RUN_TEST(daemon_frontend_idle_uses_one_maintenance_observer);
+#endif
#ifndef _WIN32
RUN_TEST(daemon_frontend_maintenance_exits_while_stdio_reader_is_blocked);
RUN_TEST(daemon_local_participant_monitor_cancels_then_bounds_active_operation);
From 0f1e65d625bb0b87fbea96251caaef0b10deebd5 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Thu, 27 Aug 2026 08:52:02 +0200
Subject: [PATCH 08/31] fix: preserve Cargo context in parallel Rust resolution
Signed-off-by: Martin Vogel
---
src/pipeline/pass_lsp_cross.c | 44 +++++++++-
src/pipeline/pass_lsp_cross.h | 10 ++-
src/pipeline/pass_parallel.c | 37 +++++++-
tests/repro/repro_issue56.c | 112 +++---------------------
tests/test_parallel.c | 159 ++++++++++++++++++++++++++++++++++
5 files changed, 255 insertions(+), 107 deletions(-)
diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c
index 17491a9ea..684554455 100644
--- a/src/pipeline/pass_lsp_cross.c
+++ b/src/pipeline/pass_lsp_cross.c
@@ -693,7 +693,10 @@ bool cbm_pxc_has_cross_lsp(CBMLanguage lang) {
/* Append cross-file results from `src_out` (allocated in a scratch arena
* about to be destroyed) into `dst_calls` (lives in cache_entry->arena),
- * copying every string field into dst_arena. Skips entries whose
+ * copying every string field into dst_arena. A manifest-qualified Rust
+ * cross-crate result supersedes any earlier result for the exact same source
+ * occurrence: the Cargo member path is stronger evidence than a same-named
+ * local fallback. Skips entries whose
* (kind, caller_qn, callee_qn, source span) is already present — avoids
* inflating the array with cross-file duplicates without conflating distinct
* same-named occurrences. For an exact duplicate, retain the higher-confidence
@@ -715,6 +718,39 @@ static void pxc_append_results(CBMArena *dst_arena, CBMResolvedCallArray *dst_ca
cbm_arena_init(&keys);
CBMHashTable *seen = cbm_ht_create((uint32_t)(dst_calls->count + src_out->count + 1));
+ /* Per-file Rust resolution may already have confidently matched the tail
+ * of `member::call()` to a same-named local function. Once the cross pass
+ * proves that `member` is a declared Cargo workspace member, replace only
+ * records for that exact parser occurrence before constructing the dedup
+ * table. This preserves distinct same-named calls at other spans while
+ * preventing the stale local result from out-ranking manifest evidence. */
+ for (int j = 0; j < src_out->count; j++) {
+ const CBMResolvedCall *src = &src_out->items[j];
+ if (!src->strategy || strcmp(src->strategy, "lsp_cross_crate") != 0 || !src->caller_qn ||
+ !src->callee_qn || src->site_end_byte <= src->site_start_byte) {
+ continue;
+ }
+ for (int i = 0; i < dst_calls->count; i++) {
+ CBMResolvedCall *dst = &dst_calls->items[i];
+ if (!dst->caller_qn || dst->kind != src->kind ||
+ dst->site_start_byte != src->site_start_byte ||
+ dst->site_end_byte != src->site_end_byte ||
+ dst->source_origin != src->source_origin ||
+ strcmp(dst->caller_qn, src->caller_qn) != 0) {
+ continue;
+ }
+ dst->caller_qn = cbm_arena_strdup(dst_arena, src->caller_qn);
+ dst->callee_qn = cbm_arena_strdup(dst_arena, src->callee_qn);
+ dst->strategy = cbm_arena_strdup(dst_arena, src->strategy);
+ dst->confidence = src->confidence;
+ dst->reason = src->reason ? cbm_arena_strdup(dst_arena, src->reason) : NULL;
+ dst->kind = src->kind;
+ dst->site_start_byte = src->site_start_byte;
+ dst->site_end_byte = src->site_end_byte;
+ dst->source_origin = src->source_origin;
+ }
+ }
+
for (int i = 0; i < dst_calls->count; i++) {
const CBMResolvedCall *rc = &dst_calls->items[i];
if (rc->caller_qn && rc->callee_qn) {
@@ -1191,8 +1227,8 @@ void cbm_pxc_dispatch_file(CBMLanguage lang, CBMFileResult *result, const char *
free(filtered);
}
-static bool pxc_build_rust_manifest(const cbm_pipeline_ctx_t *ctx, CBMArena *marena,
- CBMCargoManifest *out_m) {
+bool cbm_pxc_build_rust_manifest(const cbm_pipeline_ctx_t *ctx, CBMArena *marena,
+ CBMCargoManifest *out_m) {
if (!ctx || !ctx->repo_path || !marena || !out_m)
return false;
char path[1024];
@@ -1234,7 +1270,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *
bool have_manifest = false;
if (have_rust) {
cbm_arena_init(&cargo_arena);
- have_manifest = pxc_build_rust_manifest(ctx, &cargo_arena, &cargo_manifest);
+ have_manifest = cbm_pxc_build_rust_manifest(ctx, &cargo_arena, &cargo_manifest);
cbm_pxc_set_rust_manifest(have_manifest ? &cargo_manifest : NULL);
}
diff --git a/src/pipeline/pass_lsp_cross.h b/src/pipeline/pass_lsp_cross.h
index 149959bce..3198d79d4 100644
--- a/src/pipeline/pass_lsp_cross.h
+++ b/src/pipeline/pass_lsp_cross.h
@@ -169,10 +169,14 @@ static inline CBMTypeRegistry *cbm_pxc_registry_for_lang(const CBMCrossLspRegist
}
}
-/* Borrow the (thread-local) Rust Cargo manifest the cross-file LSP pass set for
- * cross-crate (#56) routing. The Tier-2 prebuilt Rust resolve reads it so it sees
- * exactly what the per-file fallback (cbm_pxc_run_one) would on the same thread. */
+/* Build and borrow the Rust Cargo manifest used for cross-crate (#56) routing.
+ * The manifest owns strings in manifest_arena; callers keep that arena alive
+ * until every resolver worker has joined. Each worker must install the shared
+ * immutable pointer in its own TLS slot before dispatch and clear it afterward. */
struct CBMCargoManifest;
+bool cbm_pxc_build_rust_manifest(const cbm_pipeline_ctx_t *ctx, CBMArena *manifest_arena,
+ struct CBMCargoManifest *out_manifest);
+void cbm_pxc_set_rust_manifest(const struct CBMCargoManifest *manifest);
const struct CBMCargoManifest *cbm_pxc_get_rust_manifest(void);
/* Run the cross-file LSP resolver for non-TS languages. Appends
diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c
index 9d61a1e82..716f26995 100644
--- a/src/pipeline/pass_parallel.c
+++ b/src/pipeline/pass_parallel.c
@@ -70,6 +70,7 @@ enum { PP_CSHARP_M_PREFIX_LEN = 2 };
#include "pipeline/pipeline_internal.h"
#include "pipeline/pass_lsp_cross.h" /* cbm_pxc_* helpers for fused cross-file LSP */
#include "pipeline/lsp_resolve.h"
+#include "lsp/rust_cargo.h"
#include "helpers.h" /* cbm_kind_in_set_free_cache — per-worker-thread cache teardown */
#include "pipeline/worker_pool.h"
#include "foundation/compat.h"
@@ -1398,6 +1399,11 @@ typedef struct {
* Stored as CBMCrossLspRegistries* (typedef from pass_lsp_cross.h). */
CBMCrossLspRegistries *cross_registries;
+ /* Parsed once on the coordinator thread and borrowed read-only by resolve
+ * workers. The pointer is installed into each worker's TLS slot so Rust's
+ * manifest-aware cross-file resolver sees the same Cargo context. */
+ const CBMCargoManifest *rust_manifest;
+
/* F4: LAZILY-built shared Rust registry (built ONCE, on the first NULL-filter
* rust file — the ~all_defs amplifier files). Not eager: repos whose rust files
* all filter to subsets never pay the O(all_defs) build + multi-GB RSS. Built
@@ -2944,6 +2950,8 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
resolve_ctx_t *rc = ctx_ptr;
resolve_worker_state_t *ws = &rc->workers[worker_id];
+ cbm_pxc_set_rust_manifest(rc->rust_manifest);
+
if (!ws->local_edge_buf) {
ws->local_edge_buf =
cbm_gbuf_new_shared_ids(rc->project_name, rc->repo_path, rc->shared_ids);
@@ -3009,6 +3017,8 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
* a mixed-source-root Java↔Kotlin site remains unresolved, so JVM
* callers run whenever either kind of site exists. */
bool jvm_cross_lsp = (lang == CBM_LANG_JAVA || lang == CBM_LANG_KOTLIN);
+ bool rust_workspace_cross_lsp =
+ (lang == CBM_LANG_RUST && rc->rust_manifest && rc->rust_manifest->member_count > 0);
int call_reference_sites = pp_call_reference_site_count(result, lang);
int semantic_sites = result->calls.count + call_reference_sites;
int qualified_lsp_sites = pp_qualified_lsp_site_count(result);
@@ -3016,7 +3026,8 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
bool cross_lsp_eligible =
(rc->all_defs && rc->def_count > 0 && cbm_pxc_has_cross_lsp(lang) &&
(semantic_sites > 0 || pending_lsp_site) &&
- (jvm_cross_lsp || pending_lsp_site || qualified_lsp_sites < semantic_sites) &&
+ (jvm_cross_lsp || rust_workspace_cross_lsp || pending_lsp_site ||
+ qualified_lsp_sites < semantic_sites) &&
!is_generated);
/* Skip files with nothing else to resolve and no cross-LSP work. */
@@ -3187,6 +3198,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
* into dead TLS and are never retired, so a later cross-thread free can
* never bring their refcount to zero (leak). Retiring them here releases
* each page as its final chunk returns. */
+ cbm_pxc_set_rust_manifest(NULL);
cbm_destroy_thread_parser();
cbm_slab_destroy_thread();
cbm_service_pattern_cache_end();
@@ -3213,6 +3225,25 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
}
memset(workers, 0, (size_t)worker_count * sizeof(resolve_worker_state_t));
+ bool have_rust = false;
+ for (int i = 0; i < file_count; i++) {
+ if (result_cache[i] && files[i].language == CBM_LANG_RUST) {
+ have_rust = true;
+ break;
+ }
+ }
+ CBMArena rust_manifest_arena;
+ CBMCargoManifest rust_manifest;
+ bool rust_manifest_arena_live = false;
+ const CBMCargoManifest *rust_manifest_ptr = NULL;
+ if (have_rust) {
+ cbm_arena_init(&rust_manifest_arena);
+ rust_manifest_arena_live = true;
+ if (cbm_pxc_build_rust_manifest(ctx, &rust_manifest_arena, &rust_manifest)) {
+ rust_manifest_ptr = &rust_manifest;
+ }
+ }
+
resolve_ctx_t rc = {
.files = files,
.file_count = file_count,
@@ -3230,6 +3261,7 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
.def_modules = def_modules,
.module_def_index = module_def_index,
.cross_registries = cross_registries,
+ .rust_manifest = rust_manifest_ptr,
};
atomic_init(&rc.next_file_idx, 0);
atomic_init(&rc.lsp_cross_processed, 0);
@@ -3255,6 +3287,9 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
rc.rust_shared_arena_live = false;
}
cbm_mutex_destroy(&rc.rust_shared_mu);
+ if (rust_manifest_arena_live) {
+ cbm_arena_destroy(&rust_manifest_arena);
+ }
/* Sub-phase: Merge all local edge bufs into main gbuf (SEQUENTIAL) */
CBM_PROF_START(t_resolve_merge);
diff --git a/tests/repro/repro_issue56.c b/tests/repro/repro_issue56.c
index c5cbf596b..414756746 100644
--- a/tests/repro/repro_issue56.c
+++ b/tests/repro/repro_issue56.c
@@ -1,92 +1,17 @@
/*
- * repro_issue56.c — Reproduce-first case for OPEN bug #56.
+ * repro_issue56.c — production-pipeline regression for Rust cross-crate calls.
*
- * Bug #56: "Cross-crate call graphs stop at boundaries" (Rust)
+ * The fixture is a two-member Cargo workspace. crate_b calls
+ * `crate_a::helper()` and also declares a same-named local helper, so generic
+ * bare-name resolution cannot accidentally satisfy the assertion. The test
+ * requires a persisted CALLS edge into crate_a's namespace.
*
- * ROOT CAUSE (pipeline / Rust LSP path):
- * The tree-sitter-only Rust extractor has no access to Cargo metadata
- * at extraction time, so when it sees `crate_a::helper()` inside
- * crate_b, it records a raw call-site for the path but has no registry
- * entry for `crate_a::helper` — only the definitions in the *same file*
- * were seeded. The LSP resolver therefore cannot match the call-site to
- * a callee QN across the crate boundary, and the resulting
- * CBMResolvedCall is either absent or marked with low confidence and
- * discarded. When the pipeline writes graph edges for this project, no
- * CALLS edge is minted for the cross-crate call — the call graph stops
- * at the crate edge.
- *
- * v0.8.1 added a hybrid-LSP Rust path that "materially improves" this
- * (issue comment, maintainer 2026-06-25), but the reporter was asked to
- * retest; the issue remains OPEN because no retest confirming resolution
- * was provided. The workspace-member wiring test
- * (rustlsp_extra_cargo_wires_workspace_member in test_rust_lsp.c) only
- * exercises the *single-file LSP* layer with a manually-parsed manifest;
- * it does NOT verify that the full production pipeline (rh_index_files →
- * cbm_pipeline → graph store) persists a cross-crate CALLS edge for a
- * real multi-file Cargo workspace fixture. That gap is what this test
- * fills.
- *
- * FIXTURE:
- * A minimal Cargo workspace with two crates:
- *
- * [workspace Cargo.toml] — workspace root, declares members
- * crate_a/Cargo.toml — library crate "crate_a"
- * crate_a/src/lib.rs — exposes `pub fn helper() {}`
- * crate_b/Cargo.toml — binary crate "crate_b", depends on crate_a
- * crate_b/src/main.rs — calls `crate_a::helper()` from `fn run()`;
- * also defines a LOCAL `fn helper()` to break
- * bare-name uniqueness (see note below)
- *
- * The only meaningful cross-crate CALLS edge is:
- * crate_b::run → crate_a::helper
- *
- * EXPECTED (correct) behaviour:
- * After indexing the workspace through the production MCP pipeline, the
- * graph store must contain at least one CALLS edge whose TARGET node's
- * qualified_name contains "crate_a" (i.e. routes into the crate_a
- * namespace, not into crate_b's local helper).
- *
- * ACTUAL (buggy) behaviour:
- * The pipeline extracts both files, but the cross-crate path
- * `crate_a::helper` in crate_b/src/main.rs is not resolved to a graph
- * node in crate_a because Cargo workspace member metadata is not
- * plumbed into the per-file extraction phase. Result: zero CALLS edges
- * to the crate_a namespace.
- *
- * WHY THIS IS RED ON CURRENT CODE (even post-v0.8.1):
- * The rustlsp_extra_cargo_wires_workspace_member unit test exercises only
- * the LSP layer (cbm_run_rust_lsp_with_manifest called with a parsed
- * CBMCargoManifest) and confirms the resolver *can* route
- * `engine::boot()` to `engine.boot` when given the manifest explicitly.
- * BUT: the production pipeline's per-file extraction path
- * (cbm_extract_file → cbm_run_rust_lsp) does NOT receive a pre-parsed
- * workspace manifest — it only gets the individual file's content.
- * Additionally, cbm_pxc_has_cross_lsp() returns false for CBM_LANG_RUST
- * (pass_lsp_cross.c), so the cross-file LSP pass is never invoked for
- * Rust. Therefore a real workspace indexed through index_repository
- * produces no CALLS edges crossing into crate_a, and this test is RED.
- *
- * WHY THE OLD >= 2 COUNT TEST FALSE-PASSED:
- * With a unique `helper` name in the project (one definition in crate_a,
- * no other `helper` anywhere), the generic pipeline name resolver
- * (registry.c, resolve_name_lookup) resolves `crate_a::helper` to the
- * sole `helper` candidate by bare-name suffix scoring — WITHOUT needing
- * any cross-crate workspace metadata. This produced calls >= 2 (the
- * intra-file main→run plus the bare-name-resolved run→helper), making
- * the old ASSERT_GTE(calls, 2) GREEN even though the bug was not fixed.
- *
- * Fix: add a LOCAL `fn helper()` in crate_b/src/main.rs so there are
- * now TWO `helper` candidates in the project registry. The generic
- * resolver either picks the wrong one (crate_b-local) or abstains
- * (ambiguous). Only a correctly crate-qualified resolver routes
- * `crate_a::helper` specifically to crate_a's node. The assertion then
- * checks the TARGET node's qualified_name contains "crate_a" — a count
- * check is no longer sufficient because the local helper also contributes
- * a CALLS edge (run_local→helper).
- *
- * UNCERTAINTY:
- * If a future version plumbs workspace metadata or wires Rust lsp_cross
- * correctly, this test will go GREEN — that is the intended outcome.
+ * The direct parallel regression lives in test_parallel.c because the bug was
+ * specific to worker-local pipeline context: the root Cargo manifest was
+ * parsed by the sequential cross-LSP driver but was not propagated into
+ * parallel resolve workers. A confident local result could also suppress the
+ * manifest-aware cross pass. This repro remains as an end-to-end guard for the
+ * same graph contract through the production harness.
*/
#include "test_framework.h"
@@ -108,19 +33,8 @@
* CALLS edge's TARGET node has a qualified_name containing "crate_a",
* proving the cross-crate boundary was traversed.
*
- * RED condition:
- * No CALLS edge whose target QN contains "crate_a" exists in the store.
- *
- * This test is RED on current code because:
- * 1. cbm_run_rust_lsp is called with NULL manifest (cbm.c:645), so no
- * workspace metadata is available at extraction time.
- * 2. cbm_pxc_has_cross_lsp returns false for CBM_LANG_RUST
- * (pass_lsp_cross.c:281), so the cross-file LSP pass never runs for
- * Rust and cannot seed crate_a defs into crate_b's resolver context.
- * 3. With two `helper` candidates (crate_a and crate_b-local), the
- * generic resolver's qualified_suffix_match fails (neither QN ends
- * with ".crate_a.helper") and bare-name scoring picks the crate_b-
- * local one or abstains, never routing to crate_a.
+ * Regression condition: no CALLS edge whose target QN contains "crate_a"
+ * exists in the store.
*/
TEST(repro_issue56_cross_crate_calls) {
/*
diff --git a/tests/test_parallel.c b/tests/test_parallel.c
index c33d26690..1a1c42ce3 100644
--- a/tests/test_parallel.c
+++ b/tests/test_parallel.c
@@ -351,6 +351,12 @@ static cbm_gbuf_t *run_parallel_with_extract_opts_and_mutator(
harness_ctx_free_tables(&ctx);
cbm_registry_free(reg);
+ /* cbm_parallel_extract installs a process-global package map whose
+ * production owner is the surrounding pipeline lifecycle. This direct
+ * pass harness owns that lifecycle itself, so mirror production teardown
+ * before returning the graph to the test. */
+ cbm_pkgmap_free(cbm_pipeline_get_pkgmap());
+ cbm_pipeline_set_pkgmap(NULL);
return gbuf;
}
@@ -925,6 +931,25 @@ static bool callable_has_call_target_fragment(const cbm_gbuf_t *gbuf, const char
return false;
}
+static const cbm_gbuf_edge_t *find_call_edge_to_target_fragment(const cbm_gbuf_t *gbuf,
+ const char *source_tail,
+ const char *target_fragment) {
+ const cbm_gbuf_node_t *source = find_unique_callable_node_by_tail(gbuf, source_tail);
+ if (!source || !target_fragment)
+ return NULL;
+ const cbm_gbuf_edge_t **edges = NULL;
+ int count = 0;
+ if (cbm_gbuf_find_edges_by_source_type(gbuf, source->id, "CALLS", &edges, &count) != 0)
+ return NULL;
+ for (int i = 0; i < count; i++) {
+ const cbm_gbuf_node_t *target =
+ edges[i] ? cbm_gbuf_find_by_id(gbuf, edges[i]->target_id) : NULL;
+ if (target && target->qualified_name && strstr(target->qualified_name, target_fragment))
+ return edges[i];
+ }
+ return NULL;
+}
+
static int count_calls_edges_to_tail(const cbm_gbuf_t *gbuf, const char *target_tail) {
const cbm_gbuf_node_t *target = find_unique_callable_node_by_tail(gbuf, target_tail);
if (!target)
@@ -2534,6 +2559,138 @@ TEST(parallel_kotlin_nonbinary_operator_carriers_reach_graph) {
PASS();
}
+/* Build the smallest real Cargo workspace that exercises the direct parallel
+ * resolve API. crate_a is a declared workspace member, while unlisted is a
+ * graph-visible decoy with the same helper name. That decoy is intentional:
+ * without manifest-aware routing, a bare-name match cannot accidentally make
+ * the test green. The optional crate_b-local helper pins the second failure
+ * mode where a confident in-file resolution used to suppress cross-LSP. */
+static cbm_gbuf_t *run_issue56_parallel_workspace(bool local_decoy) {
+ static const char workspace_toml[] =
+ "[workspace]\n"
+ "members = [\"crate_a\", \"crate_b\"]\n"
+ "resolver = \"2\"\n";
+ static const char crate_a_toml[] =
+ "[package]\nname = \"crate_a\"\nversion = \"0.1.0\"\nedition = \"2021\"\n";
+ static const char crate_b_toml[] =
+ "[package]\nname = \"crate_b\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"
+ "\n[dependencies]\ncrate_a = { path = \"../crate_a\" }\n";
+ static const char crate_a_source[] = "pub fn helper() {}\n";
+ static const char unlisted_source[] = "pub fn helper() {}\n";
+ static const char caller_without_local[] =
+ "fn run() { crate_a::helper(); }\n"
+ "fn main() { run(); }\n";
+ static const char caller_with_local[] =
+ "fn helper() {}\n"
+ "fn run() { crate_a::helper(); }\n"
+ "fn main() { run(); }\n";
+
+ char tmpdir[256];
+ snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_issue56_parallel_XXXXXX");
+ if (!cbm_mkdtemp(tmpdir))
+ return NULL;
+
+ char crate_a[512];
+ char crate_a_src[512];
+ char crate_b[512];
+ char crate_b_src[512];
+ char unlisted[512];
+ char unlisted_src[512];
+ snprintf(crate_a, sizeof(crate_a), "%s/crate_a", tmpdir);
+ snprintf(crate_a_src, sizeof(crate_a_src), "%s/src", crate_a);
+ snprintf(crate_b, sizeof(crate_b), "%s/crate_b", tmpdir);
+ snprintf(crate_b_src, sizeof(crate_b_src), "%s/src", crate_b);
+ snprintf(unlisted, sizeof(unlisted), "%s/unlisted", tmpdir);
+ snprintf(unlisted_src, sizeof(unlisted_src), "%s/src", unlisted);
+ if (cbm_mkdir(crate_a) != 0 || cbm_mkdir(crate_a_src) != 0 || cbm_mkdir(crate_b) != 0 ||
+ cbm_mkdir(crate_b_src) != 0 || cbm_mkdir(unlisted) != 0 || cbm_mkdir(unlisted_src) != 0) {
+ th_rmtree(tmpdir);
+ return NULL;
+ }
+
+ char root_manifest[512];
+ char crate_a_manifest[512];
+ char crate_b_manifest[512];
+ char crate_a_file[512];
+ char crate_b_file[512];
+ char unlisted_file[512];
+ snprintf(root_manifest, sizeof(root_manifest), "%s/Cargo.toml", tmpdir);
+ snprintf(crate_a_manifest, sizeof(crate_a_manifest), "%s/Cargo.toml", crate_a);
+ snprintf(crate_b_manifest, sizeof(crate_b_manifest), "%s/Cargo.toml", crate_b);
+ snprintf(crate_a_file, sizeof(crate_a_file), "%s/lib.rs", crate_a_src);
+ snprintf(crate_b_file, sizeof(crate_b_file), "%s/main.rs", crate_b_src);
+ snprintf(unlisted_file, sizeof(unlisted_file), "%s/lib.rs", unlisted_src);
+ if (th_write_file(root_manifest, workspace_toml) != 0 ||
+ th_write_file(crate_a_manifest, crate_a_toml) != 0 ||
+ th_write_file(crate_b_manifest, crate_b_toml) != 0 ||
+ th_write_file(crate_a_file, crate_a_source) != 0 ||
+ th_write_file(crate_b_file, local_decoy ? caller_with_local : caller_without_local) != 0 ||
+ th_write_file(unlisted_file, unlisted_source) != 0) {
+ th_rmtree(tmpdir);
+ return NULL;
+ }
+
+ cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL};
+ cbm_file_info_t *files = NULL;
+ int file_count = 0;
+ if (cbm_discover(tmpdir, &opts, &files, &file_count) != 0 || file_count < 3) {
+ cbm_discover_free(files, file_count);
+ th_rmtree(tmpdir);
+ return NULL;
+ }
+ cbm_gbuf_t *gbuf =
+ run_parallel("cbm_issue56_parallel", tmpdir, files, file_count, 2 /* real workers */);
+ cbm_discover_free(files, file_count);
+ th_rmtree(tmpdir);
+ return gbuf;
+}
+
+TEST(parallel_rust_cross_crate_worker_receives_workspace_manifest) {
+ cbm_gbuf_t *gbuf = run_issue56_parallel_workspace(false);
+ ASSERT_NOT_NULL(gbuf);
+
+ const cbm_gbuf_edge_t *correct =
+ find_call_edge_to_target_fragment(gbuf, "main.run", ".crate_a.");
+ const bool correct_found = correct != NULL;
+ const bool unlisted =
+ callable_has_call_target_fragment(gbuf, "main.run", ".unlisted.");
+ const bool manifest_strategy =
+ correct && correct->properties_json && strstr(correct->properties_json, "lsp_cross_crate");
+ if (!correct || unlisted || !manifest_strategy) {
+ printf(" issue56 manifest diagnostic: correct=%d unlisted=%d strategy=%d\n",
+ correct_found, unlisted, manifest_strategy);
+ }
+ cbm_gbuf_free(gbuf);
+
+ ASSERT_TRUE(correct_found);
+ ASSERT_FALSE(unlisted);
+ ASSERT_TRUE(manifest_strategy);
+ PASS();
+}
+
+TEST(parallel_rust_cross_crate_manifest_beats_confident_local_resolution) {
+ cbm_gbuf_t *gbuf = run_issue56_parallel_workspace(true);
+ ASSERT_NOT_NULL(gbuf);
+
+ const cbm_gbuf_edge_t *correct =
+ find_call_edge_to_target_fragment(gbuf, "main.run", ".crate_a.");
+ const bool correct_found = correct != NULL;
+ const bool wrong_local =
+ callable_has_call_target_fragment(gbuf, "main.run", ".crate_b.");
+ const bool manifest_strategy =
+ correct && correct->properties_json && strstr(correct->properties_json, "lsp_cross_crate");
+ if (!correct || wrong_local || !manifest_strategy) {
+ printf(" issue56 local-shadow diagnostic: correct=%d wrong_local=%d strategy=%d\n",
+ correct_found, wrong_local, manifest_strategy);
+ }
+ cbm_gbuf_free(gbuf);
+
+ ASSERT_TRUE(correct_found);
+ ASSERT_FALSE(wrong_local);
+ ASSERT_TRUE(manifest_strategy);
+ PASS();
+}
+
/* A known external macro semantic owns its parser occurrence even when the
* external target is not materialized as a graph node. It must not fall back
* to a same-named local function in either pipeline. */
@@ -3926,6 +4083,8 @@ SUITE(parallel) {
RUN_TEST(parallel_tsx_import_namespace_exact_parity);
RUN_TEST(parallel_kotlin_external_protocol_does_not_use_project_class_method_tail);
RUN_TEST(parallel_kotlin_nonbinary_operator_carriers_reach_graph);
+ RUN_TEST(parallel_rust_cross_crate_worker_receives_workspace_manifest);
+ RUN_TEST(parallel_rust_cross_crate_manifest_beats_confident_local_resolution);
RUN_TEST(parallel_rust_known_macro_does_not_fallback_to_local_function);
RUN_TEST(parallel_rust_proc_macros_are_decorates_and_usage_only);
RUN_TEST(parallel_c_preprocessed_coordinate_collision_preserves_hidden_target);
From cbd241d7f1b33248501010cbaac50b07785373b8 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Fri, 28 Aug 2026 16:41:38 +0200
Subject: [PATCH 09/31] fix(tests): venue-parity interface probes must not pipe
into grep -q
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The two layer-5 interface probes tested for a substring by piping the
captured output into `grep -q`. Under `set -o pipefail` (line 32) that
turns a SATISFIED contract into a probe failure whenever the payload is
larger than the pipe buffer: `grep -q` exits at the first match, and the
writer takes EPIPE on the remainder, so the pipeline reports the writer's
non-zero status even though the match succeeded.
Observed on macos-15-intel:
test_venue_parity_contract.sh: line 404: printf: write error: Broken pipe
INTERFACE CONTRACT: scripts/ci/smoke-artifact.sh --help must exit 0 and
print a Usage: block (rc=0)
VENUE PARITY CONTRACT VIOLATED — interface probes failed (layer 5)
rc=0 and the Usage: block was present — the match is exactly what made
grep exit early. `smoke-artifact.sh --help` is 698 bytes against a
512-byte PIPE_BUF, and prints `Usage:` on its first line, so the writer
is killed at the earliest possible moment. Whether the remaining write
lands before grep exits is a scheduling race, which is why this only
ever showed up on one runner.
Replace both pipelines with in-shell `case` matching: no subprocess, no
pipe, no race, and a strictly literal match (the second probe's "--help."
was previously a regex whose `.` matched any character).
Verified: the contract passes (18 --help entries, 8 strict-flag entries),
and the matcher still returns 0 for output with no Usage: block, so the
probe can still fail.
Signed-off-by: Martin Vogel
---
tests/test_venue_parity_contract.sh | 17 +++++++++++++++--
1 file changed, 15 insertions(+), 2 deletions(-)
diff --git a/tests/test_venue_parity_contract.sh b/tests/test_venue_parity_contract.sh
index b609a1493..4bcd694d7 100755
--- a/tests/test_venue_parity_contract.sh
+++ b/tests/test_venue_parity_contract.sh
@@ -401,7 +401,16 @@ test-infrastructure/vm/win.sh
case "$entry" in
*.py) out=$(cd "$ROOT" && python3 "$entry" --help 2>&1) && rc=0 || rc=$? ;;
esac
- if [ "$rc" -ne 0 ] || ! printf '%s' "$out" | grep -q "Usage:"; then
+ # Substring test in-shell: piping into `grep -q` is unsafe under
+ # `set -o pipefail` because grep exits at the first match and the
+ # writer takes EPIPE on any payload larger than the pipe buffer,
+ # turning a SATISFIED contract into a probe failure (macOS, 698-byte
+ # --help vs a 512-byte PIPE_BUF).
+ case "$out" in
+ *"Usage:"*) has_usage=1 ;;
+ *) has_usage=0 ;;
+ esac
+ if [ "$rc" -ne 0 ] || [ "$has_usage" -eq 0 ]; then
echo "INTERFACE CONTRACT: $entry --help must exit 0 and print a Usage: block (rc=$rc)" >&2
probe_failures=1
fi
@@ -422,7 +431,11 @@ scripts/smoke-invariants.sh
"
for entry in $STRICT_ENTRIES; do
out=$(cd "$ROOT" && bash "$entry" --definitely-not-a-flag 2>&1) && rc=0 || rc=$?
- if [ "$rc" -ne 2 ] || ! printf '%s' "$out" | grep -q "Please consult --help."; then
+ case "$out" in
+ *"Please consult --help."*) has_hint=1 ;;
+ *) has_hint=0 ;;
+ esac
+ if [ "$rc" -ne 2 ] || [ "$has_hint" -eq 0 ]; then
echo "INTERFACE CONTRACT: $entry must reject unknown flags with exit 2 + 'Please consult --help.' (rc=$rc)" >&2
probe_failures=1
fi
From e5f3aab02e1c8bdb3d3e04f6b52d78d741e1241f Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Fri, 28 Aug 2026 19:03:07 +0200
Subject: [PATCH 10/31] feat(pipeline): index-time importance scoring (weighted
degree)
Score every Function/Method/Class node during indexing and persist the
result as a numeric "importance" key inside the node's existing
properties_json:
importance = sqrt(num_refs) * priv * generic * distinct * test_penalty
num_refs is the incoming CALLS + USAGE degree. The multipliers demote
private (leading-underscore) and generic names (defined in >= 5 distinct
files), promote distinctive identifiers (snake_case or camelCase, len >=
8), and demote test scaffolding -- symbols in a test file per the graph's
own cbm_is_test_path() classifier, or targets of an incoming TESTS edge.
Weighted degree only; a transitive (PageRank) refinement is deliberately
not built here until it can be shown to beat this on a fixed judgment
set.
No schema change and no CBM_INDEX_FORMAT_VERSION bump: the score lives in
the existing properties_json TEXT column, so no index is forced to
rebuild and indexes written by older builds simply lack the key.
Three failure modes this shape invites, all of them silent, are addressed
directly:
* Cost. The generic-name multiplier needs |{files a name is defined in}|.
Computing that per NODE with a pairwise file-path scan is O(k^3) in a
same-name group of size k -- ruinous on real corpora, where a single
Java name group reaches k > 4000. Here the count is computed once per
distinct NAME and memoized, and file paths are deduplicated through a
hash set, so total distinct-file work is linear in the graph. Measured
cold full index of a 666 MB Java corpus, same -O2 binary: 85 s with the
pass disabled, 80 s with this implementation, 481 s with the per-node
shape.
* Registration. PREDUMP_PASS_COUNT is now derived from the pass table
with sizeof instead of being hand-written. A hand-written count that
lags an appended entry silently skips the last-registered pass with
every test still green.
* Incremental. The incremental post-pass sequence runs the pass too, over
a graph rehydrated from the store whose nodes already carry the key.
The write-back overwrites an existing key in place instead of appending
a second one, which would otherwise corrupt properties_json into
{"importance":1.0,...,"importance":2.0}.
tests/test_importance.c covers the formula and binds all three failure
modes; the complexity suite gains a GATED counter asserting the pass's
same-name-group work stays linear (~2x, not ~4x) under a corpus doubling.
Signed-off-by: Martin Vogel
Co-authored-by: petercoxphoto <20230727+petercoxphoto@users.noreply.github.com>
---
Makefile.cbm | 3 +-
src/pipeline/pass_importance.c | 396 +++++++++++++++++
src/pipeline/pipeline.c | 22 +-
src/pipeline/pipeline_incremental.c | 14 +
src/pipeline/pipeline_internal.h | 25 ++
tests/test_complexity.c | 44 ++
tests/test_importance.c | 641 ++++++++++++++++++++++++++++
tests/test_main.c | 2 +
8 files changed, 1142 insertions(+), 5 deletions(-)
create mode 100644 src/pipeline/pass_importance.c
create mode 100644 tests/test_importance.c
diff --git a/Makefile.cbm b/Makefile.cbm
index b71fa6ac9..dc92e1703 100644
--- a/Makefile.cbm
+++ b/Makefile.cbm
@@ -354,6 +354,7 @@ PIPELINE_SRCS = \
src/pipeline/pass_similarity.c \
src/pipeline/pass_semantic_edges.c \
src/pipeline/pass_complexity.c \
+ src/pipeline/pass_importance.c \
src/pipeline/pass_cross_repo.c \
src/pipeline/artifact.c \
src/pipeline/pass_ensemble_routing.c \
@@ -560,7 +561,7 @@ TEST_DISCOVER_SRCS = \
TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c
-TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c
+TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_importance.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c
TEST_WATCHER_SRCS = tests/test_watcher.c
diff --git a/src/pipeline/pass_importance.c b/src/pipeline/pass_importance.c
new file mode 100644
index 000000000..a727ddee0
--- /dev/null
+++ b/src/pipeline/pass_importance.c
@@ -0,0 +1,396 @@
+/*
+ * pass_importance.c — Index-time per-symbol importance score (weighted degree).
+ *
+ * Predump pass. Computes an importance score for every Function/Method/Class
+ * node and stores it as a numeric "importance" key inside the node's EXISTING
+ * properties_json blob — no new column, no schema change, and therefore no
+ * CBM_INDEX_FORMAT_VERSION bump. Indexes written by older builds simply lack
+ * the key; every consumer must tolerate its absence.
+ *
+ * Model (weighted degree, Aider-repomap shaped):
+ *
+ * importance = sqrt(num_refs) * priv * generic * distinct * test_penalty
+ *
+ * num_refs incoming CALLS + USAGE edges. 0 -> sqrt(0) = 0.
+ * priv 0.1 name is private (leading underscore)
+ * generic 0.1 the name is defined in >= 5 DISTINCT files
+ * distinct 10 name is snake_case or camelCase AND len >= 8
+ * test_penalty
+ * 0.1 the symbol is test scaffolding — either the target of an
+ * incoming TESTS edge (a production helper exercised by a
+ * test), or it lives in a test file per the graph's own
+ * canonical cbm_is_test_path() classifier (the same one
+ * pass_tests uses).
+ *
+ * Transitive importance (PageRank) is deliberately NOT built here: weighted
+ * degree is what this pass claims, and a transitive refinement has to earn
+ * itself against a fixed judgment set before it ships.
+ *
+ * ── Cost ─────────────────────────────────────────────────────────────
+ * The generic-name multiplier needs |{ file a name is defined in }|. The
+ * obvious shape — for each node, walk its whole same-name group and
+ * pairwise-compare file paths — is O(k^2) per node and therefore O(k^3) per
+ * same-name group of size k. That is not a theoretical concern: on a large
+ * Java corpus a single name group (`toString`, k≈4.6k) costs ~2 minutes, and
+ * a handful of such names alone multiplies total index time several-fold.
+ *
+ * This implementation is linear in the graph instead:
+ * - the distinct-file count is computed ONCE PER DISTINCT NAME and memoized
+ * (g_imp_name_counts), so a group of size k is scanned once, not k times;
+ * - within a group, file paths are deduplicated through a hash set rather
+ * than pairwise strcmp.
+ * Total distinct-file work is therefore Σ over distinct names of k = O(N).
+ * Both gbuf lookups it rests on (find_by_name, find_edges_by_target_type) are
+ * hash-indexed, so the per-node loop is O(1) amortised per node.
+ *
+ * g_importance_name_visits counts same-name-group member visits and is GATED
+ * in the complexity suite: it must stay linear (ratio ~2) under a corpus
+ * doubling. A regression to the per-node scan lands at ~4 and fails the gate.
+ *
+ * ── Ordering (load-bearing) ──────────────────────────────────────────
+ * Registered LAST among the predump passes, and last in the incremental
+ * post-pass sequence, so every edge type the score reads (CALLS, USAGE from
+ * extraction; TESTS from pass_tests) already exists. A pass ordered earlier
+ * would silently score everything at num_refs = 0 with no test penalty.
+ */
+#include "foundation/constants.h"
+#include "pipeline/pipeline.h"
+#include "pipeline/pipeline_internal.h"
+#include "graph_buffer/graph_buffer.h"
+#include "foundation/hash_table.h"
+#include "foundation/log.h"
+#include "foundation/compat.h"
+#include "cbm.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+/* Work counters — deltas are read by the complexity suite's linearity gate.
+ * Never reset here: callers snapshot before/after so nested runs compose. */
+_Atomic uint64_t g_importance_nodes = 0;
+_Atomic uint64_t g_importance_name_visits = 0;
+
+enum {
+ CBM_IMPORTANCE_GENERIC_MIN_FILES = 5, /* name defined in >= N files -> generic */
+ CBM_IMPORTANCE_DISTINCT_MIN_LEN = 8, /* distinctive-identifier length floor */
+};
+static const double CBM_IMPORTANCE_PRIV_MUL = 0.1;
+static const double CBM_IMPORTANCE_GENERIC_MUL = 0.1;
+static const double CBM_IMPORTANCE_DISTINCT_MUL = 10.0;
+static const double CBM_IMPORTANCE_TEST_MUL = 0.1;
+
+/* The node labels that carry an importance score. File/Module and other
+ * non-symbol nodes are deliberately excluded. */
+static const char *const CBM_IMPORTANCE_LABELS[] = {"Function", "Method", "Class"};
+enum { CBM_IMPORTANCE_LABEL_COUNT = 3 };
+
+/* Int → string for structured logging (thread-safe ring buffer). */
+static const char *itoa_imp(uint64_t val) {
+ enum { RING = 2, MASK = 1 };
+ static CBM_TLS char bufs[RING][CBM_SZ_32];
+ static CBM_TLS int idx = 0;
+ int i = idx;
+ idx = (idx + 1) & MASK;
+ snprintf(bufs[i], sizeof(bufs[i]), "%llu", (unsigned long long)val);
+ return bufs[i];
+}
+
+static bool name_is_private(const char *name) {
+ return name != NULL && name[0] == '_';
+}
+
+/* Distinctive = (snake_case OR camelCase) AND length >= 8. snake_case is an
+ * embedded '_'; camelCase is a lower->upper "hump". A plain len>=8 lowercase
+ * word (no '_', no hump) is NOT distinctive. */
+static bool name_is_distinctive(const char *name) {
+ if (!name) {
+ return false;
+ }
+ size_t len = strlen(name);
+ if (len < (size_t)CBM_IMPORTANCE_DISTINCT_MIN_LEN) {
+ return false;
+ }
+ if (strchr(name, '_') != NULL) {
+ return true; /* snake_case */
+ }
+ for (size_t i = 1; i < len; i++) {
+ if (islower((unsigned char)name[i - 1]) && isupper((unsigned char)name[i])) {
+ return true; /* camelCase hump */
+ }
+ }
+ return false;
+}
+
+/* ── JSON property write-back ─────────────────────────────────────────
+ *
+ * properties_json is a flat object here (same assumption the sibling passes'
+ * json_get_int/json_get_bool helpers make), but the value scanner below is
+ * written to survive nested values so a future richer blob cannot be
+ * truncated by an in-place overwrite. */
+
+/* Return a pointer just past the JSON value beginning at p (first non-space
+ * char of the value). NULL if the value is unterminated/malformed. */
+static const char *imp_value_end(const char *p) {
+ if (*p == '"') {
+ p++;
+ while (*p) {
+ if (*p == '\\' && p[1]) {
+ p += 2;
+ continue;
+ }
+ if (*p == '"') {
+ return p + 1;
+ }
+ p++;
+ }
+ return NULL;
+ }
+ if (*p == '{' || *p == '[') {
+ int depth = 0;
+ bool in_str = false;
+ while (*p) {
+ if (in_str) {
+ if (*p == '\\' && p[1]) {
+ p += 2;
+ continue;
+ }
+ if (*p == '"') {
+ in_str = false;
+ }
+ } else if (*p == '"') {
+ in_str = true;
+ } else if (*p == '{' || *p == '[') {
+ depth++;
+ } else if (*p == '}' || *p == ']') {
+ depth--;
+ if (depth == 0) {
+ return p + 1;
+ }
+ }
+ p++;
+ }
+ return NULL;
+ }
+ while (*p && *p != ',' && *p != '}' && *p != ']') {
+ p++;
+ }
+ return p;
+}
+
+/* Find `"key"` where it is genuinely in KEY position: preceded (modulo
+ * whitespace) by '{' or ',' and followed (modulo whitespace) by ':'. A bare
+ * strstr would also match the text inside a string VALUE. Returns a pointer
+ * to the opening quote, or NULL. */
+static const char *imp_find_key(const char *json, const char *key) {
+ char pat[CBM_SZ_64];
+ int n = snprintf(pat, sizeof(pat), "\"%s\"", key);
+ if (n < 0 || (size_t)n >= sizeof(pat)) {
+ return NULL;
+ }
+ const char *p = json;
+ while ((p = strstr(p, pat)) != NULL) {
+ const char *before = p;
+ while (before > json && isspace((unsigned char)before[-1])) {
+ before--;
+ }
+ char prev = (before > json) ? before[-1] : '\0';
+ const char *after = p + n;
+ while (isspace((unsigned char)*after)) {
+ after++;
+ }
+ if ((prev == '{' || prev == ',') && *after == ':') {
+ return p;
+ }
+ p += n;
+ }
+ return NULL;
+}
+
+/* Write the numeric "importance" key into a node's properties JSON object.
+ *
+ * IDEMPOTENT BY CONTRACT. The incremental path rehydrates nodes straight out
+ * of the store (cbm_gbuf_load_from_db), and those nodes ALREADY carry an
+ * "importance" key from the previous run. A pure append would emit
+ * {"importance":1.0,...,"importance":2.0} — silent property corruption that
+ * no build or schema check would catch. So: overwrite an existing key in
+ * place, append only when the key is genuinely absent.
+ *
+ * A properties_json that is not a JSON object is left untouched. */
+void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score) {
+ if (!node) {
+ return;
+ }
+ const char *old = node->properties_json ? node->properties_json : "{}";
+ size_t olen = strlen(old);
+ if (olen < 2 || old[olen - 1] != '}') {
+ return; /* not a JSON object — leave untouched */
+ }
+
+ char val[CBM_SZ_32];
+ int vn = snprintf(val, sizeof(val), "%.6f", score);
+ if (vn < 0 || (size_t)vn >= sizeof(val)) {
+ return;
+ }
+
+ const char *k = imp_find_key(old, "importance");
+ if (k) {
+ const char *colon = strchr(k, ':');
+ if (!colon) {
+ return; /* imp_find_key guarantees one, but never trust a raw scan */
+ }
+ const char *v = colon + 1;
+ while (isspace((unsigned char)*v)) {
+ v++;
+ }
+ const char *vend = imp_value_end(v);
+ if (!vend) {
+ return;
+ }
+ size_t head = (size_t)(v - old);
+ size_t tail = strlen(vend);
+ char *neu = malloc(head + (size_t)vn + tail + 1);
+ if (!neu) {
+ return;
+ }
+ memcpy(neu, old, head);
+ memcpy(neu + head, val, (size_t)vn);
+ memcpy(neu + head + (size_t)vn, vend, tail + 1);
+ free(node->properties_json);
+ node->properties_json = neu;
+ return;
+ }
+
+ char frag[CBM_SZ_64];
+ int fn = snprintf(frag, sizeof(frag), "%s\"importance\":%s}", (olen == 2) ? "" : ",", val);
+ if (fn < 0 || (size_t)fn >= sizeof(frag)) {
+ return;
+ }
+ char *neu = malloc(olen - 1 + (size_t)fn + 1);
+ if (!neu) {
+ return;
+ }
+ memcpy(neu, old, olen - 1); /* copy without the trailing '}' */
+ memcpy(neu + olen - 1, frag, (size_t)fn + 1);
+ free(node->properties_json);
+ node->properties_json = neu;
+}
+
+/* ── Distinct-file counting (the linear core) ─────────────────────────── */
+
+typedef struct {
+ CBMHashTable *counts; /* name -> (void *)(intptr_t)(distinct_files + 1) */
+ CBMHashTable *paths; /* scratch file-path set, cleared between names */
+} imp_name_index_t;
+
+/* Distinct files a name is DEFINED in. Counts distinct file paths, not
+ * distinct nodes — two defs of one name in the same file count once.
+ * Computed once per distinct name; every later node in the group is a hash
+ * hit. Name keys are borrowed from the gbuf nodes, which outlive the pass. */
+static int imp_distinct_file_count(const cbm_gbuf_t *gb, const char *name, imp_name_index_t *ix) {
+ if (!name || !*name || !ix || !ix->counts) {
+ return 0;
+ }
+ void *cached = cbm_ht_get(ix->counts, name);
+ if (cached) {
+ return (int)((intptr_t)cached - 1);
+ }
+
+ const cbm_gbuf_node_t **nodes = NULL;
+ int count = 0;
+ int distinct = 0;
+ if (cbm_gbuf_find_by_name(gb, name, &nodes, &count) == 0 && count > 0) {
+ atomic_fetch_add_explicit(&g_importance_name_visits, (uint64_t)count, memory_order_relaxed);
+ if (ix->paths) {
+ cbm_ht_clear(ix->paths);
+ for (int i = 0; i < count; i++) {
+ const char *fp = nodes[i]->file_path;
+ if (!fp || cbm_ht_has(ix->paths, fp)) {
+ continue;
+ }
+ cbm_ht_set(ix->paths, fp, (void *)(intptr_t)1);
+ distinct++;
+ }
+ } else {
+ /* Scratch set unavailable (allocation failure): the generic-name
+ * multiplier degrades to "never generic" rather than falling back
+ * to a pairwise scan — a silent quadratic is worse than a missing
+ * 0.1 multiplier. */
+ distinct = 0;
+ }
+ }
+ cbm_ht_set(ix->counts, name, (void *)(intptr_t)(distinct + 1));
+ return distinct;
+}
+
+static int incoming_edge_count(const cbm_gbuf_t *gb, int64_t id, const char *type) {
+ const cbm_gbuf_edge_t **edges = NULL;
+ int ne = 0;
+ if (cbm_gbuf_find_edges_by_target_type(gb, id, type, &edges, &ne) != 0) {
+ return 0;
+ }
+ return ne;
+}
+
+void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) {
+ if (!ctx || !ctx->gbuf) {
+ return;
+ }
+ cbm_gbuf_t *gb = ctx->gbuf;
+
+ /* Modest reservations, deliberately not sized from node_count: the memo
+ * holds one entry per DISTINCT name, far fewer than nodes, and reserving
+ * buckets for every node would cost hundreds of MB of transient peak on a
+ * multi-million-node graph to save a handful of amortised rehashes. */
+ imp_name_index_t ix = {
+ .counts = cbm_ht_create(CBM_SZ_4K),
+ .paths = cbm_ht_create(CBM_SZ_256),
+ };
+ if (!ix.counts) {
+ cbm_ht_free(ix.paths);
+ cbm_log_error("pass.importance", "msg", "name_index_alloc_failed");
+ return;
+ }
+
+ uint64_t updated = 0;
+ for (int li = 0; li < CBM_IMPORTANCE_LABEL_COUNT; li++) {
+ const cbm_gbuf_node_t **nodes = NULL;
+ int count = 0;
+ if (cbm_gbuf_find_by_label(gb, CBM_IMPORTANCE_LABELS[li], &nodes, &count) != 0) {
+ continue;
+ }
+ for (int i = 0; i < count; i++) {
+ cbm_gbuf_node_t *n = (cbm_gbuf_node_t *)nodes[i];
+
+ int num_refs =
+ incoming_edge_count(gb, n->id, "CALLS") + incoming_edge_count(gb, n->id, "USAGE");
+ double score = sqrt((double)num_refs);
+
+ if (name_is_private(n->name)) {
+ score *= CBM_IMPORTANCE_PRIV_MUL;
+ }
+ if (imp_distinct_file_count(gb, n->name, &ix) >= CBM_IMPORTANCE_GENERIC_MIN_FILES) {
+ score *= CBM_IMPORTANCE_GENERIC_MUL;
+ }
+ if (name_is_distinctive(n->name)) {
+ score *= CBM_IMPORTANCE_DISTINCT_MUL;
+ }
+ if (cbm_is_test_path(n->file_path) || incoming_edge_count(gb, n->id, "TESTS") > 0) {
+ score *= CBM_IMPORTANCE_TEST_MUL;
+ }
+
+ cbm_pipeline_importance_append_prop(n, score);
+ updated++;
+ }
+ }
+
+ cbm_ht_free(ix.paths);
+ cbm_ht_free(ix.counts);
+ atomic_fetch_add_explicit(&g_importance_nodes, updated, memory_order_relaxed);
+ cbm_log_info("pass.importance", "symbols", itoa_imp(updated));
+}
diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c
index 0c1d5eee4..091d8c665 100644
--- a/src/pipeline/pipeline.c
+++ b/src/pipeline/pipeline.c
@@ -946,6 +946,9 @@ static void predump_complexity(cbm_pipeline_ctx_t *ctx) {
static void predump_ensemble(cbm_pipeline_ctx_t *ctx) {
cbm_pipeline_pass_ensemble_routing(ctx);
}
+static void predump_importance(cbm_pipeline_ctx_t *ctx) {
+ cbm_pipeline_pass_importance(ctx);
+}
static void run_predump_passes(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) {
static const struct {
@@ -953,12 +956,23 @@ static void run_predump_passes(cbm_pipeline_t *p, cbm_pipeline_ctx_t *ctx) {
const char *name;
bool moderate_only; /* true = skip in fast mode */
} passes[] = {
- {predump_deco, "decorator_tags", false}, {predump_cfg, "configlink", false},
- {predump_route, "route_match", false}, {predump_ensemble, "ensemble_routing", false},
- {predump_sim, "similarity", true}, {predump_sem, "semantic_edges", true},
+ {predump_deco, "decorator_tags", false},
+ {predump_cfg, "configlink", false},
+ {predump_route, "route_match", false},
+ {predump_ensemble, "ensemble_routing", false},
+ {predump_sim, "similarity", true},
+ {predump_sem, "semantic_edges", true},
{predump_complexity, "complexity", false},
+ /* Importance runs LAST: it reads CALLS/USAGE (extraction) and TESTS
+ * (pass_tests, which run_post_extraction runs before this loop), so
+ * every edge type its score depends on already exists here. */
+ {predump_importance, "importance", false},
};
- enum { PREDUMP_PASS_COUNT = 7 };
+ /* Derived from the table, never hand-written. A hand-written count that
+ * lags a newly appended entry silently skips the LAST pass while every
+ * test stays green — exactly the failure this expression makes
+ * impossible. */
+ enum { PREDUMP_PASS_COUNT = (int)(sizeof(passes) / sizeof(passes[0])) };
struct timespec t;
for (int i = 0; i < PREDUMP_PASS_COUNT && !check_cancel(p); i++) {
/* "moderate_only" passes (similarity/semantic edges) run in FULL,
diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c
index fca1c8f06..5de6bfd6a 100644
--- a/src/pipeline/pipeline_incremental.c
+++ b/src/pipeline/pipeline_incremental.c
@@ -1456,6 +1456,20 @@ static int run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_file
return rc < 0 ? rc : CBM_NOT_FOUND;
}
}
+
+ /* Importance last, in EVERY mode — same ordering rule as the full path:
+ * it reads CALLS/USAGE (re-extraction, above) and TESTS (pass_tests, first
+ * in this function). The graph here is the rehydrated whole-project buffer,
+ * so nodes outside the changed set already carry an "importance" key from
+ * the previous run; cbm_pipeline_importance_append_prop overwrites it in
+ * place rather than appending a duplicate. */
+ cbm_clock_gettime(CLOCK_MONOTONIC, &t);
+ cbm_pipeline_pass_importance(ctx);
+ cbm_log_info("pass.timing", "pass", "incr_importance", "elapsed_ms",
+ itoa_buf((int)elapsed_ms(t)));
+ if (cbm_pipeline_check_cancel(ctx)) {
+ return CBM_NOT_FOUND;
+ }
return 0;
}
/* Publish the test-only legacy partial result through the same atomic
diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h
index 754fa4ffa..a25573733 100644
--- a/src/pipeline/pipeline_internal.h
+++ b/src/pipeline/pipeline_internal.h
@@ -630,6 +630,31 @@ int cbm_pipeline_pass_semantic_edges(cbm_pipeline_ctx_t *ctx);
* cycles (recursive). Runs on the graph buffer before the dump. */
void cbm_pipeline_pass_complexity(cbm_pipeline_ctx_t *ctx);
+/* Pre-dump pass: per-symbol importance score (weighted degree).
+ * importance = sqrt(num_refs) * priv * generic * distinct * test_penalty
+ * Stored as a numeric "importance" key inside the node's EXISTING
+ * properties_json — no schema change and no index-format bump; indexes written
+ * by older builds simply lack the key and consumers must tolerate its absence.
+ * MUST run after pass_tests and after CALLS/USAGE extraction — a pass ordered
+ * earlier would see zero TESTS edges and num_refs = 0 everywhere. It is
+ * therefore registered last in run_predump_passes and last in the incremental
+ * post-pass sequence. */
+void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx);
+
+/* Write the numeric "importance" key into a node's properties JSON object.
+ * IDEMPOTENT: an existing key is overwritten in place, never appended twice —
+ * the incremental path re-scores nodes rehydrated from the store that already
+ * carry the key. Non-static so the idempotence contract is directly testable. */
+void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score);
+
+/* Work counters for the importance pass (pass_importance.c). Deltas are read
+ * by the complexity suite's linearity gate; never reset by the pass itself, so
+ * nested/repeated runs compose. g_importance_name_visits counts same-name-group
+ * member visits during distinct-file counting — the quantity that goes
+ * superlinear if the per-distinct-name memoization is ever lost. */
+extern _Atomic uint64_t g_importance_nodes;
+extern _Atomic uint64_t g_importance_name_visits;
+
/* ── Env URL scanner (pass_envscan.c) ────────────────────────────── */
typedef struct {
diff --git a/tests/test_complexity.c b/tests/test_complexity.c
index 1fe68a71e..d9c651ff9 100644
--- a/tests/test_complexity.c
+++ b/tests/test_complexity.c
@@ -353,6 +353,8 @@ typedef struct {
uint64_t tail_lookups;
uint64_t tail_candidates;
uint64_t fallback_rows;
+ uint64_t imp_nodes; /* symbols scored by the importance pass */
+ uint64_t imp_name_visits; /* same-name-group members visited by that pass */
double wall_s;
} CxMetrics;
@@ -373,6 +375,8 @@ static int cx_run(const char *root, const char *db_path, CxMetrics *out) {
uint64_t tl0 = atomic_load_explicit(&g_lsp_tail_lookups, memory_order_relaxed);
uint64_t tc0 = atomic_load_explicit(&g_lsp_tail_candidates, memory_order_relaxed);
uint64_t fb0 = cbm_pp_lsp_linear_fallback_rows();
+ uint64_t in0 = atomic_load_explicit(&g_importance_nodes, memory_order_relaxed);
+ uint64_t iv0 = atomic_load_explicit(&g_importance_name_visits, memory_order_relaxed);
atomic_store_explicit(&g_cx_pass_count, 0, memory_order_relaxed);
cbm_log_set_sink_ex(cx_pass_sink, CBM_LOG_SINK_TEE);
@@ -407,6 +411,9 @@ static int cx_run(const char *root, const char *db_path, CxMetrics *out) {
out->tail_lookups = atomic_load_explicit(&g_lsp_tail_lookups, memory_order_relaxed) - tl0;
out->tail_candidates = atomic_load_explicit(&g_lsp_tail_candidates, memory_order_relaxed) - tc0;
out->fallback_rows = cbm_pp_lsp_linear_fallback_rows() - fb0;
+ out->imp_nodes = atomic_load_explicit(&g_importance_nodes, memory_order_relaxed) - in0;
+ out->imp_name_visits =
+ atomic_load_explicit(&g_importance_name_visits, memory_order_relaxed) - iv0;
cbm_store_t *s = cbm_store_open_path(db_path);
if (!s) {
@@ -717,9 +724,46 @@ TEST(complexity_throughput_report_written) {
PASS();
}
+/* Importance scoring must stay linear in the graph. Its generic-name
+ * multiplier needs |{files a name is defined in}|; computing that per NODE
+ * instead of once per distinct NAME makes a same-name group of size k cost
+ * O(k^3) — measured on a large Java corpus as a multi-minute cost for a
+ * handful of names. Under replication the same-name groups grow with the copy
+ * count, so the per-node shape lands at ~4x per doubling here while the
+ * memoized shape stays at ~2x. Gated on the counter ratio, never on wall time
+ * (O9). This counter is GATED, not merely recorded: the whole point of the
+ * work is that this quantity must not go superlinear. */
+TEST(complexity_importance_scoring_is_linear) {
+ if (cx_measure_pair() != 0) {
+ FAIL("failed to build/run the complexity corpus pair");
+ }
+ const CxMetrics *a = &g_cx_base;
+ const CxMetrics *b = &g_cx_doubled;
+
+ printf(" imp_nodes %llu -> %llu imp_name_visits %llu -> %llu\n",
+ (unsigned long long)a->imp_nodes, (unsigned long long)b->imp_nodes,
+ (unsigned long long)a->imp_name_visits, (unsigned long long)b->imp_name_visits);
+
+ /* Non-vacuous, twice over: the pass must have run at all (a miscounted
+ * PREDUMP_PASS_COUNT would silently skip it and zero both counters), and
+ * the base leg must have produced enough work for a ratio to mean
+ * anything. A zero here is a wiring defect to fix, never a pass. */
+ ASSERT_GT((long long)a->imp_nodes, 0);
+ ASSERT_GT((long long)a->imp_name_visits, (long long)CX_MIN_BASE_WORK);
+
+ double node_r = cx_ratio((double)b->imp_nodes, (double)a->imp_nodes);
+ double visit_r = cx_ratio((double)b->imp_name_visits, (double)a->imp_name_visits);
+ printf(" imp_nodes ratio %.2f imp_name_visits ratio %.2f (linear ~2, per-node ~4)\n",
+ node_r, visit_r);
+ ASSERT_TRUE(node_r >= CX_RATIO_LO && node_r <= CX_RATIO_HI);
+ ASSERT_TRUE(visit_r <= CX_RATIO_HI);
+ PASS();
+}
+
SUITE(complexity) {
RUN_TEST(complexity_replicated_modules_scale_linearly);
RUN_TEST(complexity_perfile_registry_work_is_linear);
+ RUN_TEST(complexity_importance_scoring_is_linear);
RUN_TEST(complexity_shared_package_growth_stays_linear);
RUN_TEST(complexity_throughput_report_written);
}
diff --git a/tests/test_importance.c b/tests/test_importance.c
new file mode 100644
index 000000000..9452a49e9
--- /dev/null
+++ b/tests/test_importance.c
@@ -0,0 +1,641 @@
+/*
+ * test_importance.c — index-time per-symbol importance score (pass_importance.c).
+ *
+ * Formula under test:
+ * importance = sqrt(num_refs) * priv * generic * distinct * test_penalty
+ *
+ * Three of these tests exist to catch failure modes that are SILENT — they
+ * produce a wrong index with a fully green build:
+ *
+ * - importance_pass_actually_runs_in_full_pipeline
+ * binds the predump registration. A hand-written PREDUMP_PASS_COUNT that
+ * lags the pass table skips the LAST-registered pass and nothing else
+ * notices. This test fails loudly if the score never lands.
+ * - importance_rehydrated_node_keeps_exactly_one_key
+ * - importance_second_index_run_keeps_exactly_one_key
+ * bind the idempotent write-back. The incremental path rehydrates nodes
+ * that already carry "importance"; a plain append yields
+ * {"importance":1.0,...,"importance":2.0} — property corruption, not a
+ * build failure.
+ * - importance_distinct_file_count_is_linear_in_group_size
+ * binds the cost shape: the distinct-file count must be computed once
+ * per distinct NAME, not once per node. The per-node shape is O(k^3) in
+ * a same-name group and is measurably disqualifying on real corpora.
+ */
+#include "test_framework.h"
+#include "test_helpers.h"
+
+#include "pipeline/pipeline.h"
+#include "pipeline/pipeline_internal.h"
+#include "graph_buffer/graph_buffer.h"
+#include "store/store.h"
+#include "foundation/compat.h"
+#include "foundation/log.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+/* ── Shared helpers ───────────────────────────────────────────────────── */
+
+/* Parse the numeric value of "importance" out of a properties_json blob.
+ * Returns a sentinel (-999.0) when the key is absent — callers that care about
+ * presence assert on imp_key_count() instead. */
+static double imp_value(const char *json) {
+ if (!json) {
+ return -999.0;
+ }
+ const char *p = strstr(json, "\"importance\":");
+ if (!p) {
+ return -999.0;
+ }
+ return strtod(p + strlen("\"importance\":"), NULL);
+}
+
+/* How many times the key appears. The duplicate-key corruption this suite
+ * guards against shows up here as 2. */
+static int imp_key_count(const char *json) {
+ if (!json) {
+ return 0;
+ }
+ int n = 0;
+ const char *p = json;
+ while ((p = strstr(p, "\"importance\":")) != NULL) {
+ n++;
+ p += strlen("\"importance\":");
+ }
+ return n;
+}
+
+static void imp_run_pass(cbm_gbuf_t *gb) {
+ atomic_int cancelled = 0;
+ cbm_pipeline_ctx_t ctx = {
+ .project_name = "test-proj",
+ .repo_path = "/tmp/test",
+ .gbuf = gb,
+ .cancelled = &cancelled,
+ };
+ cbm_pipeline_pass_importance(&ctx);
+}
+
+/* Every stored node of the given label must carry exactly `expect` copies of
+ * the key. Returns the number of nodes checked, or -1 on a store error. */
+static int imp_check_label(cbm_store_t *s, const char *project, const char *label, int expect) {
+ cbm_node_t *nodes = NULL;
+ int count = 0;
+ if (cbm_store_find_nodes_by_label(s, project, label, &nodes, &count) != CBM_STORE_OK) {
+ return -1;
+ }
+ int bad = 0;
+ for (int i = 0; i < count; i++) {
+ if (imp_key_count(nodes[i].properties_json) != expect) {
+ bad++;
+ printf(" %s node '%s' props=%s\n", label, nodes[i].name ? nodes[i].name : "?",
+ nodes[i].properties_json ? nodes[i].properties_json : "(null)");
+ }
+ }
+ cbm_store_free_nodes(nodes, count);
+ return bad == 0 ? count : -1;
+}
+
+/* ── Registration: the pass must actually execute ─────────────────────── */
+
+/* A miscounted PREDUMP_PASS_COUNT silently drops the last-registered pass.
+ * The score is the only observable, so assert on the score. */
+TEST(importance_pass_actually_runs_in_full_pipeline) {
+ char *tmp = th_mktempdir("cbm_imp_run");
+ if (!tmp) {
+ FAIL("tmpdir");
+ }
+ char root[512];
+ snprintf(root, sizeof(root), "%s", tmp); /* th_mktempdir returns a static buffer */
+
+ ASSERT_EQ(th_write_file(TH_PATH(root, "main.py"),
+ "def helper():\n return 1\n\n"
+ "def caller():\n return helper() + helper()\n\n"
+ "class Widget:\n def render(self):\n return helper()\n"),
+ 0);
+
+ char db_path[600];
+ snprintf(db_path, sizeof(db_path), "%s/graph.db", root);
+ cbm_pipeline_t *p = cbm_pipeline_new(root, db_path, CBM_MODE_FULL);
+ ASSERT_NOT_NULL(p);
+ ASSERT_EQ(cbm_pipeline_run(p), 0);
+ char project[256];
+ snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p));
+ cbm_pipeline_free(p);
+
+ cbm_store_t *s = cbm_store_open_path(db_path);
+ ASSERT_NOT_NULL(s);
+
+ /* Non-vacuous: the corpus really produced symbols of each kind. */
+ int fc = imp_check_label(s, project, "Function", 1);
+ int mc = imp_check_label(s, project, "Method", 1);
+ int cc = imp_check_label(s, project, "Class", 1);
+ printf(" scored Function=%d Method=%d Class=%d\n", fc, mc, cc);
+ ASSERT_GT(fc, 0);
+ ASSERT_GT(mc, 0);
+ ASSERT_GT(cc, 0);
+
+ /* File nodes are deliberately excluded from scoring. */
+ cbm_node_t *files = NULL;
+ int filec = 0;
+ ASSERT_EQ(cbm_store_find_nodes_by_label(s, project, "File", &files, &filec), CBM_STORE_OK);
+ for (int i = 0; i < filec; i++) {
+ ASSERT_EQ(imp_key_count(files[i].properties_json), 0);
+ }
+ cbm_store_free_nodes(files, filec);
+
+ cbm_store_close(s);
+ th_rmtree(root);
+ PASS();
+}
+
+/* ── Formula ──────────────────────────────────────────────────────────── */
+
+TEST(importance_base_is_sqrt_of_incoming_refs) {
+ cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test");
+ ASSERT_NOT_NULL(gb);
+
+ int64_t target =
+ cbm_gbuf_upsert_node(gb, "Function", "target", "pkg.target", "pkg/main.go", 1, 1, "{}");
+ int64_t c1 = cbm_gbuf_upsert_node(gb, "Function", "c1", "pkg.c1", "pkg/main.go", 2, 2, "{}");
+ int64_t c2 = cbm_gbuf_upsert_node(gb, "Function", "c2", "pkg.c2", "pkg/main.go", 3, 3, "{}");
+ int64_t c3 = cbm_gbuf_upsert_node(gb, "Function", "c3", "pkg.c3", "pkg/main.go", 4, 4, "{}");
+ int64_t lonely =
+ cbm_gbuf_upsert_node(gb, "Function", "lonely", "pkg.lonely", "pkg/main.go", 5, 5, "{}");
+ ASSERT_GT(target, 0);
+ ASSERT_GT(lonely, 0);
+
+ cbm_gbuf_insert_edge(gb, c1, target, "CALLS", "{}");
+ cbm_gbuf_insert_edge(gb, c2, target, "CALLS", "{}");
+ cbm_gbuf_insert_edge(gb, c3, target, "USAGE", "{}"); /* spans BOTH edge types */
+
+ imp_run_pass(gb);
+
+ const cbm_gbuf_node_t *tn = cbm_gbuf_find_by_id(gb, target);
+ ASSERT_NOT_NULL(tn);
+ ASSERT_EQ(imp_key_count(tn->properties_json), 1);
+ ASSERT_FLOAT_EQ(imp_value(tn->properties_json), sqrt(3.0), 1e-6);
+
+ /* num_refs == 0 -> sqrt(0) == 0, still written. */
+ const cbm_gbuf_node_t *ln = cbm_gbuf_find_by_id(gb, lonely);
+ ASSERT_NOT_NULL(ln);
+ ASSERT_EQ(imp_key_count(ln->properties_json), 1);
+ ASSERT_FLOAT_EQ(imp_value(ln->properties_json), 0.0, 1e-9);
+
+ cbm_gbuf_free(gb);
+ PASS();
+}
+
+TEST(importance_private_names_are_demoted) {
+ cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test");
+ ASSERT_NOT_NULL(gb);
+
+ int64_t priv =
+ cbm_gbuf_upsert_node(gb, "Function", "_run", "pkg._run", "pkg/main.go", 1, 1, "{}");
+ int64_t pub = cbm_gbuf_upsert_node(gb, "Function", "run", "pkg.run", "pkg/main.go", 2, 2, "{}");
+ for (int i = 0; i < 4; i++) {
+ char name[CBM_SZ_32];
+ char qn[CBM_SZ_64];
+ snprintf(name, sizeof(name), "p%d", i);
+ snprintf(qn, sizeof(qn), "pkg.p%d", i);
+ int64_t c =
+ cbm_gbuf_upsert_node(gb, "Function", name, qn, "pkg/main.go", 10 + i, 10 + i, "{}");
+ cbm_gbuf_insert_edge(gb, c, priv, "CALLS", "{}");
+ snprintf(name, sizeof(name), "q%d", i);
+ snprintf(qn, sizeof(qn), "pkg.q%d", i);
+ c = cbm_gbuf_upsert_node(gb, "Function", name, qn, "pkg/main.go", 20 + i, 20 + i, "{}");
+ cbm_gbuf_insert_edge(gb, c, pub, "CALLS", "{}");
+ }
+
+ imp_run_pass(gb);
+
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, priv)->properties_json), sqrt(4.0) * 0.1,
+ 1e-6);
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, pub)->properties_json), sqrt(4.0), 1e-6);
+
+ cbm_gbuf_free(gb);
+ PASS();
+}
+
+/* Generic = defined in >= 5 DISTINCT FILES. Two defs of one name in the same
+ * file count once, so the boundary is tested on files, not on node count. */
+TEST(importance_generic_names_are_demoted_on_distinct_files) {
+ cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test");
+ ASSERT_NOT_NULL(gb);
+
+ /* "generic": 5 distinct files -> demoted. */
+ int64_t generic = 0;
+ for (int i = 0; i < 5; i++) {
+ char qn[CBM_SZ_64];
+ char file[CBM_SZ_64];
+ snprintf(qn, sizeof(qn), "pkg%d.generic", i);
+ snprintf(file, sizeof(file), "pkg%d/main.go", i);
+ int64_t id = cbm_gbuf_upsert_node(gb, "Function", "generic", qn, file, 1, 1, "{}");
+ if (i == 0) {
+ generic = id;
+ }
+ }
+ /* "narrow": 4 distinct files, but 6 nodes — two duplicates share a file,
+ * so the DISTINCT count is 4 and it stays undemoted. */
+ int64_t narrow = 0;
+ for (int i = 0; i < 4; i++) {
+ char qn[CBM_SZ_64];
+ char file[CBM_SZ_64];
+ snprintf(qn, sizeof(qn), "n%d.narrow", i);
+ snprintf(file, sizeof(file), "n%d/main.go", i);
+ int64_t id = cbm_gbuf_upsert_node(gb, "Function", "narrow", qn, file, 1, 1, "{}");
+ if (i == 0) {
+ narrow = id;
+ }
+ }
+ cbm_gbuf_upsert_node(gb, "Function", "narrow", "n0.narrow_b", "n0/main.go", 9, 9, "{}");
+ cbm_gbuf_upsert_node(gb, "Function", "narrow", "n1.narrow_b", "n1/main.go", 9, 9, "{}");
+
+ for (int i = 0; i < 9; i++) {
+ char name[CBM_SZ_32];
+ char qn[CBM_SZ_64];
+ snprintf(name, sizeof(name), "z%d", i);
+ snprintf(qn, sizeof(qn), "z.z%d", i);
+ int64_t c = cbm_gbuf_upsert_node(gb, "Function", name, qn, "z/main.go", i, i, "{}");
+ cbm_gbuf_insert_edge(gb, c, i < 4 ? generic : narrow, "CALLS", "{}");
+ }
+
+ imp_run_pass(gb);
+
+ /* generic: sqrt(4) * 0.1; narrow: sqrt(5), undemoted. */
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, generic)->properties_json), sqrt(4.0) * 0.1,
+ 1e-6);
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, narrow)->properties_json), sqrt(5.0), 1e-6);
+
+ cbm_gbuf_free(gb);
+ PASS();
+}
+
+TEST(importance_distinctive_names_are_promoted) {
+ cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test");
+ ASSERT_NOT_NULL(gb);
+
+ /* snake_case >= 8, camelCase >= 8, and a plain long lowercase word that is
+ * NOT distinctive (no '_', no hump). */
+ int64_t snake = cbm_gbuf_upsert_node(gb, "Function", "load_user_profile",
+ "pkg.load_user_profile", "pkg/a.go", 1, 1, "{}");
+ int64_t camel = cbm_gbuf_upsert_node(gb, "Function", "loadUserProfile", "pkg.loadUserProfile",
+ "pkg/b.go", 1, 1, "{}");
+ int64_t plain =
+ cbm_gbuf_upsert_node(gb, "Function", "aggregate", "pkg.aggregate", "pkg/c.go", 1, 1, "{}");
+ int64_t shortnm =
+ cbm_gbuf_upsert_node(gb, "Function", "get_x", "pkg.get_x", "pkg/d.go", 1, 1, "{}");
+
+ const int64_t targets[] = {snake, camel, plain, shortnm};
+ for (int t = 0; t < 4; t++) {
+ char name[CBM_SZ_32];
+ char qn[CBM_SZ_64];
+ snprintf(name, sizeof(name), "cc%d", t);
+ snprintf(qn, sizeof(qn), "z.cc%d", t);
+ int64_t c = cbm_gbuf_upsert_node(gb, "Function", name, qn, "z/main.go", t, t, "{}");
+ cbm_gbuf_insert_edge(gb, c, targets[t], "CALLS", "{}");
+ }
+
+ imp_run_pass(gb);
+
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, snake)->properties_json), 1.0 * 10.0, 1e-6);
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, camel)->properties_json), 1.0 * 10.0, 1e-6);
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, plain)->properties_json), 1.0, 1e-6);
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, shortnm)->properties_json), 1.0, 1e-6);
+
+ cbm_gbuf_free(gb);
+ PASS();
+}
+
+/* Test scaffolding is demoted two ways: living in a test file (the graph's own
+ * cbm_is_test_path classifier) or being the target of a TESTS edge. */
+TEST(importance_test_scaffolding_is_demoted) {
+ cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test");
+ ASSERT_NOT_NULL(gb);
+
+ int64_t in_test_file = cbm_gbuf_upsert_node(gb, "Function", "fixture", "t.fixture",
+ "pkg/thing_test.go", 1, 1, "{}");
+ int64_t tests_target =
+ cbm_gbuf_upsert_node(gb, "Function", "helper", "pkg.helper", "pkg/util.go", 1, 1, "{}");
+ int64_t plain =
+ cbm_gbuf_upsert_node(gb, "Function", "worker", "pkg.worker", "pkg/util.go", 2, 2, "{}");
+ /* A production file whose NAME merely contains "test" must NOT be demoted. */
+ int64_t testutil = cbm_gbuf_upsert_node(gb, "Function", "spawn", "pkg.spawn",
+ "pkg/testutil_helpers.go", 1, 1, "{}");
+
+ const int64_t targets[] = {in_test_file, tests_target, plain, testutil};
+ for (int t = 0; t < 4; t++) {
+ char name[CBM_SZ_32];
+ char qn[CBM_SZ_64];
+ snprintf(name, sizeof(name), "d%d", t);
+ snprintf(qn, sizeof(qn), "z.d%d", t);
+ int64_t c = cbm_gbuf_upsert_node(gb, "Function", name, qn, "z/main.go", t, t, "{}");
+ cbm_gbuf_insert_edge(gb, c, targets[t], "CALLS", "{}");
+ }
+ int64_t tfn = cbm_gbuf_upsert_node(gb, "Function", "TestHelper", "t.TestHelper",
+ "pkg/util_test.go", 5, 5, "{}");
+ cbm_gbuf_insert_edge(gb, tfn, tests_target, "TESTS", "{}");
+
+ imp_run_pass(gb);
+
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, in_test_file)->properties_json), 1.0 * 0.1,
+ 1e-6);
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, tests_target)->properties_json), 1.0 * 0.1,
+ 1e-6);
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, plain)->properties_json), 1.0, 1e-6);
+ ASSERT_FLOAT_EQ(imp_value(cbm_gbuf_find_by_id(gb, testutil)->properties_json), 1.0, 1e-6);
+
+ cbm_gbuf_free(gb);
+ PASS();
+}
+
+/* ── Idempotent write-back ────────────────────────────────────────────── */
+
+TEST(importance_append_prop_overwrites_instead_of_duplicating) {
+ cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test");
+ ASSERT_NOT_NULL(gb);
+ int64_t id = cbm_gbuf_upsert_node(gb, "Function", "f", "p.f", "p/a.go", 1, 1,
+ "{\"loop_depth\":2,\"recursive\":false}");
+ cbm_gbuf_node_t *n = (cbm_gbuf_node_t *)cbm_gbuf_find_by_id(gb, id);
+ ASSERT_NOT_NULL(n);
+
+ cbm_pipeline_importance_append_prop(n, 1.5);
+ ASSERT_EQ(imp_key_count(n->properties_json), 1);
+ ASSERT_FLOAT_EQ(imp_value(n->properties_json), 1.5, 1e-6);
+
+ cbm_pipeline_importance_append_prop(n, 42.25);
+ printf(" after second write: %s\n", n->properties_json);
+ ASSERT_EQ(imp_key_count(n->properties_json), 1);
+ ASSERT_FLOAT_EQ(imp_value(n->properties_json), 42.25, 1e-6);
+
+ /* Sibling keys survive the in-place overwrite, on both sides of the key. */
+ ASSERT_TRUE(strstr(n->properties_json, "\"loop_depth\":2") != NULL);
+ ASSERT_TRUE(strstr(n->properties_json, "\"recursive\":false") != NULL);
+
+ /* A key that is NOT last: overwrite must not truncate the tail. */
+ free(n->properties_json);
+ n->properties_json = strdup("{\"importance\":0.000000,\"tail\":7}");
+ ASSERT_NOT_NULL(n->properties_json);
+ cbm_pipeline_importance_append_prop(n, 3.0);
+ ASSERT_EQ(imp_key_count(n->properties_json), 1);
+ ASSERT_FLOAT_EQ(imp_value(n->properties_json), 3.0, 1e-6);
+ ASSERT_TRUE(strstr(n->properties_json, "\"tail\":7") != NULL);
+
+ /* A string VALUE that merely contains the key text is not a key. */
+ free(n->properties_json);
+ n->properties_json = strdup("{\"doc\":\"see \\\"importance\\\": below\"}");
+ ASSERT_NOT_NULL(n->properties_json);
+ cbm_pipeline_importance_append_prop(n, 2.0);
+ ASSERT_FLOAT_EQ(imp_value(n->properties_json), 2.0, 1e-6);
+ ASSERT_TRUE(strstr(n->properties_json, "\"doc\":") != NULL);
+
+ /* Empty object and a non-object blob. */
+ free(n->properties_json);
+ n->properties_json = strdup("{}");
+ ASSERT_NOT_NULL(n->properties_json);
+ cbm_pipeline_importance_append_prop(n, 0.5);
+ ASSERT_STR_EQ(n->properties_json, "{\"importance\":0.500000}");
+
+ free(n->properties_json);
+ n->properties_json = strdup("not json");
+ ASSERT_NOT_NULL(n->properties_json);
+ cbm_pipeline_importance_append_prop(n, 9.0);
+ ASSERT_STR_EQ(n->properties_json, "not json");
+
+ cbm_gbuf_free(gb);
+ PASS();
+}
+
+/* Running the pass twice over the same graph — the shape the incremental path
+ * produces when it re-scores an already-scored rehydrated buffer. */
+TEST(importance_pass_is_idempotent_across_reruns) {
+ cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test");
+ ASSERT_NOT_NULL(gb);
+ int64_t t =
+ cbm_gbuf_upsert_node(gb, "Function", "target", "pkg.target", "pkg/main.go", 1, 1, "{}");
+ int64_t c =
+ cbm_gbuf_upsert_node(gb, "Function", "caller", "pkg.caller", "pkg/main.go", 2, 2, "{}");
+ cbm_gbuf_insert_edge(gb, c, t, "CALLS", "{}");
+
+ imp_run_pass(gb);
+ char first[CBM_SZ_512];
+ snprintf(first, sizeof(first), "%s", cbm_gbuf_find_by_id(gb, t)->properties_json);
+ imp_run_pass(gb);
+ const char *second = cbm_gbuf_find_by_id(gb, t)->properties_json;
+
+ printf(" run1=%s run2=%s\n", first, second);
+ ASSERT_EQ(imp_key_count(second), 1);
+ ASSERT_STR_EQ(first, second); /* re-scoring is a fixed point */
+
+ cbm_gbuf_free(gb);
+ PASS();
+}
+
+/* The literal incremental shape: index, rehydrate the stored graph exactly as
+ * pipeline_incremental.c does, re-run the pass. Nodes arrive already carrying
+ * the key — an append-only write-back yields two. */
+TEST(importance_rehydrated_node_keeps_exactly_one_key) {
+ char *tmp = th_mktempdir("cbm_imp_rehy");
+ if (!tmp) {
+ FAIL("tmpdir");
+ }
+ char root[512];
+ snprintf(root, sizeof(root), "%s", tmp); /* th_mktempdir returns a static buffer */
+
+ ASSERT_EQ(th_write_file(TH_PATH(root, "main.py"),
+ "def helper():\n return 1\n\n"
+ "def caller():\n return helper() + helper()\n"),
+ 0);
+ char db_path[600];
+ snprintf(db_path, sizeof(db_path), "%s/graph.db", root);
+ cbm_pipeline_t *p = cbm_pipeline_new(root, db_path, CBM_MODE_FULL);
+ ASSERT_NOT_NULL(p);
+ ASSERT_EQ(cbm_pipeline_run(p), 0);
+ char project[256];
+ snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p));
+ cbm_pipeline_free(p);
+
+ cbm_gbuf_t *gb = cbm_gbuf_new(project, root);
+ ASSERT_NOT_NULL(gb);
+ ASSERT_EQ(cbm_gbuf_load_from_db(gb, db_path, project), 0);
+
+ /* Precondition: the rehydrated nodes already carry the key. Without this
+ * the test could pass while proving nothing. */
+ const cbm_gbuf_node_t **fns = NULL;
+ int fc = 0;
+ ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Function", &fns, &fc), 0);
+ ASSERT_GT(fc, 0);
+ for (int i = 0; i < fc; i++) {
+ ASSERT_EQ(imp_key_count(fns[i]->properties_json), 1);
+ }
+
+ imp_run_pass(gb);
+
+ ASSERT_EQ(cbm_gbuf_find_by_label(gb, "Function", &fns, &fc), 0);
+ ASSERT_GT(fc, 0);
+ for (int i = 0; i < fc; i++) {
+ if (imp_key_count(fns[i]->properties_json) != 1) {
+ printf(" duplicate key on '%s': %s\n", fns[i]->name, fns[i]->properties_json);
+ }
+ ASSERT_EQ(imp_key_count(fns[i]->properties_json), 1);
+ }
+
+ cbm_gbuf_free(gb);
+ th_rmtree(root);
+ PASS();
+}
+
+/* Pass-timing observer: which importance-pass call sites fired. */
+static atomic_int g_saw_incr_importance = 0;
+static atomic_int g_saw_full_importance = 0;
+static void imp_pass_sink(const char *line) {
+ if (!line || !strstr(line, "pass.timing")) {
+ return;
+ }
+ if (strstr(line, "pass=incr_importance")) {
+ atomic_fetch_add_explicit(&g_saw_incr_importance, 1, memory_order_relaxed);
+ } else if (strstr(line, "pass=importance")) {
+ atomic_fetch_add_explicit(&g_saw_full_importance, 1, memory_order_relaxed);
+ }
+}
+
+/* End-to-end, on the incremental route that actually rehydrates the stored
+ * graph (LEGACY_PARTIAL — production's other incremental route, closure
+ * repair, works on a delta buffer and never sees pre-scored nodes). Index,
+ * edit a file, index again: the run must be observed on that route, the pass
+ * must have run on it, and every stored symbol must carry exactly one key. An
+ * append-only write-back produces two here. */
+TEST(importance_second_index_run_keeps_exactly_one_key) {
+ char *tmp = th_mktempdir("cbm_imp_incr");
+ if (!tmp) {
+ FAIL("tmpdir");
+ }
+ char root[512];
+ snprintf(root, sizeof(root), "%s", tmp); /* th_mktempdir returns a static buffer */
+
+ ASSERT_EQ(th_write_file(TH_PATH(root, "a.py"), "def helper():\n return 1\n"), 0);
+ ASSERT_EQ(
+ th_write_file(TH_PATH(root, "b.py"), "import a\n\ndef caller():\n return a.helper()\n"),
+ 0);
+
+ char db_path[600];
+ snprintf(db_path, sizeof(db_path), "%s/graph.db", root);
+
+ atomic_store(&g_saw_incr_importance, 0);
+ atomic_store(&g_saw_full_importance, 0);
+ cbm_log_set_sink_ex(imp_pass_sink, CBM_LOG_SINK_TEE);
+
+ cbm_pipeline_t *p1 = cbm_pipeline_new(root, db_path, CBM_MODE_FULL);
+ ASSERT_NOT_NULL(p1);
+ ASSERT_EQ(cbm_pipeline_run(p1), 0);
+ char project[256];
+ snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1));
+ cbm_pipeline_free(p1);
+
+ /* Edit one file so the second run has real work but stays incremental. */
+ ASSERT_EQ(th_write_file(TH_PATH(root, "b.py"),
+ "import a\n\ndef caller():\n return a.helper() + a.helper()\n"),
+ 0);
+
+ cbm_pipeline_incremental_test_force_legacy_partial_once();
+ cbm_pipeline_t *p2 = cbm_pipeline_new(root, db_path, CBM_MODE_FULL);
+ ASSERT_NOT_NULL(p2);
+ ASSERT_EQ(cbm_pipeline_run(p2), 0);
+ cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
+ cbm_pipeline_free(p2);
+ cbm_log_set_sink(NULL);
+
+ /* Route proof: without it a silent fall-back to a full reindex would make
+ * the duplicate-key assertion below vacuous. */
+ ASSERT_EQ((int)route, (int)CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL);
+
+ int incr = atomic_load(&g_saw_incr_importance);
+ int full = atomic_load(&g_saw_full_importance);
+ printf(" importance pass runs: full=%d incremental=%d\n", full, incr);
+ ASSERT_GT(full, 0); /* first run scored via the predump table */
+ /* The second run must have re-scored too — through whichever path it took.
+ * If it was NOT incremental the duplicate-key trap is untested, so say so
+ * rather than passing quietly. */
+ if (incr == 0) {
+ th_rmtree(root);
+ FAIL("second run did not re-score on the incremental path — trap untested");
+ }
+
+ cbm_store_t *s = cbm_store_open_path(db_path);
+ ASSERT_NOT_NULL(s);
+ int fc = imp_check_label(s, project, "Function", 1);
+ printf(" functions with exactly one importance key: %d\n", fc);
+ ASSERT_GT(fc, 0);
+ cbm_store_close(s);
+
+ th_rmtree(root);
+ PASS();
+}
+
+/* ── Cost shape ───────────────────────────────────────────────────────── */
+
+/* The distinct-file count must be computed once per distinct NAME. Computing
+ * it per NODE makes a same-name group of size k cost O(k^3) — measurably
+ * disqualifying on real corpora. Assert on the pass's own work counter, not
+ * on wall time: doubling a same-name group must roughly double the work. */
+TEST(importance_distinct_file_count_is_linear_in_group_size) {
+ enum { K = 200 };
+
+ uint64_t visits[2];
+ for (int leg = 0; leg < 2; leg++) {
+ int k = (leg == 0) ? K : 2 * K;
+ cbm_gbuf_t *gb = cbm_gbuf_new("test-proj", "/tmp/test");
+ ASSERT_NOT_NULL(gb);
+ for (int i = 0; i < k; i++) {
+ char qn[CBM_SZ_64];
+ char file[CBM_SZ_64];
+ snprintf(qn, sizeof(qn), "pkg%d.toString", i);
+ snprintf(file, sizeof(file), "pkg%d/a.java", i);
+ ASSERT_GT(cbm_gbuf_upsert_node(gb, "Method", "toString", qn, file, 1, 1, "{}"), 0);
+ }
+ uint64_t v0 = atomic_load_explicit(&g_importance_name_visits, memory_order_relaxed);
+ imp_run_pass(gb);
+ visits[leg] = atomic_load_explicit(&g_importance_name_visits, memory_order_relaxed) - v0;
+ cbm_gbuf_free(gb);
+ }
+
+ double ratio = (visits[0] > 0) ? (double)visits[1] / (double)visits[0] : 0.0;
+ printf(" name visits k=%d -> %llu, k=%d -> %llu (ratio %.2f; linear ~2, per-node ~4)\n", K,
+ (unsigned long long)visits[0], 2 * K, (unsigned long long)visits[1], ratio);
+
+ /* Non-vacuous: the group really was scanned. */
+ ASSERT_GT((long long)visits[0], 0);
+ /* Memoized: one scan of the whole group, so visits == k exactly. */
+ ASSERT_EQ((long long)visits[0], (long long)K);
+ ASSERT_EQ((long long)visits[1], (long long)(2 * K));
+ ASSERT_TRUE(ratio <= 2.75);
+ PASS();
+}
+
+/* ── Suite ────────────────────────────────────────────────────────────── */
+
+SUITE(importance) {
+ /* Registration — catches a miscounted PREDUMP_PASS_COUNT. */
+ RUN_TEST(importance_pass_actually_runs_in_full_pipeline);
+
+ /* Formula. */
+ RUN_TEST(importance_base_is_sqrt_of_incoming_refs);
+ RUN_TEST(importance_private_names_are_demoted);
+ RUN_TEST(importance_generic_names_are_demoted_on_distinct_files);
+ RUN_TEST(importance_distinctive_names_are_promoted);
+ RUN_TEST(importance_test_scaffolding_is_demoted);
+
+ /* Idempotent write-back — the incremental duplicate-key trap. */
+ RUN_TEST(importance_append_prop_overwrites_instead_of_duplicating);
+ RUN_TEST(importance_pass_is_idempotent_across_reruns);
+ RUN_TEST(importance_rehydrated_node_keeps_exactly_one_key);
+ RUN_TEST(importance_second_index_run_keeps_exactly_one_key);
+
+ /* Cost shape. */
+ RUN_TEST(importance_distinct_file_count_is_linear_in_group_size);
+}
diff --git a/tests/test_main.c b/tests/test_main.c
index 0e478ccad..2d57f5468 100644
--- a/tests/test_main.c
+++ b/tests/test_main.c
@@ -768,6 +768,7 @@ extern void suite_discover(void);
extern void suite_graph_buffer(void);
extern void suite_registry(void);
extern void suite_pipeline(void);
+extern void suite_importance(void);
extern void suite_pipeline_semantic_manifest_repro(void);
extern void suite_cross_repo(void);
extern void suite_index_resilience(void);
@@ -1041,6 +1042,7 @@ int main(int argc, char **argv) {
/* Pipeline (M8) */
RUN_SELECTED_SUITE(registry);
RUN_SELECTED_SUITE(pipeline);
+ RUN_SELECTED_SUITE(importance);
RUN_SELECTED_SUITE(index_format);
RUN_SELECTED_SUITE(pipeline_semantic_manifest_repro);
RUN_SELECTED_SUITE(call_reference_contract);
From 396ac348b5abe0e1a7c6cc7bb9d604e8ee76cefc Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Fri, 28 Aug 2026 20:56:33 +0200
Subject: [PATCH 11/31] fix(pipeline): score importance over the staging store
on the delta route
The closure-delta incremental route scored importance off a graph that does
not contain the project's edges, and persisted the result.
cbm_delta_preseed fills that route's gbuf with PROXY nodes -- id, label, name,
qualified name, file path, and nothing else. Only the re-extracted files carry
real edges; every inbound edge from an unchanged file is snapshotted and
re-linked later, inside cbm_delta_patch. Scoring in run_postpasses therefore
read an in-degree of zero for symbols with hundreds of real callers, and
because the repaired files' rows are purged and re-inserted, cbm_delta_patch
wrote those zeros to disk. Measured on a fixture whose `helper` has 25
callers: full index 5.000000, after one closure-delta re-index 0.000000.
closure_try_plan runs first, so this is the preferred warm path, not an edge
case. The comment at the call site claimed a rehydrated whole-project buffer;
that is true only of the legacy-partial route, and it is now stated correctly.
The fix rescores in SQL over the staging store, immediately after
cbm_delta_patch has inserted the new nodes and re-linked the inbound edges --
the first moment on this route where the complete graph is queryable. Loading
the full graph into RAM instead would have given back the point of the delta
route.
Rescoring is project-wide, not limited to the changed files. Distinct-files-
per-name and in-degree are both GLOBAL inputs: a body-only edit in one file
can change the score of a symbol defined in a file that did not change. A
scoped rescore would leave those stale.
TWO ROUTES, ONE RULE. The scoring rule and the JSON write-back are not
reimplemented in SQL. cbm_pipeline_importance_score() and
cbm_pipeline_importance_set_prop() are now single definitions that both routes
call -- the SQL side reaches them through two SQLite user-defined functions,
so the same C code runs either way and the rule cannot drift. SQL contributes
only the three aggregates, each an indexed GROUP BY. What remains free is the
input GATHERING, and tests bind that directly: a full index and a closure-delta
incremental index converging on the same tree must produce identical scores for
every symbol. Both new tests assert the route was actually taken, so neither
can pass by silently running some other path.
Cost, measured on a 666 MB Java corpus: cold full index unchanged at 83 s
(the recompute does not run on that path); warm closure-delta re-index 20 s
without the recompute, 26 s with it. The delta route keeps its advantage over
a full reindex (26 s vs 83 s).
Signed-off-by: Martin Vogel
---
src/pipeline/pass_importance.c | 242 ++++++++++++++++++++-----
src/pipeline/pipeline_incremental.c | 57 ++++--
src/pipeline/pipeline_internal.h | 38 +++-
tests/test_importance.c | 270 ++++++++++++++++++++++++++++
4 files changed, 548 insertions(+), 59 deletions(-)
diff --git a/src/pipeline/pass_importance.c b/src/pipeline/pass_importance.c
index a727ddee0..c544d0a10 100644
--- a/src/pipeline/pass_importance.c
+++ b/src/pipeline/pass_importance.c
@@ -60,7 +60,9 @@
#include "foundation/hash_table.h"
#include "foundation/log.h"
#include "foundation/compat.h"
+#include "store/store.h"
#include "cbm.h"
+#include "sqlite3.h"
#include
#include
@@ -75,6 +77,7 @@
* Never reset here: callers snapshot before/after so nested runs compose. */
_Atomic uint64_t g_importance_nodes = 0;
_Atomic uint64_t g_importance_name_visits = 0;
+_Atomic uint64_t g_importance_store_rows = 0;
enum {
CBM_IMPORTANCE_GENERIC_MIN_FILES = 5, /* name defined in >= N files -> generic */
@@ -212,37 +215,39 @@ static const char *imp_find_key(const char *json, const char *key) {
return NULL;
}
-/* Write the numeric "importance" key into a node's properties JSON object.
+/* Produce a copy of `json` carrying the numeric "importance" key set to
+ * `score`, or NULL if `json` is not a JSON object (caller leaves it alone) or
+ * on allocation failure. Caller owns the result.
*
- * IDEMPOTENT BY CONTRACT. The incremental path rehydrates nodes straight out
- * of the store (cbm_gbuf_load_from_db), and those nodes ALREADY carry an
- * "importance" key from the previous run. A pure append would emit
- * {"importance":1.0,...,"importance":2.0} — silent property corruption that
- * no build or schema check would catch. So: overwrite an existing key in
- * place, append only when the key is genuinely absent.
+ * IDEMPOTENT BY CONTRACT. Nodes reach this function already carrying an
+ * "importance" key on every re-scoring route: the legacy-partial incremental
+ * path rehydrates them via cbm_gbuf_load_from_db, and the store-level
+ * recompute reads them straight out of SQLite. A pure append would emit
+ * {"importance":1.0,...,"importance":2.0} — silent property corruption that no
+ * build or schema check would catch. So: overwrite an existing key in place,
+ * append only when the key is genuinely absent.
*
- * A properties_json that is not a JSON object is left untouched. */
-void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score) {
- if (!node) {
- return;
- }
- const char *old = node->properties_json ? node->properties_json : "{}";
+ * SINGLE DEFINITION. Both scoring routes (the in-memory gbuf pass and the
+ * SQL-level store recompute) write through this one function, so the JSON
+ * shape cannot drift between them. */
+char *cbm_pipeline_importance_set_prop(const char *json, double score) {
+ const char *old = json ? json : "{}";
size_t olen = strlen(old);
if (olen < 2 || old[olen - 1] != '}') {
- return; /* not a JSON object — leave untouched */
+ return NULL; /* not a JSON object — leave untouched */
}
char val[CBM_SZ_32];
int vn = snprintf(val, sizeof(val), "%.6f", score);
if (vn < 0 || (size_t)vn >= sizeof(val)) {
- return;
+ return NULL;
}
const char *k = imp_find_key(old, "importance");
if (k) {
const char *colon = strchr(k, ':');
if (!colon) {
- return; /* imp_find_key guarantees one, but never trust a raw scan */
+ return NULL; /* imp_find_key guarantees one, but never trust a raw scan */
}
const char *v = colon + 1;
while (isspace((unsigned char)*v)) {
@@ -250,37 +255,77 @@ void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score) {
}
const char *vend = imp_value_end(v);
if (!vend) {
- return;
+ return NULL;
}
size_t head = (size_t)(v - old);
size_t tail = strlen(vend);
char *neu = malloc(head + (size_t)vn + tail + 1);
if (!neu) {
- return;
+ return NULL;
}
memcpy(neu, old, head);
memcpy(neu + head, val, (size_t)vn);
memcpy(neu + head + (size_t)vn, vend, tail + 1);
- free(node->properties_json);
- node->properties_json = neu;
- return;
+ return neu;
}
char frag[CBM_SZ_64];
int fn = snprintf(frag, sizeof(frag), "%s\"importance\":%s}", (olen == 2) ? "" : ",", val);
if (fn < 0 || (size_t)fn >= sizeof(frag)) {
- return;
+ return NULL;
}
char *neu = malloc(olen - 1 + (size_t)fn + 1);
if (!neu) {
- return;
+ return NULL;
}
memcpy(neu, old, olen - 1); /* copy without the trailing '}' */
memcpy(neu + olen - 1, frag, (size_t)fn + 1);
+ return neu;
+}
+
+/* gbuf-node convenience wrapper around cbm_pipeline_importance_set_prop. */
+void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score) {
+ if (!node) {
+ return;
+ }
+ char *neu = cbm_pipeline_importance_set_prop(node->properties_json, score);
+ if (!neu) {
+ return;
+ }
free(node->properties_json);
node->properties_json = neu;
}
+/* ── The scoring rule ─────────────────────────────────────────────────
+ *
+ * SINGLE DEFINITION, deliberately. There are two routes that score nodes —
+ * the in-memory gbuf pass (full index, legacy-partial incremental) and the
+ * SQL-level recompute over the staging store (closure-delta incremental) —
+ * and they gather their inputs very differently. Only the GATHERING differs;
+ * the rule itself lives here alone, so the two routes cannot drift apart in
+ * what a score means. The equivalence test in tests/test_importance.c guards
+ * the remaining freedom: that both gatherings produce the same inputs for the
+ * same graph. */
+double cbm_pipeline_importance_score(const cbm_importance_inputs_t *in) {
+ if (!in) {
+ return 0.0;
+ }
+ double score = sqrt((double)(in->num_refs > 0 ? in->num_refs : 0));
+ if (name_is_private(in->name)) {
+ score *= CBM_IMPORTANCE_PRIV_MUL;
+ }
+ if (in->name_distinct_files >= CBM_IMPORTANCE_GENERIC_MIN_FILES) {
+ score *= CBM_IMPORTANCE_GENERIC_MUL;
+ }
+ if (name_is_distinctive(in->name)) {
+ score *= CBM_IMPORTANCE_DISTINCT_MUL;
+ }
+ if (cbm_is_test_path(in->file_path) || in->tests_target) {
+ score *= CBM_IMPORTANCE_TEST_MUL;
+ }
+ return score;
+}
+
/* ── Distinct-file counting (the linear core) ─────────────────────────── */
typedef struct {
@@ -367,24 +412,16 @@ void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) {
for (int i = 0; i < count; i++) {
cbm_gbuf_node_t *n = (cbm_gbuf_node_t *)nodes[i];
- int num_refs =
- incoming_edge_count(gb, n->id, "CALLS") + incoming_edge_count(gb, n->id, "USAGE");
- double score = sqrt((double)num_refs);
-
- if (name_is_private(n->name)) {
- score *= CBM_IMPORTANCE_PRIV_MUL;
- }
- if (imp_distinct_file_count(gb, n->name, &ix) >= CBM_IMPORTANCE_GENERIC_MIN_FILES) {
- score *= CBM_IMPORTANCE_GENERIC_MUL;
- }
- if (name_is_distinctive(n->name)) {
- score *= CBM_IMPORTANCE_DISTINCT_MUL;
- }
- if (cbm_is_test_path(n->file_path) || incoming_edge_count(gb, n->id, "TESTS") > 0) {
- score *= CBM_IMPORTANCE_TEST_MUL;
- }
-
- cbm_pipeline_importance_append_prop(n, score);
+ /* Gather only — the rule itself lives in one place. */
+ cbm_importance_inputs_t in = {
+ .name = n->name,
+ .file_path = n->file_path,
+ .num_refs = incoming_edge_count(gb, n->id, "CALLS") +
+ incoming_edge_count(gb, n->id, "USAGE"),
+ .name_distinct_files = imp_distinct_file_count(gb, n->name, &ix),
+ .tests_target = incoming_edge_count(gb, n->id, "TESTS") > 0,
+ };
+ cbm_pipeline_importance_append_prop(n, cbm_pipeline_importance_score(&in));
updated++;
}
}
@@ -394,3 +431,126 @@ void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx) {
atomic_fetch_add_explicit(&g_importance_nodes, updated, memory_order_relaxed);
cbm_log_info("pass.importance", "symbols", itoa_imp(updated));
}
+
+/* ── Store-level recompute (closure-delta incremental) ────────────────
+ *
+ * WHY THIS EXISTS. The closure-delta route never materialises the project
+ * graph in RAM: cbm_delta_preseed fills the delta gbuf with *proxy* nodes
+ * (id, label, name, qn, file_path — no edges, empty properties), and only the
+ * re-extracted files' symbols carry real edges. Scoring there reads a
+ * near-zero in-degree for a symbol referenced hundreds of times project-wide,
+ * and cbm_delta_patch then persists exactly those fresh rows. Loading the full
+ * graph to fix that would give back the point of the delta route, so the
+ * recompute runs where the complete graph already exists: in SQL, over the
+ * staging store, AFTER cbm_delta_patch has inserted the new nodes and
+ * re-linked the snapshotted inbound edges.
+ *
+ * WHY IT CANNOT DRIFT FROM THE IN-MEMORY PASS. The scoring rule and the JSON
+ * write-back are not reimplemented here. They are registered as SQLite
+ * user-defined functions that call cbm_pipeline_importance_score() and
+ * cbm_pipeline_importance_set_prop() — the same C code the gbuf pass runs.
+ * SQL supplies only the three aggregates (in-degree, TESTS presence, distinct
+ * files per name), each one indexed GROUP BY. What remains free is the
+ * GATHERING, and the full-vs-incremental equivalence test guards that.
+ *
+ * The whole project is rescored, not just the changed files: distinct-files-
+ * per-name is a global input, so editing one file can legitimately move a name
+ * across the generic-name threshold for every other definition of that name. A
+ * scoped recompute would leave those stale — and would fail the equivalence
+ * test, correctly. */
+
+/* UDF: cbm_imp_score(name, file_path, num_refs, name_files, tests_target). */
+static void sql_importance_score(sqlite3_context *c, int argc, sqlite3_value **argv) {
+ if (argc != 5) {
+ sqlite3_result_error(c, "cbm_imp_score arity", -1);
+ return;
+ }
+ cbm_importance_inputs_t in = {
+ .name = (const char *)sqlite3_value_text(argv[0]),
+ .file_path = (const char *)sqlite3_value_text(argv[1]),
+ .num_refs = sqlite3_value_int(argv[2]),
+ .name_distinct_files = sqlite3_value_int(argv[3]),
+ .tests_target = sqlite3_value_int(argv[4]) != 0,
+ };
+ sqlite3_result_double(c, cbm_pipeline_importance_score(&in));
+}
+
+/* UDF: cbm_imp_set(properties, score) → properties with "importance" set.
+ * A non-object blob yields NULL; the UPDATE's COALESCE then leaves that row
+ * untouched — the same tolerance the in-memory writer has. */
+static void sql_importance_set(sqlite3_context *c, int argc, sqlite3_value **argv) {
+ if (argc != 2) {
+ sqlite3_result_error(c, "cbm_imp_set arity", -1);
+ return;
+ }
+ const char *props = (const char *)sqlite3_value_text(argv[0]);
+ char *neu = cbm_pipeline_importance_set_prop(props, sqlite3_value_double(argv[1]));
+ if (!neu) {
+ sqlite3_result_null(c);
+ return;
+ }
+ sqlite3_result_text(c, neu, -1, SQLITE_TRANSIENT);
+ free(neu);
+}
+
+/* The aggregate CTE is MATERIALIZED on purpose: it reads `nodes` while the
+ * UPDATE writes `nodes`, and materialising settles that ordering explicitly
+ * rather than relying on the planner to choose it. */
+static const char *const kImportanceRecomputeSQL =
+ "WITH agg AS MATERIALIZED ("
+ " SELECT n.id AS id,"
+ " cbm_imp_score(n.name, n.file_path, COALESCE(d.refs, 0),"
+ " COALESCE(f.files, 0), COALESCE(d.tests, 0)) AS score"
+ " FROM nodes n"
+ " LEFT JOIN (SELECT target_id AS id,"
+ " SUM(CASE WHEN type IN ('CALLS','USAGE') THEN 1 ELSE 0 END) AS refs,"
+ " SUM(CASE WHEN type = 'TESTS' THEN 1 ELSE 0 END) AS tests"
+ " FROM edges WHERE project = ?1 GROUP BY target_id) d ON d.id = n.id"
+ " LEFT JOIN (SELECT name, COUNT(DISTINCT file_path) AS files"
+ " FROM nodes WHERE project = ?1 GROUP BY name) f ON f.name = n.name"
+ " WHERE n.project = ?1 AND n.label IN ('Function','Method','Class')"
+ ")"
+ "UPDATE nodes SET properties = COALESCE(cbm_imp_set(properties, agg.score), properties)"
+ " FROM agg WHERE nodes.id = agg.id";
+
+int cbm_pipeline_importance_recompute_store(cbm_store_t *store, const char *project) {
+ if (!store || !project) {
+ return -1;
+ }
+ sqlite3 *db = cbm_store_get_db(store);
+ if (!db) {
+ return -1;
+ }
+ if (sqlite3_create_function(db, "cbm_imp_score", 5, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL,
+ sql_importance_score, NULL, NULL) != SQLITE_OK ||
+ sqlite3_create_function(db, "cbm_imp_set", 2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL,
+ sql_importance_set, NULL, NULL) != SQLITE_OK) {
+ cbm_log_error("importance.store_udf_failed", "err", sqlite3_errmsg(db));
+ return -1;
+ }
+ if (cbm_store_begin(store) != CBM_STORE_OK) {
+ return -1;
+ }
+ sqlite3_stmt *st = NULL;
+ if (sqlite3_prepare_v2(db, kImportanceRecomputeSQL, -1, &st, NULL) != SQLITE_OK) {
+ cbm_log_error("importance.store_prepare_failed", "err", sqlite3_errmsg(db));
+ cbm_store_rollback(store);
+ return -1;
+ }
+ sqlite3_bind_text(st, 1, project, -1, SQLITE_TRANSIENT);
+ int step_rc = sqlite3_step(st);
+ sqlite3_finalize(st);
+ if (step_rc != SQLITE_DONE) {
+ cbm_log_error("importance.store_step_failed", "err", sqlite3_errmsg(db));
+ cbm_store_rollback(store);
+ return -1;
+ }
+ int changed = sqlite3_changes(db);
+ if (cbm_store_commit(store) != CBM_STORE_OK) {
+ return -1;
+ }
+ uint64_t rows = (uint64_t)(changed > 0 ? changed : 0);
+ atomic_fetch_add_explicit(&g_importance_store_rows, rows, memory_order_relaxed);
+ cbm_log_info("pass.importance_store", "symbols", itoa_imp(rows));
+ return 0;
+}
diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c
index 5de6bfd6a..4ff89aa42 100644
--- a/src/pipeline/pipeline_incremental.c
+++ b/src/pipeline/pipeline_incremental.c
@@ -1410,9 +1410,15 @@ static int run_extract_resolve(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed
}
}
-/* Run post-extraction passes (tests, decorator tags, configlink). */
+/* Run post-extraction passes (tests, decorator tags, configlink).
+ *
+ * score_importance: false on the closure-delta route, whose ctx->gbuf is a
+ * PROXY buffer — cbm_delta_preseed fills it with id/label/name/qn/file_path
+ * and no project-wide edges, so an in-memory score there would read a
+ * near-zero in-degree and cbm_delta_patch would persist it. That route scores
+ * in SQL instead, after the patch; see cbm_pipeline_importance_recompute_store. */
static int run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_files, int ci,
- const char *project) {
+ const char *project, bool score_importance) {
struct timespec t;
cbm_clock_gettime(CLOCK_MONOTONIC, &t);
@@ -1457,16 +1463,22 @@ static int run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_file
}
}
- /* Importance last, in EVERY mode — same ordering rule as the full path:
- * it reads CALLS/USAGE (re-extraction, above) and TESTS (pass_tests, first
- * in this function). The graph here is the rehydrated whole-project buffer,
- * so nodes outside the changed set already carry an "importance" key from
- * the previous run; cbm_pipeline_importance_append_prop overwrites it in
- * place rather than appending a duplicate. */
- cbm_clock_gettime(CLOCK_MONOTONIC, &t);
- cbm_pipeline_pass_importance(ctx);
- cbm_log_info("pass.timing", "pass", "incr_importance", "elapsed_ms",
- itoa_buf((int)elapsed_ms(t)));
+ /* Importance last, in EVERY mode — same ordering rule as the full path: it
+ * reads CALLS/USAGE (re-extraction, above) and TESTS (pass_tests, first in
+ * this function).
+ *
+ * ONLY on the legacy-partial route, where ctx->gbuf really is the whole
+ * project rehydrated by cbm_gbuf_load_from_db — those nodes already carry
+ * an "importance" key from the previous run, and the write-back overwrites
+ * it in place rather than appending a duplicate. The closure-delta route
+ * passes score_importance = false and rescores in SQL after its patch,
+ * because its buffer holds proxies without project-wide edges. */
+ if (score_importance) {
+ cbm_clock_gettime(CLOCK_MONOTONIC, &t);
+ cbm_pipeline_pass_importance(ctx);
+ cbm_log_info("pass.timing", "pass", "incr_importance", "elapsed_ms",
+ itoa_buf((int)elapsed_ms(t)));
+ }
if (cbm_pipeline_check_cancel(ctx)) {
return CBM_NOT_FOUND;
}
@@ -2158,7 +2170,7 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
phase_rc = cbm_pipeline_check_cancel(&ctx);
}
if (phase_rc == 0) {
- phase_rc = run_postpasses(&ctx, changed_files, ci, project);
+ phase_rc = run_postpasses(&ctx, changed_files, ci, project, false);
}
if (ctx.return_type_table) {
for (int i = 0; i < ctx.return_type_table->count; i++) {
@@ -2183,6 +2195,23 @@ static int run_closure_delta(cbm_pipeline_t *p, const char *db_path, const char
}
cbm_log_info("delta.patch_done", "elapsed_ms", itoa_buf((int)elapsed_ms(t)));
+ /* Importance, in SQL, over the now-complete staging graph. This is the
+ * FIRST point on this route where the whole project is queryable: the
+ * patch has just inserted the repaired files' nodes AND re-linked the
+ * snapshotted inbound edges, so in-degree is finally correct. Scoring
+ * earlier — in run_postpasses, off the proxy buffer — would persist a
+ * near-zero score for any heavily-referenced symbol in a changed file.
+ * A failure here is a delta-route failure: `result` still holds
+ * FORCE_FULL_REINDEX, so the run degrades to a correct full rebuild
+ * rather than publishing wrong scores. */
+ cbm_clock_gettime(CLOCK_MONOTONIC, &t);
+ if (cbm_pipeline_importance_recompute_store(staging, project) != 0) {
+ cbm_log_error("delta.err", "phase", "importance_recompute");
+ goto out;
+ }
+ cbm_log_info("pass.timing", "pass", "delta_importance_store", "elapsed_ms",
+ itoa_buf((int)elapsed_ms(t)));
+
/* Coverage: previous failure rows for files not re-extracted + this
* run's fresh entries — same merge the legacy tail performs. */
cbm_file_error_t *run_errs = NULL;
@@ -2713,7 +2742,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
phase_rc = cbm_pipeline_check_cancel(&ctx);
}
if (phase_rc == 0) {
- phase_rc = run_postpasses(&ctx, changed_files, ci, project);
+ phase_rc = run_postpasses(&ctx, changed_files, ci, project, true);
}
/* Free ObjectScript tables built by pass_calls during run_extract_resolve. */
diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h
index a25573733..e686ac3b7 100644
--- a/src/pipeline/pipeline_internal.h
+++ b/src/pipeline/pipeline_internal.h
@@ -641,12 +641,39 @@ void cbm_pipeline_pass_complexity(cbm_pipeline_ctx_t *ctx);
* post-pass sequence. */
void cbm_pipeline_pass_importance(cbm_pipeline_ctx_t *ctx);
-/* Write the numeric "importance" key into a node's properties JSON object.
- * IDEMPOTENT: an existing key is overwritten in place, never appended twice —
- * the incremental path re-scores nodes rehydrated from the store that already
- * carry the key. Non-static so the idempotence contract is directly testable. */
+/* Gathered inputs for one symbol. Each scoring route fills this its own way
+ * (gbuf lookups, or SQL aggregates) and then calls the ONE rule below. */
+typedef struct {
+ const char *name;
+ const char *file_path;
+ int num_refs; /* incoming CALLS + USAGE */
+ int name_distinct_files; /* distinct files the NAME is defined in */
+ bool tests_target; /* has an incoming TESTS edge */
+} cbm_importance_inputs_t;
+
+/* THE scoring rule — single definition, shared by every route, so the two
+ * gathering strategies cannot drift apart in what a score means. */
+double cbm_pipeline_importance_score(const cbm_importance_inputs_t *in);
+
+/* Produce a copy of `json` with the numeric "importance" key set to `score`,
+ * or NULL when `json` is not a JSON object or allocation fails. Caller owns
+ * the result. IDEMPOTENT: an existing key is overwritten in place, never
+ * appended twice — every re-scoring route sees nodes that already carry it.
+ * Single definition, shared by the in-memory and SQL writers alike. */
+char *cbm_pipeline_importance_set_prop(const char *json, double score);
+
+/* gbuf-node convenience wrapper around cbm_pipeline_importance_set_prop. */
void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score);
+/* Recompute importance for an ENTIRE project directly in a store, in SQL.
+ * Used by the closure-delta incremental route, whose in-RAM graph is a proxy
+ * buffer with no project-wide edges — scoring there would persist a near-zero
+ * in-degree for heavily-referenced symbols in the changed files. Must be
+ * called on the STAGING store AFTER cbm_delta_patch has merged nodes and
+ * re-linked inbound edges. Returns 0 on success; the caller treats failure as
+ * a delta-route failure and falls back to a full rebuild. */
+int cbm_pipeline_importance_recompute_store(cbm_store_t *store, const char *project);
+
/* Work counters for the importance pass (pass_importance.c). Deltas are read
* by the complexity suite's linearity gate; never reset by the pass itself, so
* nested/repeated runs compose. g_importance_name_visits counts same-name-group
@@ -654,6 +681,9 @@ void cbm_pipeline_importance_append_prop(cbm_gbuf_node_t *node, double score);
* superlinear if the per-distinct-name memoization is ever lost. */
extern _Atomic uint64_t g_importance_nodes;
extern _Atomic uint64_t g_importance_name_visits;
+/* Rows rescored by the SQL-level store recompute. Lets a test prove the
+ * closure-delta route actually took that path instead of passing vacuously. */
+extern _Atomic uint64_t g_importance_store_rows;
/* ── Env URL scanner (pass_envscan.c) ────────────────────────────── */
diff --git a/tests/test_importance.c b/tests/test_importance.c
index 9452a49e9..afaf41a3c 100644
--- a/tests/test_importance.c
+++ b/tests/test_importance.c
@@ -101,6 +101,97 @@ static int imp_check_label(cbm_store_t *s, const char *project, const char *labe
return bad == 0 ? count : -1;
}
+/* ── Store-level score readout (for the closure-delta tests) ──────────── */
+
+typedef struct {
+ char **qns;
+ double *scores;
+ int count;
+} ImpScoreSet;
+
+static void imp_scores_free(ImpScoreSet *set) {
+ for (int i = 0; i < set->count; i++) {
+ free(set->qns[i]);
+ }
+ free(set->qns);
+ free(set->scores);
+ set->qns = NULL;
+ set->scores = NULL;
+ set->count = 0;
+}
+
+/* Collect (qualified_name -> importance) for every scored label in a store. */
+static int imp_collect_scores(const char *db_path, const char *project, ImpScoreSet *out) {
+ static const char *const kLabels[] = {"Function", "Method", "Class"};
+ memset(out, 0, sizeof(*out));
+ cbm_store_t *s = cbm_store_open_path(db_path);
+ if (!s) {
+ return -1;
+ }
+ int rc = 0;
+ for (int li = 0; li < 3; li++) {
+ cbm_node_t *nodes = NULL;
+ int n = 0;
+ if (cbm_store_find_nodes_by_label(s, project, kLabels[li], &nodes, &n) != CBM_STORE_OK) {
+ rc = -1;
+ break;
+ }
+ char **qns = realloc(out->qns, (size_t)(out->count + n) * sizeof(*qns));
+ double *sc = realloc(out->scores, (size_t)(out->count + n) * sizeof(*sc));
+ if (n > 0 && (!qns || !sc)) {
+ cbm_store_free_nodes(nodes, n);
+ rc = -1;
+ break;
+ }
+ if (qns) {
+ out->qns = qns;
+ }
+ if (sc) {
+ out->scores = sc;
+ }
+ for (int i = 0; i < n; i++) {
+ out->qns[out->count] = strdup(nodes[i].qualified_name ? nodes[i].qualified_name : "");
+ out->scores[out->count] = imp_value(nodes[i].properties_json);
+ out->count++;
+ }
+ cbm_store_free_nodes(nodes, n);
+ }
+ cbm_store_close(s);
+ return rc;
+}
+
+static double imp_score_of(const ImpScoreSet *set, const char *qn_suffix) {
+ for (int i = 0; i < set->count; i++) {
+ size_t l = strlen(set->qns[i]);
+ size_t k = strlen(qn_suffix);
+ if (l >= k && strcmp(set->qns[i] + (l - k), qn_suffix) == 0) {
+ return set->scores[i];
+ }
+ }
+ return -999.0;
+}
+
+/* Write a fixture whose `helper` in hot.py is called from `callers` other
+ * files — the inbound edges that only exist project-wide, never in a delta
+ * buffer. `tag` lets the caller perturb hot.py to force a re-parse. */
+static int imp_write_hot_fixture(const char *root, int callers, const char *tag) {
+ char body[256];
+ snprintf(body, sizeof(body), "%sdef helper():\n return 1\n", tag);
+ if (th_write_file(TH_PATH(root, "hot.py"), body) != 0) {
+ return -1;
+ }
+ for (int i = 0; i < callers; i++) {
+ char rel[64];
+ char src[192];
+ snprintf(rel, sizeof(rel), "c%d.py", i);
+ snprintf(src, sizeof(src), "import hot\n\ndef use%d():\n return hot.helper()\n", i);
+ if (th_write_file(TH_PATH(root, rel), src) != 0) {
+ return -1;
+ }
+ }
+ return 0;
+}
+
/* ── Registration: the pass must actually execute ─────────────────────── */
/* A miscounted PREDUMP_PASS_COUNT silently drops the last-registered pass.
@@ -577,6 +668,181 @@ TEST(importance_second_index_run_keeps_exactly_one_key) {
PASS();
}
+/* ── Closure-delta route: the graph is only complete in the store ─────── */
+
+/* REGRESSION. `helper` lives in hot.py and is called from 25 other files. Edit
+ * hot.py and re-index: the closure-delta route purges hot.py's rows, rebuilds
+ * them from a PROXY buffer that holds no project-wide edges, and persists what
+ * it computed. Scoring off that buffer sees in-degree 0 and stores 0.0 for a
+ * symbol with 25 real callers. The fix rescores in SQL after cbm_delta_patch
+ * has re-linked the snapshotted inbound edges. */
+TEST(importance_closure_delta_keeps_referenced_symbol_sane) {
+ enum { CALLERS = 25 };
+ char *tmp = th_mktempdir("cbm_imp_delta");
+ if (!tmp) {
+ FAIL("tmpdir");
+ }
+ char root[512];
+ snprintf(root, sizeof(root), "%s", tmp);
+ char *tmp2 = th_mktempdir("cbm_imp_deltadb");
+ if (!tmp2) {
+ th_rmtree(root);
+ FAIL("tmpdir2");
+ }
+ char dbdir[512];
+ snprintf(dbdir, sizeof(dbdir), "%s", tmp2);
+
+ ASSERT_EQ(imp_write_hot_fixture(root, CALLERS, ""), 0);
+
+ char db_path[600];
+ snprintf(db_path, sizeof(db_path), "%s/graph.db", dbdir);
+ cbm_pipeline_t *p1 = cbm_pipeline_new(root, db_path, CBM_MODE_FULL);
+ ASSERT_NOT_NULL(p1);
+ ASSERT_EQ(cbm_pipeline_run(p1), 0);
+ char project[256];
+ snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1));
+ cbm_pipeline_free(p1);
+
+ ImpScoreSet before = {0};
+ ASSERT_EQ(imp_collect_scores(db_path, project, &before), 0);
+ double full_score = imp_score_of(&before, "helper");
+ printf(" full-index helper score %.6f (expect sqrt(%d) = %.6f)\n", full_score, CALLERS,
+ sqrt((double)CALLERS));
+ /* Non-vacuous: the callers really produced edges. If this is 0 the fixture
+ * stopped exercising the bug and the test below would prove nothing. */
+ ASSERT_TRUE(full_score > 1.0);
+ imp_scores_free(&before);
+
+ /* Touch hot.py so the delta route has exactly one file to repair. */
+ ASSERT_EQ(imp_write_hot_fixture(root, CALLERS, "# touched\n"), 0);
+
+ uint64_t sql0 = atomic_load_explicit(&g_importance_store_rows, memory_order_relaxed);
+ cbm_pipeline_t *p2 = cbm_pipeline_new(root, db_path, CBM_MODE_FULL);
+ ASSERT_NOT_NULL(p2);
+ ASSERT_EQ(cbm_pipeline_run(p2), 0);
+ cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
+ cbm_pipeline_free(p2);
+ uint64_t sql_rows = atomic_load_explicit(&g_importance_store_rows, memory_order_relaxed) - sql0;
+
+ /* Route proof — without it a fall-back to a full reindex would repair the
+ * score for the wrong reason and the test would pass vacuously. */
+ printf(" route=%d (closure_repair=%d) sql_rescored_rows=%llu\n", (int)route,
+ (int)CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR, (unsigned long long)sql_rows);
+ ASSERT_EQ((int)route, (int)CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
+
+ ImpScoreSet after = {0};
+ ASSERT_EQ(imp_collect_scores(db_path, project, &after), 0);
+ double delta_score = imp_score_of(&after, "helper");
+ printf(" after closure-delta helper score %.6f\n", delta_score);
+ imp_scores_free(&after);
+
+ th_rmtree(root);
+ th_rmtree(dbdir);
+ /* THE SYMPTOM, asserted before any mechanism guard: the graph is unchanged,
+ * so the score must be unchanged. Scoring off the proxy buffer yields 0.0. */
+ ASSERT_FLOAT_EQ(delta_score, full_score, 1e-6);
+ /* Mechanism guard second, so a regression fails on the wrong score rather
+ * than on bookkeeping. */
+ ASSERT_GT((long long)sql_rows, 0);
+ PASS();
+}
+
+/* EQUIVALENCE. A full index and an incremental index that converge on the same
+ * tree must produce the same scores — for every symbol, not just the hot one.
+ * Same directory and therefore the same project name and qualified names, so
+ * the two score maps are directly comparable. */
+TEST(importance_full_and_incremental_converge_on_same_scores) {
+ enum { CALLERS = 12 };
+ char *tmp = th_mktempdir("cbm_imp_equiv");
+ if (!tmp) {
+ FAIL("tmpdir");
+ }
+ char root[512];
+ snprintf(root, sizeof(root), "%s", tmp);
+ char *tmp2 = th_mktempdir("cbm_imp_equivdb");
+ if (!tmp2) {
+ th_rmtree(root);
+ FAIL("tmpdir2");
+ }
+ char dbdir[512];
+ snprintf(dbdir, sizeof(dbdir), "%s", tmp2);
+
+ /* v1: every caller but c0 calls helper. c0 imports hot but does not. */
+ ASSERT_EQ(imp_write_hot_fixture(root, CALLERS, ""), 0);
+ ASSERT_EQ(th_write_file(TH_PATH(root, "c0.py"), "import hot\n\ndef use0():\n return 1\n"),
+ 0);
+ ASSERT_EQ(th_write_file(TH_PATH(root, "extra.py"),
+ "import hot\n\ndef load_user_profile():\n return hot.helper()\n"),
+ 0);
+
+ char db_incr[600];
+ snprintf(db_incr, sizeof(db_incr), "%s/incr.db", dbdir);
+ cbm_pipeline_t *p1 = cbm_pipeline_new(root, db_incr, CBM_MODE_FULL);
+ ASSERT_NOT_NULL(p1);
+ ASSERT_EQ(cbm_pipeline_run(p1), 0);
+ char project[256];
+ snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(p1));
+ cbm_pipeline_free(p1);
+
+ /* v2: c0's BODY starts calling helper. Body-only, so c0's surface is
+ * unchanged and the closure planner accepts the delta — but the in-degree
+ * of `helper`, which lives in the UNCHANGED hot.py, goes up by one. That
+ * is the case a changed-files-only rescore gets wrong: the symbol whose
+ * score moved is not in the changed set at all. */
+ ASSERT_EQ(th_write_file(TH_PATH(root, "c0.py"),
+ "import hot\n\ndef use0():\n return hot.helper()\n"),
+ 0);
+
+ uint64_t sql0 = atomic_load_explicit(&g_importance_store_rows, memory_order_relaxed);
+ cbm_pipeline_t *p2 = cbm_pipeline_new(root, db_incr, CBM_MODE_FULL);
+ ASSERT_NOT_NULL(p2);
+ ASSERT_EQ(cbm_pipeline_run(p2), 0);
+ cbm_incremental_route_t route = cbm_pipeline_incremental_test_last_route();
+ cbm_pipeline_free(p2);
+ uint64_t sql_rows = atomic_load_explicit(&g_importance_store_rows, memory_order_relaxed) - sql0;
+ printf(" route=%d (closure_repair=%d) sql_rescored_rows=%llu\n", (int)route,
+ (int)CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR, (unsigned long long)sql_rows);
+ ASSERT_EQ((int)route, (int)CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR);
+
+ /* Same tree, indexed cold into a separate database. */
+ char db_full[600];
+ snprintf(db_full, sizeof(db_full), "%s/full.db", dbdir);
+ cbm_pipeline_t *p3 = cbm_pipeline_new(root, db_full, CBM_MODE_FULL);
+ ASSERT_NOT_NULL(p3);
+ ASSERT_EQ(cbm_pipeline_run(p3), 0);
+ cbm_pipeline_free(p3);
+
+ ImpScoreSet incr = {0};
+ ImpScoreSet full = {0};
+ ASSERT_EQ(imp_collect_scores(db_incr, project, &incr), 0);
+ ASSERT_EQ(imp_collect_scores(db_full, project, &full), 0);
+ printf(" scored symbols: incremental=%d full=%d\n", incr.count, full.count);
+
+ int mismatches = 0;
+ for (int i = 0; i < full.count; i++) {
+ double a = full.scores[i];
+ double b = imp_score_of(&incr, full.qns[i]);
+ if (!(fabs(a - b) < 1e-6)) {
+ mismatches++;
+ if (mismatches <= 5) {
+ printf(" MISMATCH %s: full=%.6f incremental=%.6f\n", full.qns[i], a, b);
+ }
+ }
+ }
+ int count_full = full.count;
+ int count_incr = incr.count;
+ imp_scores_free(&incr);
+ imp_scores_free(&full);
+ th_rmtree(root);
+ th_rmtree(dbdir);
+
+ ASSERT_GT(count_full, 5); /* non-vacuous: there really are symbols to compare */
+ ASSERT_EQ(count_incr, count_full);
+ ASSERT_EQ(mismatches, 0); /* the symptom */
+ ASSERT_GT((long long)sql_rows, 0); /* mechanism guard, asserted second */
+ PASS();
+}
+
/* ── Cost shape ───────────────────────────────────────────────────────── */
/* The distinct-file count must be computed once per distinct NAME. Computing
@@ -636,6 +902,10 @@ SUITE(importance) {
RUN_TEST(importance_rehydrated_node_keeps_exactly_one_key);
RUN_TEST(importance_second_index_run_keeps_exactly_one_key);
+ /* Closure-delta route — the store-level recompute. */
+ RUN_TEST(importance_closure_delta_keeps_referenced_symbol_sane);
+ RUN_TEST(importance_full_and_incremental_converge_on_same_scores);
+
/* Cost shape. */
RUN_TEST(importance_distinct_file_count_is_linear_in_group_size);
}
From cf6e620431954034523a2705c35f9d4f553c4688 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Fri, 28 Aug 2026 18:10:00 +0200
Subject: [PATCH 12/31] feat(mcp): add bounded graph comparison tool
Signed-off-by: Martin Vogel
---
src/mcp/mcp.c | 431 ++++++++++++++++++++++++++++++++++++++-
src/store/store.c | 384 ++++++++++++++++++++++++++++++++++
src/store/store.h | 53 +++++
tests/test_mcp.c | 510 +++++++++++++++++++++++++++++++++++++++++++++-
4 files changed, 1371 insertions(+), 7 deletions(-)
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index 0ce6ae7eb..616bbf6a1 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -1,5 +1,5 @@
/*
- * mcp.c — MCP server: JSON-RPC 2.0 over stdio with 14 graph tools.
+ * mcp.c — MCP server: JSON-RPC 2.0 over stdio with graph tools.
*
* Uses yyjson for fast JSON parsing/building.
* Single-threaded event loop: read line → parse → dispatch → respond.
@@ -36,6 +36,11 @@ enum {
MCP_TOOLS_PAGE_SIZE = 8,
MCP_HELP_TOOLS_WRAP_COL = 74, /* --help tool list stays readable on 80-col terminals */
MCP_MAX_CROSS_REPO_TARGETS = 4096,
+ MCP_COMPARE_DEFAULT_LIMIT = 200,
+ MCP_COMPARE_MAX_LIMIT = 1000,
+ MCP_COMPARE_DEFAULT_SCAN_LIMIT = 2000000,
+ MCP_COMPARE_MAX_SCAN_LIMIT = 10000000,
+ MCP_COMPARE_SET_BYTE_BUDGET = 512 * 1024,
};
#define MCP_MS_TO_US 1000LL
#define MCP_S_TO_US 1000000LL
@@ -556,6 +561,19 @@ static const tool_def_t TOOLS[] = {
"{\"type\":\"object\",\"properties\":{\"project\":{\"type\":\"string\"}},\"required\":["
"\"project\"]}"},
+ {"compare_graphs", "Compare graphs",
+ "Compare two indexed project snapshots. Returns deterministic target-only additions and "
+ "base-only removals for stable node and edge identities using a bounded streaming merge. "
+ "Each result set is independently capped by limit and a fixed 512 KiB encoded-byte budget; "
+ "exact totals and truncation reasons are always reported.",
+ "{\"type\":\"object\",\"properties\":{"
+ "\"base_project\":{\"type\":\"string\",\"minLength\":1},"
+ "\"target_project\":{\"type\":\"string\",\"minLength\":1},"
+ "\"limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":1000,\"default\":200},"
+ "\"scan_limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":10000000,"
+ "\"default\":2000000}},\"required\":[\"base_project\",\"target_project\"],"
+ "\"additionalProperties\":false}"},
+
{"get_architecture", "Get architecture",
"Get high-level architecture overview. DEFAULT (no aspects) is a compact summary — "
"overview counts, languages, packages, entry_points; request more via aspects:[...] "
@@ -719,6 +737,7 @@ static const tool_annotation_def_t TOOL_ANNOTATIONS[] = {
{"trace_path", false, true, true, false},
{"get_code_snippet", false, true, true, false},
{"get_graph_schema", false, true, true, false},
+ {"compare_graphs", true, false, true, false},
{"get_architecture", false, true, true, false},
{"search_code", false, true, true, false},
{"list_projects", true, false, true, false},
@@ -780,9 +799,9 @@ static void mcp_add_tool_def(yyjson_mut_doc *doc, yyjson_mut_val *tools, int i)
static bool mcp_tool_allowed(cbm_mcp_tool_profile_t profile, const char *name) {
static const char *const analysis_tools[] = {
- "search_graph", "query_graph", "trace_path", "get_code_snippet",
- "get_graph_schema", "get_architecture", "search_code", "list_projects",
- "index_status", "check_index_coverage", "detect_changes",
+ "search_graph", "query_graph", "trace_path", "get_code_snippet",
+ "get_graph_schema", "compare_graphs", "get_architecture", "search_code",
+ "list_projects", "index_status", "check_index_coverage", "detect_changes",
};
static const char *const scout_tools[] = {
"search_graph", "trace_path", "get_code_snippet", "get_architecture",
@@ -2751,6 +2770,407 @@ static char *verify_project_indexed(cbm_store_t *store, const char *project) {
return NULL;
}
+/* compare_graphs deliberately bypasses resolve_store(): it needs two
+ * independently-owned request-scoped read handles, while resolve_store caches
+ * one handle on the server. Direct-name lookup stays the fast path; the
+ * existing internal-name fallback preserves legacy renamed databases. */
+static cbm_store_t *compare_open_project_store(const char *project) {
+ char path[CBM_SZ_1K];
+ project_db_path(project, path, sizeof(path));
+ cbm_store_t *store = path[0] ? cbm_store_open_path_query(path) : NULL;
+ if (store) {
+ cbm_project_t row = {0};
+ if (cbm_store_get_project(store, project, &row) == CBM_STORE_OK) {
+ cbm_project_free_fields(&row);
+ return store;
+ }
+ cbm_store_close(store);
+ }
+ return resolve_store_fallback_scan(project);
+}
+
+typedef struct {
+ yyjson_mut_val *items;
+ size_t returned;
+ size_t encoded_bytes;
+ bool budget_exhausted;
+} compare_result_set_t;
+
+typedef struct {
+ cbm_mcp_server_t *server;
+ yyjson_mut_doc *doc;
+ size_t limit;
+ compare_result_set_t nodes_added;
+ compare_result_set_t nodes_removed;
+ compare_result_set_t edges_added;
+ compare_result_set_t edges_removed;
+} compare_response_t;
+
+static char *compare_graphs_error(const char *code, const char *message) {
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL;
+ if (!doc || !root) {
+ yyjson_mut_doc_free(doc);
+ return cbm_mcp_text_result("compare_graphs failed: out of memory", true);
+ }
+ yyjson_mut_doc_set_root(doc, root);
+ if (!yyjson_mut_obj_add_strcpy(doc, root, "error", message) ||
+ !yyjson_mut_obj_add_strcpy(doc, root, "code", code)) {
+ yyjson_mut_doc_free(doc);
+ return cbm_mcp_text_result("compare_graphs failed: out of memory", true);
+ }
+ char *json = yy_doc_to_str(doc);
+ yyjson_mut_doc_free(doc);
+ if (!json) {
+ return cbm_mcp_text_result("compare_graphs failed: out of memory", true);
+ }
+ char *result = cbm_mcp_text_result(json, true);
+ free(json);
+ return result;
+}
+
+static bool compare_arg_name_allowed(const char *name) {
+ return strcmp(name, "base_project") == 0 || strcmp(name, "target_project") == 0 ||
+ strcmp(name, "limit") == 0 || strcmp(name, "scan_limit") == 0;
+}
+
+static bool compare_parse_bounded_integer(yyjson_val *root, const char *key, int64_t default_value,
+ int64_t maximum, uint64_t *out,
+ const char **error_message) {
+ yyjson_val *value = yyjson_obj_get(root, key);
+ int64_t parsed = default_value;
+ if (value) {
+ if (!yyjson_is_int(value)) {
+ *error_message = "limit values must be integers";
+ return false;
+ }
+ parsed = yyjson_get_int(value);
+ }
+ if (parsed < 1 || parsed > maximum) {
+ *error_message = strcmp(key, "limit") == 0 ? "limit must be between 1 and 1000"
+ : "scan_limit must be between 1 and 10000000";
+ return false;
+ }
+ *out = (uint64_t)parsed;
+ return true;
+}
+
+static bool compare_parse_arguments(const char *args, char **base_project, char **target_project,
+ uint64_t *limit, uint64_t *scan_limit,
+ const char **error_message) {
+ *base_project = NULL;
+ *target_project = NULL;
+ yyjson_doc *doc = yyjson_read(args ? args : "{}", args ? strlen(args) : SLEN("{}"), 0);
+ if (!doc) {
+ *error_message = "arguments must be valid JSON";
+ return false;
+ }
+ yyjson_val *root = yyjson_doc_get_root(doc);
+ if (!yyjson_is_obj(root)) {
+ *error_message = "arguments must be an object";
+ yyjson_doc_free(doc);
+ return false;
+ }
+
+ yyjson_obj_iter iterator = yyjson_obj_iter_with(root);
+ yyjson_val *key = NULL;
+ while ((key = yyjson_obj_iter_next(&iterator)) != NULL) {
+ const char *name = yyjson_get_str(key);
+ if (!name || !compare_arg_name_allowed(name)) {
+ *error_message = "unknown argument";
+ yyjson_doc_free(doc);
+ return false;
+ }
+ }
+
+ yyjson_val *base = yyjson_obj_get(root, "base_project");
+ yyjson_val *target = yyjson_obj_get(root, "target_project");
+ if (!base || !yyjson_is_str(base) || yyjson_get_len(base) == 0 || !target ||
+ !yyjson_is_str(target) || yyjson_get_len(target) == 0) {
+ *error_message = "base_project and target_project are required non-empty strings";
+ yyjson_doc_free(doc);
+ return false;
+ }
+ if (strcmp(yyjson_get_str(base), yyjson_get_str(target)) == 0) {
+ *error_message = "base_project and target_project must be distinct";
+ yyjson_doc_free(doc);
+ return false;
+ }
+ if (!compare_parse_bounded_integer(root, "limit", MCP_COMPARE_DEFAULT_LIMIT,
+ MCP_COMPARE_MAX_LIMIT, limit, error_message) ||
+ !compare_parse_bounded_integer(root, "scan_limit", MCP_COMPARE_DEFAULT_SCAN_LIMIT,
+ MCP_COMPARE_MAX_SCAN_LIMIT, scan_limit, error_message)) {
+ yyjson_doc_free(doc);
+ return false;
+ }
+
+ *base_project = heap_strdup(yyjson_get_str(base));
+ *target_project = heap_strdup(yyjson_get_str(target));
+ yyjson_doc_free(doc);
+ if (!*base_project || !*target_project) {
+ free(*base_project);
+ free(*target_project);
+ *base_project = NULL;
+ *target_project = NULL;
+ *error_message = "out of memory while validating arguments";
+ return false;
+ }
+ return true;
+}
+
+static yyjson_mut_val *compare_node_json(yyjson_mut_doc *doc,
+ const cbm_graph_node_identity_t *node) {
+ yyjson_mut_val *object = yyjson_mut_obj(doc);
+ if (!object ||
+ !yyjson_mut_obj_add_strcpy(doc, object, "qualified_name", node->qualified_name) ||
+ !yyjson_mut_obj_add_strcpy(doc, object, "label", node->label) ||
+ !yyjson_mut_obj_add_strcpy(doc, object, "file_path", node->file_path)) {
+ return NULL;
+ }
+ return object;
+}
+
+static bool compare_append_item(compare_response_t *response, compare_result_set_t *set,
+ yyjson_mut_doc *item_doc, yyjson_mut_val *item) {
+ if (!item_doc || !item) {
+ yyjson_mut_doc_free(item_doc);
+ return false;
+ }
+ char *encoded = yy_doc_to_str(item_doc);
+ if (!encoded) {
+ yyjson_mut_doc_free(item_doc);
+ return false;
+ }
+ size_t encoded_len = strlen(encoded);
+ size_t separator = set->returned > 0 ? 1U : 0U;
+ free(encoded);
+
+ if (set->encoded_bytes > MCP_COMPARE_SET_BYTE_BUDGET ||
+ separator > MCP_COMPARE_SET_BYTE_BUDGET - set->encoded_bytes ||
+ encoded_len > MCP_COMPARE_SET_BYTE_BUDGET - set->encoded_bytes - separator) {
+ set->budget_exhausted = true;
+ yyjson_mut_doc_free(item_doc);
+ return true;
+ }
+
+ yyjson_mut_val *copy = yyjson_mut_val_mut_copy(response->doc, item);
+ bool ok = copy && yyjson_mut_arr_add_val(set->items, copy);
+ yyjson_mut_doc_free(item_doc);
+ if (!ok) {
+ return false;
+ }
+ set->encoded_bytes += separator + encoded_len;
+ set->returned++;
+ return true;
+}
+
+static bool compare_node_callback(void *context, bool added,
+ const cbm_graph_node_identity_t *node) {
+ compare_response_t *response = (compare_response_t *)context;
+ compare_result_set_t *set = added ? &response->nodes_added : &response->nodes_removed;
+ if (set->returned >= response->limit || set->budget_exhausted) {
+ return true;
+ }
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *item = doc ? compare_node_json(doc, node) : NULL;
+ if (doc && item) {
+ yyjson_mut_doc_set_root(doc, item);
+ }
+ return compare_append_item(response, set, doc, item);
+}
+
+static bool compare_edge_callback(void *context, bool added,
+ const cbm_graph_edge_identity_t *edge) {
+ compare_response_t *response = (compare_response_t *)context;
+ compare_result_set_t *set = added ? &response->edges_added : &response->edges_removed;
+ if (set->returned >= response->limit || set->budget_exhausted) {
+ return true;
+ }
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *item = doc ? yyjson_mut_obj(doc) : NULL;
+ yyjson_mut_val *source = doc ? compare_node_json(doc, &edge->source) : NULL;
+ yyjson_mut_val *target = doc ? compare_node_json(doc, &edge->target) : NULL;
+ bool ok = item && source && target && yyjson_mut_obj_add_val(doc, item, "source", source) &&
+ yyjson_mut_obj_add_val(doc, item, "target", target) &&
+ yyjson_mut_obj_add_strcpy(doc, item, "type", edge->type) &&
+ yyjson_mut_obj_add_strcpy(doc, item, "local_name_gen", edge->local_name_gen);
+ if (ok) {
+ yyjson_mut_doc_set_root(doc, item);
+ }
+ return compare_append_item(response, set, doc, ok ? item : NULL);
+}
+
+static bool compare_cancel_callback(void *context) {
+ compare_response_t *response = (compare_response_t *)context;
+ return mcp_request_cancelled(response->server);
+}
+
+static yyjson_mut_val *compare_project_json(yyjson_mut_doc *doc, const char *project,
+ const cbm_graph_compare_project_t *metadata) {
+ yyjson_mut_val *object = yyjson_mut_obj(doc);
+ if (!object || !yyjson_mut_obj_add_strcpy(doc, object, "project", project) ||
+ !yyjson_mut_obj_add_strcpy(doc, object, "generation", metadata->generation) ||
+ !yyjson_mut_obj_add_strcpy(doc, object, "index_mode", metadata->index_mode) ||
+ !yyjson_mut_obj_add_sint(doc, object, "node_count", metadata->node_count) ||
+ !yyjson_mut_obj_add_sint(doc, object, "edge_count", metadata->edge_count)) {
+ return NULL;
+ }
+ return object;
+}
+
+static yyjson_mut_val *compare_set_json(yyjson_mut_doc *doc, compare_result_set_t *set,
+ uint64_t total, size_t limit) {
+ yyjson_mut_val *object = yyjson_mut_obj(doc);
+ yyjson_mut_val *reasons = yyjson_mut_arr(doc);
+ bool truncated = total > (uint64_t)set->returned;
+ if (!object || !reasons || !yyjson_mut_obj_add_val(doc, object, "items", set->items) ||
+ !yyjson_mut_obj_add_uint(doc, object, "returned", set->returned) ||
+ !yyjson_mut_obj_add_uint(doc, object, "total", total) ||
+ !yyjson_mut_obj_add_bool(doc, object, "truncated", truncated)) {
+ return NULL;
+ }
+ if (truncated && set->returned >= limit && !yyjson_mut_arr_add_strcpy(doc, reasons, "limit")) {
+ return NULL;
+ }
+ if (truncated && set->budget_exhausted &&
+ !yyjson_mut_arr_add_strcpy(doc, reasons, "encoded_byte_budget")) {
+ return NULL;
+ }
+ if (!yyjson_mut_obj_add_val(doc, object, "truncation_reasons", reasons)) {
+ return NULL;
+ }
+ return object;
+}
+
+static char *handle_compare_graphs(cbm_mcp_server_t *server, const char *args) {
+ char *base_project = NULL;
+ char *target_project = NULL;
+ uint64_t limit = 0;
+ uint64_t scan_limit = 0;
+ const char *argument_error = NULL;
+ if (!compare_parse_arguments(args, &base_project, &target_project, &limit, &scan_limit,
+ &argument_error)) {
+ return compare_graphs_error("invalid_arguments", argument_error);
+ }
+ if (mcp_request_cancelled(server)) {
+ free(base_project);
+ free(target_project);
+ return compare_graphs_error("cancelled", "compare_graphs cancelled for this request");
+ }
+
+ cbm_store_t *base_store = compare_open_project_store(base_project);
+ if (!base_store) {
+ free(base_project);
+ free(target_project);
+ return compare_graphs_error("project_not_indexed", "base project is not indexed");
+ }
+ cbm_store_t *target_store = compare_open_project_store(target_project);
+ if (!target_store) {
+ cbm_store_close(base_store);
+ free(base_project);
+ free(target_project);
+ return compare_graphs_error("project_not_indexed", "target project is not indexed");
+ }
+
+ yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *root = doc ? yyjson_mut_obj(doc) : NULL;
+ compare_response_t response = {
+ .server = server,
+ .doc = doc,
+ .limit = (size_t)limit,
+ .nodes_added = {.items = doc ? yyjson_mut_arr(doc) : NULL, .encoded_bytes = 2U},
+ .nodes_removed = {.items = doc ? yyjson_mut_arr(doc) : NULL, .encoded_bytes = 2U},
+ .edges_added = {.items = doc ? yyjson_mut_arr(doc) : NULL, .encoded_bytes = 2U},
+ .edges_removed = {.items = doc ? yyjson_mut_arr(doc) : NULL, .encoded_bytes = 2U},
+ };
+ if (!doc || !root || !response.nodes_added.items || !response.nodes_removed.items ||
+ !response.edges_added.items || !response.edges_removed.items) {
+ cbm_store_close(target_store);
+ cbm_store_close(base_store);
+ yyjson_mut_doc_free(doc);
+ free(base_project);
+ free(target_project);
+ return compare_graphs_error("allocation_failed", "could not allocate comparison result");
+ }
+ yyjson_mut_doc_set_root(doc, root);
+
+ cbm_graph_compare_result_t comparison = {0};
+ int compare_rc = cbm_store_compare_graphs(
+ base_store, base_project, target_store, target_project, scan_limit, compare_cancel_callback,
+ compare_node_callback, compare_edge_callback, &response, &comparison);
+ cbm_store_close(target_store);
+ cbm_store_close(base_store);
+
+ if (compare_rc != CBM_STORE_OK) {
+ yyjson_mut_doc_free(doc);
+ free(base_project);
+ free(target_project);
+ if (compare_rc == CBM_STORE_CANCELLED) {
+ return compare_graphs_error("cancelled", "compare_graphs cancelled for this request");
+ }
+ if (compare_rc == CBM_STORE_NOT_FOUND) {
+ return compare_graphs_error("project_not_indexed", "project is not indexed");
+ }
+ if (compare_rc == CBM_STORE_SCAN_LIMIT) {
+ return compare_graphs_error("scan_limit_exceeded",
+ "combined graph rows exceed scan_limit");
+ }
+ if (compare_rc == CBM_STORE_CALLBACK_ERR) {
+ return compare_graphs_error("allocation_failed",
+ "could not allocate comparison result");
+ }
+ return compare_graphs_error("query_failed", "graph comparison query failed");
+ }
+
+ yyjson_mut_val *base = compare_project_json(doc, base_project, &comparison.base);
+ yyjson_mut_val *target = compare_project_json(doc, target_project, &comparison.target);
+ yyjson_mut_val *nodes = yyjson_mut_obj(doc);
+ yyjson_mut_val *edges = yyjson_mut_obj(doc);
+ yyjson_mut_val *limits = yyjson_mut_obj(doc);
+ yyjson_mut_val *nodes_added =
+ compare_set_json(doc, &response.nodes_added, comparison.nodes_added_total, response.limit);
+ yyjson_mut_val *nodes_removed = compare_set_json(
+ doc, &response.nodes_removed, comparison.nodes_removed_total, response.limit);
+ yyjson_mut_val *edges_added =
+ compare_set_json(doc, &response.edges_added, comparison.edges_added_total, response.limit);
+ yyjson_mut_val *edges_removed = compare_set_json(
+ doc, &response.edges_removed, comparison.edges_removed_total, response.limit);
+ bool built =
+ base && target && nodes && edges && limits && nodes_added && nodes_removed && edges_added &&
+ edges_removed && yyjson_mut_obj_add_int(doc, root, "schema_version", 1) &&
+ yyjson_mut_obj_add_val(doc, root, "base", base) &&
+ yyjson_mut_obj_add_val(doc, root, "target", target) &&
+ yyjson_mut_obj_add_val(doc, nodes, "added", nodes_added) &&
+ yyjson_mut_obj_add_val(doc, nodes, "removed", nodes_removed) &&
+ yyjson_mut_obj_add_val(doc, root, "nodes", nodes) &&
+ yyjson_mut_obj_add_val(doc, edges, "added", edges_added) &&
+ yyjson_mut_obj_add_val(doc, edges, "removed", edges_removed) &&
+ yyjson_mut_obj_add_val(doc, root, "edges", edges) &&
+ yyjson_mut_obj_add_uint(doc, limits, "limit", limit) &&
+ yyjson_mut_obj_add_uint(doc, limits, "scan_limit", scan_limit) &&
+ yyjson_mut_obj_add_uint(doc, limits, "encoded_byte_budget", MCP_COMPARE_SET_BYTE_BUDGET) &&
+ yyjson_mut_obj_add_val(doc, root, "limits", limits);
+ free(base_project);
+ free(target_project);
+ if (!built) {
+ yyjson_mut_doc_free(doc);
+ return compare_graphs_error("allocation_failed", "could not allocate comparison result");
+ }
+ if (mcp_request_cancelled(server)) {
+ yyjson_mut_doc_free(doc);
+ return compare_graphs_error("cancelled", "compare_graphs cancelled for this request");
+ }
+
+ char *json = yy_doc_to_str(doc);
+ yyjson_mut_doc_free(doc);
+ if (!json) {
+ return compare_graphs_error("allocation_failed", "could not serialize comparison result");
+ }
+ char *result = cbm_mcp_text_result(json, false);
+ free(json);
+ return result;
+}
+
static bool sg_field_blocked(const char *f); /* internal-only fields, defined with search_graph */
static char *handle_get_graph_schema(cbm_mcp_server_t *srv, const char *args) {
@@ -11413,6 +11833,9 @@ static char *dispatch_tool(cbm_mcp_server_t *srv, const char *tool_name, const c
if (strcmp(tool_name, "get_graph_schema") == 0) {
return handle_get_graph_schema(srv, args_json);
}
+ if (strcmp(tool_name, "compare_graphs") == 0) {
+ return handle_compare_graphs(srv, args_json);
+ }
if (strcmp(tool_name, "search_graph") == 0) {
return handle_search_graph(srv, args_json);
}
diff --git a/src/store/store.c b/src/store/store.c
index ad0b7967b..0f2a5ead6 100644
--- a/src/store/store.c
+++ b/src/store/store.c
@@ -1208,6 +1208,390 @@ int cbm_store_rollback(cbm_store_t *s) {
return exec_sql(s, "ROLLBACK;");
}
+/* ── Graph comparison ────────────────────────────────────────────── */
+
+typedef struct {
+ cbm_graph_compare_cancel_fn cancel;
+ void *context;
+} graph_compare_progress_t;
+
+typedef struct {
+ sqlite3_stmt *stmt;
+ cbm_graph_node_identity_t identity;
+ bool has_row;
+} graph_node_cursor_t;
+
+typedef struct {
+ sqlite3_stmt *stmt;
+ cbm_graph_edge_identity_t identity;
+ bool has_row;
+} graph_edge_cursor_t;
+
+static bool graph_compare_is_cancelled(const graph_compare_progress_t *progress) {
+ return progress && progress->cancel && progress->cancel(progress->context);
+}
+
+static int graph_compare_progress(void *context) {
+ return graph_compare_is_cancelled((const graph_compare_progress_t *)context) ? 1 : 0;
+}
+
+/* SQLite's BINARY collation compares the unsigned bytes of the UTF-8 text.
+ * Keep the merge comparator byte-for-byte equivalent so neither cursor can
+ * be advanced past a row that SQLite considers distinct. */
+static int graph_binary_compare(const char *left, const char *right) {
+ const unsigned char *a = (const unsigned char *)safe_str(left);
+ const unsigned char *b = (const unsigned char *)safe_str(right);
+ while (*a && *a == *b) {
+ a++;
+ b++;
+ }
+ return (*a > *b) - (*a < *b);
+}
+
+static int graph_node_compare(const cbm_graph_node_identity_t *left,
+ const cbm_graph_node_identity_t *right) {
+ int cmp = graph_binary_compare(left->qualified_name, right->qualified_name);
+ if (cmp == 0) {
+ cmp = graph_binary_compare(left->label, right->label);
+ }
+ if (cmp == 0) {
+ cmp = graph_binary_compare(left->file_path, right->file_path);
+ }
+ return cmp;
+}
+
+static int graph_edge_compare(const cbm_graph_edge_identity_t *left,
+ const cbm_graph_edge_identity_t *right) {
+ int cmp = graph_node_compare(&left->source, &right->source);
+ if (cmp == 0) {
+ cmp = graph_node_compare(&left->target, &right->target);
+ }
+ if (cmp == 0) {
+ cmp = graph_binary_compare(left->type, right->type);
+ }
+ if (cmp == 0) {
+ cmp = graph_binary_compare(left->local_name_gen, right->local_name_gen);
+ }
+ return cmp;
+}
+
+static int graph_count_rows(cbm_store_t *store, const char *sql, const char *project,
+ int64_t *out) {
+ sqlite3_stmt *stmt = NULL;
+ if (sqlite3_prepare_v2(store->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
+ store_set_error_sqlite(store, "compare count prepare");
+ return CBM_STORE_ERR;
+ }
+ bind_text(stmt, SKIP_ONE, project);
+ int step_rc = sqlite3_step(stmt);
+ if (step_rc != SQLITE_ROW) {
+ store_set_error_sqlite(store, "compare count");
+ sqlite3_finalize(stmt);
+ return step_rc == SQLITE_INTERRUPT ? CBM_STORE_CANCELLED : CBM_STORE_ERR;
+ }
+ *out = sqlite3_column_int64(stmt, 0);
+ step_rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+ if (step_rc != SQLITE_DONE) {
+ store_set_error_sqlite(store, "compare count finish");
+ return step_rc == SQLITE_INTERRUPT ? CBM_STORE_CANCELLED : CBM_STORE_ERR;
+ }
+ return CBM_STORE_OK;
+}
+
+static int graph_capture_project(cbm_store_t *store, const char *project,
+ cbm_graph_compare_project_t *out) {
+ cbm_project_t row = {0};
+ int rc = cbm_store_get_project(store, project, &row);
+ if (rc != CBM_STORE_OK) {
+ return rc;
+ }
+ if (!row.name || !row.indexed_at || !row.root_path) {
+ cbm_project_free_fields(&row);
+ store_set_error(store, "compare project allocation failed");
+ return CBM_STORE_ERR;
+ }
+
+ snprintf(out->generation, sizeof(out->generation), "%s", safe_str(row.indexed_at));
+ snprintf(out->index_mode, sizeof(out->index_mode), "unknown");
+
+ cbm_coverage_meta_t meta = {0};
+ rc = cbm_store_coverage_meta_get(store, project, &meta);
+ if (rc == CBM_STORE_OK) {
+ snprintf(out->generation, sizeof(out->generation), "%s", safe_str(meta.generation));
+ snprintf(out->index_mode, sizeof(out->index_mode), "%s", safe_str(meta.index_mode));
+ cbm_store_coverage_meta_clear(&meta);
+ } else if (rc != CBM_STORE_NOT_FOUND) {
+ cbm_project_free_fields(&row);
+ return rc;
+ }
+ cbm_project_free_fields(&row);
+
+ rc = graph_count_rows(store, "SELECT count(*) FROM nodes WHERE project=?1;", project,
+ &out->node_count);
+ if (rc != CBM_STORE_OK) {
+ return rc;
+ }
+ return graph_count_rows(store, "SELECT count(*) FROM edges WHERE project=?1;", project,
+ &out->edge_count);
+}
+
+static int graph_node_cursor_step(cbm_store_t *store, graph_node_cursor_t *cursor) {
+ int rc = sqlite3_step(cursor->stmt);
+ if (rc == SQLITE_DONE) {
+ cursor->has_row = false;
+ return CBM_STORE_OK;
+ }
+ if (rc != SQLITE_ROW) {
+ store_set_error_sqlite(store, "compare node scan");
+ cursor->has_row = false;
+ return rc == SQLITE_INTERRUPT ? CBM_STORE_CANCELLED : CBM_STORE_ERR;
+ }
+ cursor->identity.qualified_name = safe_str((const char *)sqlite3_column_text(cursor->stmt, 0));
+ cursor->identity.label = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_1));
+ cursor->identity.file_path =
+ safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_2));
+ cursor->has_row = true;
+ return CBM_STORE_OK;
+}
+
+static int graph_edge_cursor_step(cbm_store_t *store, graph_edge_cursor_t *cursor) {
+ int rc = sqlite3_step(cursor->stmt);
+ if (rc == SQLITE_DONE) {
+ cursor->has_row = false;
+ return CBM_STORE_OK;
+ }
+ if (rc != SQLITE_ROW) {
+ store_set_error_sqlite(store, "compare edge scan");
+ cursor->has_row = false;
+ return rc == SQLITE_INTERRUPT ? CBM_STORE_CANCELLED : CBM_STORE_ERR;
+ }
+ cursor->identity.source.qualified_name =
+ safe_str((const char *)sqlite3_column_text(cursor->stmt, 0));
+ cursor->identity.source.label =
+ safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_1));
+ cursor->identity.source.file_path =
+ safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_2));
+ cursor->identity.target.qualified_name =
+ safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_3));
+ cursor->identity.target.label =
+ safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_4));
+ cursor->identity.target.file_path =
+ safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_5));
+ cursor->identity.type = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_6));
+ cursor->identity.local_name_gen =
+ safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_7));
+ cursor->has_row = true;
+ return CBM_STORE_OK;
+}
+
+static int graph_prepare_node_cursor(cbm_store_t *store, const char *project,
+ graph_node_cursor_t *cursor) {
+ static const char sql[] =
+ "SELECT qualified_name,label,coalesce(file_path,'') FROM nodes WHERE project=?1 "
+ "ORDER BY qualified_name COLLATE BINARY,label COLLATE BINARY,"
+ "coalesce(file_path,'') COLLATE BINARY;";
+ if (sqlite3_prepare_v2(store->db, sql, CBM_NOT_FOUND, &cursor->stmt, NULL) != SQLITE_OK) {
+ store_set_error_sqlite(store, "compare node prepare");
+ return CBM_STORE_ERR;
+ }
+ bind_text(cursor->stmt, SKIP_ONE, project);
+ return graph_node_cursor_step(store, cursor);
+}
+
+static int graph_prepare_edge_cursor(cbm_store_t *store, const char *project,
+ graph_edge_cursor_t *cursor) {
+ static const char sql[] =
+ "SELECT sn.qualified_name,sn.label,coalesce(sn.file_path,''),"
+ "tn.qualified_name,tn.label,coalesce(tn.file_path,''),e.type,"
+ "coalesce(e.local_name_gen,'') FROM edges e "
+ "JOIN nodes sn ON sn.id=e.source_id JOIN nodes tn ON tn.id=e.target_id "
+ "WHERE e.project=?1 ORDER BY "
+ "sn.qualified_name COLLATE BINARY,sn.label COLLATE BINARY,"
+ "coalesce(sn.file_path,'') COLLATE BINARY,"
+ "tn.qualified_name COLLATE BINARY,tn.label COLLATE BINARY,"
+ "coalesce(tn.file_path,'') COLLATE BINARY,"
+ "e.type COLLATE BINARY,coalesce(e.local_name_gen,'') COLLATE BINARY;";
+ if (sqlite3_prepare_v2(store->db, sql, CBM_NOT_FOUND, &cursor->stmt, NULL) != SQLITE_OK) {
+ store_set_error_sqlite(store, "compare edge prepare");
+ return CBM_STORE_ERR;
+ }
+ bind_text(cursor->stmt, SKIP_ONE, project);
+ return graph_edge_cursor_step(store, cursor);
+}
+
+static int graph_compare_nodes(cbm_store_t *base_store, const char *base_project,
+ cbm_store_t *target_store, const char *target_project,
+ graph_compare_progress_t *progress,
+ cbm_graph_compare_node_fn callback,
+ cbm_graph_compare_result_t *out) {
+ graph_node_cursor_t base = {0};
+ graph_node_cursor_t target = {0};
+ int rc = graph_prepare_node_cursor(base_store, base_project, &base);
+ if (rc == CBM_STORE_OK) {
+ rc = graph_prepare_node_cursor(target_store, target_project, &target);
+ }
+ uint64_t merged = 0;
+ while (rc == CBM_STORE_OK && (base.has_row || target.has_row)) {
+ if ((merged++ & UINT64_C(255)) == 0 && graph_compare_is_cancelled(progress)) {
+ rc = CBM_STORE_CANCELLED;
+ break;
+ }
+ int cmp =
+ !base.has_row
+ ? 1
+ : (!target.has_row ? -1 : graph_node_compare(&base.identity, &target.identity));
+ if (cmp < 0) {
+ out->nodes_removed_total++;
+ if (callback && !callback(progress->context, false, &base.identity)) {
+ rc = CBM_STORE_CALLBACK_ERR;
+ break;
+ }
+ rc = graph_node_cursor_step(base_store, &base);
+ } else if (cmp > 0) {
+ out->nodes_added_total++;
+ if (callback && !callback(progress->context, true, &target.identity)) {
+ rc = CBM_STORE_CALLBACK_ERR;
+ break;
+ }
+ rc = graph_node_cursor_step(target_store, &target);
+ } else {
+ rc = graph_node_cursor_step(base_store, &base);
+ if (rc == CBM_STORE_OK) {
+ rc = graph_node_cursor_step(target_store, &target);
+ }
+ }
+ }
+ sqlite3_finalize(base.stmt);
+ sqlite3_finalize(target.stmt);
+ return rc;
+}
+
+static int graph_compare_edges(cbm_store_t *base_store, const char *base_project,
+ cbm_store_t *target_store, const char *target_project,
+ graph_compare_progress_t *progress,
+ cbm_graph_compare_edge_fn callback,
+ cbm_graph_compare_result_t *out) {
+ graph_edge_cursor_t base = {0};
+ graph_edge_cursor_t target = {0};
+ int rc = graph_prepare_edge_cursor(base_store, base_project, &base);
+ if (rc == CBM_STORE_OK) {
+ rc = graph_prepare_edge_cursor(target_store, target_project, &target);
+ }
+ uint64_t merged = 0;
+ while (rc == CBM_STORE_OK && (base.has_row || target.has_row)) {
+ if ((merged++ & UINT64_C(255)) == 0 && graph_compare_is_cancelled(progress)) {
+ rc = CBM_STORE_CANCELLED;
+ break;
+ }
+ int cmp =
+ !base.has_row
+ ? 1
+ : (!target.has_row ? -1 : graph_edge_compare(&base.identity, &target.identity));
+ if (cmp < 0) {
+ out->edges_removed_total++;
+ if (callback && !callback(progress->context, false, &base.identity)) {
+ rc = CBM_STORE_CALLBACK_ERR;
+ break;
+ }
+ rc = graph_edge_cursor_step(base_store, &base);
+ } else if (cmp > 0) {
+ out->edges_added_total++;
+ if (callback && !callback(progress->context, true, &target.identity)) {
+ rc = CBM_STORE_CALLBACK_ERR;
+ break;
+ }
+ rc = graph_edge_cursor_step(target_store, &target);
+ } else {
+ rc = graph_edge_cursor_step(base_store, &base);
+ if (rc == CBM_STORE_OK) {
+ rc = graph_edge_cursor_step(target_store, &target);
+ }
+ }
+ }
+ sqlite3_finalize(base.stmt);
+ sqlite3_finalize(target.stmt);
+ return rc;
+}
+
+static bool graph_scan_limit_exceeded(int64_t base_count, int64_t target_count,
+ uint64_t scan_limit) {
+ if (base_count < 0 || target_count < 0 || (uint64_t)base_count > scan_limit ||
+ (uint64_t)target_count > scan_limit) {
+ return true;
+ }
+ return (uint64_t)base_count > scan_limit - (uint64_t)target_count;
+}
+
+int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project,
+ cbm_store_t *target_store, const char *target_project,
+ uint64_t scan_limit, cbm_graph_compare_cancel_fn cancel,
+ cbm_graph_compare_node_fn on_node, cbm_graph_compare_edge_fn on_edge,
+ void *context, cbm_graph_compare_result_t *out) {
+ if (!base_store || !target_store || base_store == target_store || !base_project ||
+ !base_project[0] || !target_project || !target_project[0] || scan_limit == 0 || !out) {
+ return CBM_STORE_ERR;
+ }
+ memset(out, 0, sizeof(*out));
+ graph_compare_progress_t progress = {.cancel = cancel, .context = context};
+ if (graph_compare_is_cancelled(&progress)) {
+ return CBM_STORE_CANCELLED;
+ }
+
+ int rc = CBM_STORE_OK;
+ bool base_transaction = false;
+ bool target_transaction = false;
+ sqlite3_progress_handler(base_store->db, 1000, graph_compare_progress, &progress);
+ sqlite3_progress_handler(target_store->db, 1000, graph_compare_progress, &progress);
+
+ rc = exec_sql(base_store, "BEGIN;");
+ if (rc == CBM_STORE_OK) {
+ base_transaction = true;
+ rc = exec_sql(target_store, "BEGIN;");
+ }
+ if (rc == CBM_STORE_OK) {
+ target_transaction = true;
+ rc = graph_capture_project(base_store, base_project, &out->base);
+ }
+ if (rc == CBM_STORE_OK) {
+ rc = graph_capture_project(target_store, target_project, &out->target);
+ }
+ if (rc == CBM_STORE_OK &&
+ (graph_scan_limit_exceeded(out->base.node_count, out->target.node_count, scan_limit) ||
+ graph_scan_limit_exceeded(out->base.edge_count, out->target.edge_count, scan_limit))) {
+ rc = CBM_STORE_SCAN_LIMIT;
+ }
+ if (rc == CBM_STORE_OK) {
+ rc = graph_compare_nodes(base_store, base_project, target_store, target_project, &progress,
+ on_node, out);
+ }
+ if (rc == CBM_STORE_OK) {
+ rc = graph_compare_edges(base_store, base_project, target_store, target_project, &progress,
+ on_edge, out);
+ }
+ if (rc == CBM_STORE_OK && graph_compare_is_cancelled(&progress)) {
+ rc = CBM_STORE_CANCELLED;
+ }
+ if (rc != CBM_STORE_OK && graph_compare_is_cancelled(&progress)) {
+ rc = CBM_STORE_CANCELLED;
+ }
+
+ sqlite3_progress_handler(base_store->db, 0, NULL, NULL);
+ sqlite3_progress_handler(target_store->db, 0, NULL, NULL);
+ if (target_transaction && exec_sql(target_store, "ROLLBACK;") != CBM_STORE_OK &&
+ rc == CBM_STORE_OK) {
+ rc = CBM_STORE_ERR;
+ }
+ if (base_transaction && exec_sql(base_store, "ROLLBACK;") != CBM_STORE_OK &&
+ rc == CBM_STORE_OK) {
+ rc = CBM_STORE_ERR;
+ }
+ if (rc != CBM_STORE_OK) {
+ memset(out, 0, sizeof(*out));
+ }
+ return rc;
+}
+
/* ── Bulk write ─────────────────────────────────────────────────── */
int cbm_store_begin_bulk(cbm_store_t *s) {
diff --git a/src/store/store.h b/src/store/store.h
index 45a7bb439..43c189792 100644
--- a/src/store/store.h
+++ b/src/store/store.h
@@ -24,6 +24,9 @@ typedef struct cbm_store cbm_store_t;
#define CBM_STORE_ERR (-1)
#define CBM_STORE_NOT_FOUND (-2)
#define CBM_INDEX_FORMAT_VERSION 1
+#define CBM_STORE_CANCELLED (-3)
+#define CBM_STORE_SCAN_LIMIT (-4)
+#define CBM_STORE_CALLBACK_ERR (-5)
/* ── Data structures ────────────────────────────────────────────── */
@@ -255,6 +258,56 @@ typedef struct {
int sample_qn_count;
} cbm_schema_info_t;
+/* ── Graph comparison ──────────────────────────────────────────── */
+
+/* Stable graph identities deliberately exclude the project name. Strings are
+ * borrowed from the active SQLite row and remain valid only for the duration
+ * of the callback. */
+typedef struct {
+ const char *qualified_name;
+ const char *label;
+ const char *file_path;
+} cbm_graph_node_identity_t;
+
+typedef struct {
+ cbm_graph_node_identity_t source;
+ cbm_graph_node_identity_t target;
+ const char *type;
+ const char *local_name_gen;
+} cbm_graph_edge_identity_t;
+
+typedef bool (*cbm_graph_compare_cancel_fn)(void *context);
+typedef bool (*cbm_graph_compare_node_fn)(void *context, bool added,
+ const cbm_graph_node_identity_t *node);
+typedef bool (*cbm_graph_compare_edge_fn)(void *context, bool added,
+ const cbm_graph_edge_identity_t *edge);
+
+typedef struct {
+ char generation[128];
+ char index_mode[32];
+ int64_t node_count;
+ int64_t edge_count;
+} cbm_graph_compare_project_t;
+
+typedef struct {
+ cbm_graph_compare_project_t base;
+ cbm_graph_compare_project_t target;
+ uint64_t nodes_added_total;
+ uint64_t nodes_removed_total;
+ uint64_t edges_added_total;
+ uint64_t edges_removed_total;
+} cbm_graph_compare_result_t;
+
+/* Compare two independently-owned read-only stores using ordered streaming
+ * cursors. Both read transactions and both SQLite progress handlers are owned
+ * by this call and released on every exit. scan_limit applies independently to
+ * the combined node rows and combined edge rows across the two projects. */
+int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project,
+ cbm_store_t *target_store, const char *target_project,
+ uint64_t scan_limit, cbm_graph_compare_cancel_fn cancel,
+ cbm_graph_compare_node_fn on_node, cbm_graph_compare_edge_fn on_edge,
+ void *context, cbm_graph_compare_result_t *out);
+
/* ── Lifecycle ──────────────────────────────────────────────────── */
/* Open an in-memory database (for testing). */
diff --git a/tests/test_mcp.c b/tests/test_mcp.c
index 6b7af64ed..fbf59823e 100644
--- a/tests/test_mcp.c
+++ b/tests/test_mcp.c
@@ -914,6 +914,7 @@ TEST(mcp_tools_have_behavior_annotations) {
{"trace_path", false, true, true, false},
{"get_code_snippet", false, true, true, false},
{"get_graph_schema", false, true, true, false},
+ {"compare_graphs", true, false, true, false},
{"get_architecture", false, true, true, false},
{"search_code", false, true, true, false},
{"list_projects", true, false, true, false},
@@ -1359,9 +1360,9 @@ TEST(server_handle_analysis_profile_filters_and_rejects_mutators) {
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":220,\"method\":\"tools/list\"}");
ASSERT_NOT_NULL(resp);
static const char *const analysis_tools[] = {
- "search_graph", "query_graph", "trace_path", "get_code_snippet",
- "get_graph_schema", "get_architecture", "search_code", "list_projects",
- "index_status", "check_index_coverage", "detect_changes",
+ "search_graph", "query_graph", "trace_path", "get_code_snippet",
+ "get_graph_schema", "compare_graphs", "get_architecture", "search_code",
+ "list_projects", "index_status", "check_index_coverage", "detect_changes",
};
ASSERT_EQ(mcp_response_tool_count(resp), sizeof(analysis_tools) / sizeof(analysis_tools[0]));
for (size_t i = 0U; i < sizeof(analysis_tools) / sizeof(analysis_tools[0]); i++) {
@@ -1411,6 +1412,7 @@ TEST(server_handle_scout_profile_exposes_only_the_fast_tier) {
ASSERT_FALSE(mcp_response_has_exact_tool(resp, "query_graph"));
ASSERT_FALSE(mcp_response_has_exact_tool(resp, "search_code"));
ASSERT_FALSE(mcp_response_has_exact_tool(resp, "get_graph_schema"));
+ ASSERT_FALSE(mcp_response_has_exact_tool(resp, "compare_graphs"));
ASSERT_FALSE(mcp_response_has_exact_tool(resp, "detect_changes"));
ASSERT_FALSE(mcp_response_has_exact_tool(resp, "index_repository"));
free(resp);
@@ -1670,6 +1672,24 @@ TEST(tool_unknown_tool) {
PASS();
}
+/* Issue #525: compare_graphs is a first-class analysis tool. The frozen base
+ * routes this call to the unknown-tool fallback; production registration is
+ * therefore required before any compare semantics can accidentally look green. */
+TEST(tool_compare_graphs_registered_issue525) {
+ cbm_mcp_server_t *srv = setup_mcp_with_data();
+
+ char *resp = cbm_mcp_server_handle(
+ srv, "{\"jsonrpc\":\"2.0\",\"id\":525,\"method\":\"tools/call\","
+ "\"params\":{\"name\":\"compare_graphs\",\"arguments\":{"
+ "\"base_project\":\"base525\",\"target_project\":\"target525\"}}}");
+ ASSERT_NOT_NULL(resp);
+ ASSERT_NULL(strstr(resp, "unknown tool: compare_graphs"));
+ free(resp);
+
+ cbm_mcp_server_free(srv);
+ PASS();
+}
+
TEST(tool_search_graph_basic) {
cbm_mcp_server_t *srv = setup_mcp_with_data();
@@ -7923,6 +7943,485 @@ static char *extract_text_content(const char *mcp_result) {
return result;
}
+typedef struct {
+ char cache[512];
+ char base_path[768];
+ char target_path[768];
+ char *saved_cache;
+} compare_graphs_fixture_t;
+
+typedef struct {
+ const char *qualified_name;
+ const char *label;
+ const char *file_path;
+} compare_node_spec_t;
+
+static bool compare_write_coverage(cbm_store_t *store, const char *project,
+ const char *generation) {
+ cbm_coverage_meta_t meta = {
+ .generation = generation,
+ .index_mode = "full",
+ .recorded_at = "2026-08-28T00:00:00Z",
+ .recording_status = "complete",
+ .ignored_files_stored = 0,
+ .ignored_files_total = 0,
+ .coverage_version = 1,
+ .hash_records_complete = true,
+ };
+ return cbm_store_coverage_replace_ex(store, project, NULL, 0, &meta) == CBM_STORE_OK;
+}
+
+static bool compare_write_fixture_store(const char *path, const char *project, bool target) {
+ static const compare_node_spec_t base_nodes[] = {
+ {"pkg.common", "Function", "src/common.c"}, {"pkg.base", "Function", "src/base.c"},
+ {"pkg.changed", "Function", "src/old.c"}, {"pkg.source", "Module", "src/source.c"},
+ {"pkg.sink", "Module", "src/sink.c"},
+ };
+ static const compare_node_spec_t target_nodes[] = {
+ {"pkg.common", "Function", "src/common.c"}, {"pkg.target", "Function", "src/target.c"},
+ {"pkg.changed", "Method", "src/new.c"}, {"pkg.source", "Module", "src/source.c"},
+ {"pkg.sink", "Module", "src/sink.c"},
+ };
+ static const int base_order[] = {3, 2, 0, 4, 1};
+ static const int target_order[] = {1, 4, 0, 2, 3};
+ const compare_node_spec_t *nodes = target ? target_nodes : base_nodes;
+ const int *order = target ? target_order : base_order;
+
+ cbm_store_t *store = cbm_store_open_path(path);
+ if (!store ||
+ cbm_store_upsert_project(store, project, "/tmp/compare-graphs-525") != CBM_STORE_OK) {
+ cbm_store_close(store);
+ return false;
+ }
+ int64_t ids[5] = {0};
+ bool ok = true;
+ for (size_t position = 0; position < 5; position++) {
+ int index = order[position];
+ cbm_node_t node = {
+ .project = project,
+ .label = nodes[index].label,
+ .name = nodes[index].qualified_name,
+ .qualified_name = nodes[index].qualified_name,
+ .file_path = nodes[index].file_path,
+ .properties_json = "{}",
+ };
+ ids[index] = cbm_store_upsert_node(store, &node);
+ ok = ok && ids[index] > 0;
+ }
+
+ cbm_edge_t common_call = {
+ .project = project,
+ .source_id = ids[3],
+ .target_id = ids[4],
+ .type = "CALLS",
+ .properties_json = "{}",
+ };
+ cbm_edge_t common_import = {
+ .project = project,
+ .source_id = ids[3],
+ .target_id = ids[4],
+ .type = "IMPORTS",
+ .properties_json = "{\"local_name\":\"Alpha\"}",
+ };
+ cbm_edge_t changed_import = {
+ .project = project,
+ .source_id = ids[3],
+ .target_id = ids[4],
+ .type = "IMPORTS",
+ .properties_json = target ? "{\"local_name\":\"Gamma\"}" : "{\"local_name\":\"Beta\"}",
+ };
+ cbm_edge_t side_edge = {
+ .project = project,
+ .source_id = ids[1],
+ .target_id = ids[0],
+ .type = "USES",
+ .properties_json = "{}",
+ };
+ if (target) {
+ ok = ok && cbm_store_insert_edge(store, &side_edge) > 0 &&
+ cbm_store_insert_edge(store, &changed_import) > 0 &&
+ cbm_store_insert_edge(store, &common_call) > 0 &&
+ cbm_store_insert_edge(store, &common_import) > 0;
+ } else {
+ ok = ok && cbm_store_insert_edge(store, &common_import) > 0 &&
+ cbm_store_insert_edge(store, &common_call) > 0 &&
+ cbm_store_insert_edge(store, &side_edge) > 0 &&
+ cbm_store_insert_edge(store, &changed_import) > 0;
+ }
+ ok = ok &&
+ compare_write_coverage(store, project,
+ target ? "target-generation-525" : "base-generation-525") &&
+ cbm_store_prepare_for_publish(store) == CBM_STORE_OK;
+ cbm_store_close(store);
+ return ok;
+}
+
+static bool compare_graphs_fixture_open(compare_graphs_fixture_t *fixture) {
+ memset(fixture, 0, sizeof(*fixture));
+ snprintf(fixture->cache, sizeof(fixture->cache), "%s/cbm-compare-525-XXXXXX", cbm_tmpdir());
+ if (!cbm_mkdtemp(fixture->cache)) {
+ return false;
+ }
+ const char *saved = getenv("CBM_CACHE_DIR");
+ fixture->saved_cache = saved ? strdup(saved) : NULL;
+ if ((saved && !fixture->saved_cache) || cbm_setenv("CBM_CACHE_DIR", fixture->cache, 1) != 0) {
+ free(fixture->saved_cache);
+ fixture->saved_cache = NULL;
+ (void)th_rmtree(fixture->cache);
+ return false;
+ }
+ snprintf(fixture->base_path, sizeof(fixture->base_path), "%s/base525.db", fixture->cache);
+ snprintf(fixture->target_path, sizeof(fixture->target_path), "%s/target525.db", fixture->cache);
+ if (!compare_write_fixture_store(fixture->base_path, "base525", false) ||
+ !compare_write_fixture_store(fixture->target_path, "target525", true)) {
+ restore_cache_dir(fixture->saved_cache);
+ free(fixture->saved_cache);
+ fixture->saved_cache = NULL;
+ (void)th_rmtree(fixture->cache);
+ return false;
+ }
+ return true;
+}
+
+static bool compare_graphs_fixture_close(compare_graphs_fixture_t *fixture) {
+ restore_cache_dir(fixture->saved_cache);
+ free(fixture->saved_cache);
+ fixture->saved_cache = NULL;
+ return th_rmtree(fixture->cache) == 0;
+}
+
+static char *compare_graphs_call(cbm_mcp_server_t *server, const char *extra_arguments) {
+ char arguments[1024];
+ snprintf(arguments, sizeof(arguments),
+ "{\"base_project\":\"base525\",\"target_project\":\"target525\"%s}",
+ extra_arguments ? extra_arguments : "");
+ return cbm_mcp_handle_tool(server, "compare_graphs", arguments);
+}
+
+static bool compare_set_has(const yyjson_val *set, uint64_t total, uint64_t returned,
+ bool truncated, const char *reason) {
+ if (!set || !yyjson_is_obj(set)) {
+ return false;
+ }
+ yyjson_val *items = yyjson_obj_get(set, "items");
+ yyjson_val *reasons = yyjson_obj_get(set, "truncation_reasons");
+ return items && yyjson_is_arr(items) && yyjson_arr_size(items) == returned && reasons &&
+ yyjson_is_arr(reasons) && yyjson_get_uint(yyjson_obj_get(set, "total")) == total &&
+ yyjson_get_uint(yyjson_obj_get(set, "returned")) == returned &&
+ yyjson_get_bool(yyjson_obj_get(set, "truncated")) == truncated &&
+ ((!reason && yyjson_arr_size(reasons) == 0) ||
+ (reason && yyjson_arr_size(reasons) == 1 &&
+ strcmp(yyjson_get_str(yyjson_arr_get(reasons, 0)), reason) == 0));
+}
+
+TEST(tool_compare_graphs_streams_stable_deltas_issue525) {
+ compare_graphs_fixture_t fixture;
+ ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
+ cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
+ ASSERT_NOT_NULL(server);
+ cbm_mcp_server_set_tool_profile(server, CBM_MCP_TOOL_PROFILE_ANALYSIS);
+
+ char *first = compare_graphs_call(server, NULL);
+ char *second = compare_graphs_call(server, NULL);
+ ASSERT_NOT_NULL(first);
+ ASSERT_NOT_NULL(second);
+ ASSERT_STR_EQ(first, second);
+ ASSERT_NOT_NULL(strstr(first, "\"isError\":false"));
+ char *inner = extract_text_content(first);
+ ASSERT_NOT_NULL(inner);
+ yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0);
+ ASSERT_NOT_NULL(doc);
+ yyjson_val *root = yyjson_doc_get_root(doc);
+ ASSERT_EQ(yyjson_get_int(yyjson_obj_get(root, "schema_version")), 1);
+
+ yyjson_val *base = yyjson_obj_get(root, "base");
+ yyjson_val *target = yyjson_obj_get(root, "target");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(base, "project")), "base525");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(base, "generation")), "base-generation-525");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(base, "index_mode")), "full");
+ ASSERT_EQ(yyjson_get_int(yyjson_obj_get(base, "node_count")), 5);
+ ASSERT_EQ(yyjson_get_int(yyjson_obj_get(base, "edge_count")), 4);
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(target, "project")), "target525");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(target, "generation")), "target-generation-525");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(target, "index_mode")), "full");
+ ASSERT_EQ(yyjson_get_int(yyjson_obj_get(target, "node_count")), 5);
+ ASSERT_EQ(yyjson_get_int(yyjson_obj_get(target, "edge_count")), 4);
+
+ yyjson_val *nodes = yyjson_obj_get(root, "nodes");
+ yyjson_val *nodes_added = yyjson_obj_get(nodes, "added");
+ yyjson_val *nodes_removed = yyjson_obj_get(nodes, "removed");
+ ASSERT_TRUE(compare_set_has(nodes_added, 2, 2, false, NULL));
+ ASSERT_TRUE(compare_set_has(nodes_removed, 2, 2, false, NULL));
+ yyjson_val *added_items = yyjson_obj_get(nodes_added, "items");
+ yyjson_val *removed_items = yyjson_obj_get(nodes_removed, "items");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(added_items, 0), "qualified_name")),
+ "pkg.changed");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(added_items, 0), "label")),
+ "Method");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(added_items, 0), "file_path")),
+ "src/new.c");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(added_items, 1), "qualified_name")),
+ "pkg.target");
+ ASSERT_STR_EQ(
+ yyjson_get_str(yyjson_obj_get(yyjson_arr_get(removed_items, 0), "qualified_name")),
+ "pkg.base");
+ ASSERT_STR_EQ(
+ yyjson_get_str(yyjson_obj_get(yyjson_arr_get(removed_items, 1), "qualified_name")),
+ "pkg.changed");
+
+ yyjson_val *edges = yyjson_obj_get(root, "edges");
+ yyjson_val *edges_added = yyjson_obj_get(edges, "added");
+ yyjson_val *edges_removed = yyjson_obj_get(edges, "removed");
+ ASSERT_TRUE(compare_set_has(edges_added, 2, 2, false, NULL));
+ ASSERT_TRUE(compare_set_has(edges_removed, 2, 2, false, NULL));
+ yyjson_val *edge_add_items = yyjson_obj_get(edges_added, "items");
+ yyjson_val *edge_remove_items = yyjson_obj_get(edges_removed, "items");
+ ASSERT_STR_EQ(
+ yyjson_get_str(yyjson_obj_get(yyjson_arr_get(edge_add_items, 0), "local_name_gen")),
+ "Gamma");
+ ASSERT_STR_EQ(
+ yyjson_get_str(yyjson_obj_get(yyjson_arr_get(edge_remove_items, 1), "local_name_gen")),
+ "Beta");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(edge_add_items, 1), "type")),
+ "USES");
+ ASSERT_STR_EQ(
+ yyjson_get_str(yyjson_obj_get(yyjson_arr_get(edge_add_items, 1), "local_name_gen")), "");
+ yyjson_val *edge_source = yyjson_obj_get(yyjson_arr_get(edge_add_items, 0), "source");
+ yyjson_val *edge_target = yyjson_obj_get(yyjson_arr_get(edge_add_items, 0), "target");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(edge_source, "qualified_name")), "pkg.source");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(edge_target, "qualified_name")), "pkg.sink");
+
+ yyjson_val *limits = yyjson_obj_get(root, "limits");
+ ASSERT_EQ(yyjson_get_uint(yyjson_obj_get(limits, "limit")), 200);
+ ASSERT_EQ(yyjson_get_uint(yyjson_obj_get(limits, "scan_limit")), 2000000);
+ ASSERT_EQ(yyjson_get_uint(yyjson_obj_get(limits, "encoded_byte_budget")), 512U * 1024U);
+ yyjson_doc_free(doc);
+ free(inner);
+ free(second);
+ free(first);
+
+ char *limited = compare_graphs_call(server, ",\"limit\":1");
+ ASSERT_NOT_NULL(limited);
+ inner = extract_text_content(limited);
+ doc = yyjson_read(inner, strlen(inner), 0);
+ ASSERT_NOT_NULL(doc);
+ root = yyjson_doc_get_root(doc);
+ nodes = yyjson_obj_get(root, "nodes");
+ edges = yyjson_obj_get(root, "edges");
+ ASSERT_TRUE(compare_set_has(yyjson_obj_get(nodes, "added"), 2, 1, true, "limit"));
+ ASSERT_TRUE(compare_set_has(yyjson_obj_get(nodes, "removed"), 2, 1, true, "limit"));
+ ASSERT_TRUE(compare_set_has(yyjson_obj_get(edges, "added"), 2, 1, true, "limit"));
+ ASSERT_TRUE(compare_set_has(yyjson_obj_get(edges, "removed"), 2, 1, true, "limit"));
+ yyjson_doc_free(doc);
+ free(inner);
+ free(limited);
+
+ cbm_mcp_server_free(server);
+ ASSERT_TRUE(compare_graphs_fixture_close(&fixture));
+ PASS();
+}
+
+static bool compare_write_single_node_store(const char *path, const char *project,
+ const char *generation, const char *qualified_name) {
+ cbm_store_t *store = cbm_store_open_path(path);
+ if (!store ||
+ cbm_store_upsert_project(store, project, "/tmp/compare-budget-525") != CBM_STORE_OK) {
+ cbm_store_close(store);
+ return false;
+ }
+ bool ok = true;
+ if (qualified_name) {
+ cbm_node_t node = {
+ .project = project,
+ .label = "Function",
+ .name = "large",
+ .qualified_name = qualified_name,
+ .file_path = "src/large.c",
+ .properties_json = "{}",
+ };
+ ok = cbm_store_upsert_node(store, &node) > 0;
+ }
+ ok = ok && compare_write_coverage(store, project, generation) &&
+ cbm_store_prepare_for_publish(store) == CBM_STORE_OK;
+ cbm_store_close(store);
+ return ok;
+}
+
+TEST(tool_compare_graphs_enforces_encoded_budget_issue525) {
+ compare_graphs_fixture_t fixture;
+ ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
+ char base_path[768];
+ char target_path[768];
+ snprintf(base_path, sizeof(base_path), "%s/budgetbase525.db", fixture.cache);
+ snprintf(target_path, sizeof(target_path), "%s/budgettarget525.db", fixture.cache);
+ size_t name_length = 530000U;
+ char *large_name = malloc(name_length + 1U);
+ ASSERT_NOT_NULL(large_name);
+ memset(large_name, 'x', name_length);
+ large_name[0] = 'z';
+ large_name[name_length] = '\0';
+ ASSERT_TRUE(compare_write_single_node_store(base_path, "budgetbase525", "budget-base", NULL));
+ ASSERT_TRUE(compare_write_single_node_store(target_path, "budgettarget525", "budget-target",
+ large_name));
+ free(large_name);
+
+ cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
+ ASSERT_NOT_NULL(server);
+ char *response = cbm_mcp_handle_tool(
+ server, "compare_graphs",
+ "{\"base_project\":\"budgetbase525\",\"target_project\":\"budgettarget525\","
+ "\"limit\":1000}");
+ ASSERT_NOT_NULL(response);
+ ASSERT_LT(strlen(response), 10000U);
+ char *inner = extract_text_content(response);
+ yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL;
+ ASSERT_NOT_NULL(doc);
+ yyjson_val *nodes = yyjson_obj_get(yyjson_doc_get_root(doc), "nodes");
+ ASSERT_TRUE(compare_set_has(yyjson_obj_get(nodes, "added"), 1, 0, true, "encoded_byte_budget"));
+ yyjson_doc_free(doc);
+ free(inner);
+ free(response);
+ cbm_mcp_server_free(server);
+ ASSERT_TRUE(compare_graphs_fixture_close(&fixture));
+ PASS();
+}
+
+TEST(tool_compare_graphs_validation_and_scan_cap_are_atomic_issue525) {
+ compare_graphs_fixture_t fixture;
+ ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
+ cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
+ ASSERT_NOT_NULL(server);
+
+ char equal_path[768];
+ char equal_wal[800];
+ char equal_shm[800];
+ snprintf(equal_path, sizeof(equal_path), "%s/same525.db", fixture.cache);
+ snprintf(equal_wal, sizeof(equal_wal), "%s-wal", equal_path);
+ snprintf(equal_shm, sizeof(equal_shm), "%s-shm", equal_path);
+ ASSERT_FALSE(cbm_file_exists(equal_path));
+
+ static const char *const invalid_arguments[] = {
+ "{}",
+ "{\"base_project\":\"same525\",\"target_project\":\"same525\"}",
+ "{\"base_project\":\"base525\",\"target_project\":\"target525\",\"limit\":0}",
+ "{\"base_project\":\"base525\",\"target_project\":\"target525\",\"limit\":1001}",
+ "{\"base_project\":\"base525\",\"target_project\":\"target525\",\"scan_limit\":0}",
+ ("{\"base_project\":\"base525\",\"target_project\":\"target525\","
+ "\"scan_limit\":10000001}"),
+ "{\"base_project\":\"base525\",\"target_project\":\"target525\",\"extra\":1}",
+ "{",
+ };
+ for (size_t index = 0; index < sizeof(invalid_arguments) / sizeof(invalid_arguments[0]);
+ index++) {
+ char *response = cbm_mcp_handle_tool(server, "compare_graphs", invalid_arguments[index]);
+ ASSERT_NOT_NULL(response);
+ ASSERT_NOT_NULL(strstr(response, "\"isError\":true"));
+ char *inner = extract_text_content(response);
+ ASSERT_NOT_NULL(inner);
+ ASSERT_NULL(strstr(inner, "\"nodes\""));
+ free(inner);
+ free(response);
+ }
+ ASSERT_FALSE(cbm_file_exists(equal_path));
+ ASSERT_FALSE(cbm_file_exists(equal_wal));
+ ASSERT_FALSE(cbm_file_exists(equal_shm));
+
+ char ghost_path[768];
+ char ghost_wal[800];
+ char ghost_shm[800];
+ snprintf(ghost_path, sizeof(ghost_path), "%s/ghost525.db", fixture.cache);
+ snprintf(ghost_wal, sizeof(ghost_wal), "%s-wal", ghost_path);
+ snprintf(ghost_shm, sizeof(ghost_shm), "%s-shm", ghost_path);
+ ASSERT_FALSE(cbm_file_exists(ghost_path));
+ char *missing = cbm_mcp_handle_tool(
+ server, "compare_graphs", "{\"base_project\":\"base525\",\"target_project\":\"ghost525\"}");
+ ASSERT_NOT_NULL(missing);
+ ASSERT_NOT_NULL(strstr(missing, "project_not_indexed"));
+ ASSERT_FALSE(cbm_file_exists(ghost_path));
+ ASSERT_FALSE(cbm_file_exists(ghost_wal));
+ ASSERT_FALSE(cbm_file_exists(ghost_shm));
+ free(missing);
+
+ char *scan_limited = compare_graphs_call(server, ",\"scan_limit\":1");
+ ASSERT_NOT_NULL(scan_limited);
+ ASSERT_NOT_NULL(strstr(scan_limited, "scan_limit_exceeded"));
+ char *scan_inner = extract_text_content(scan_limited);
+ ASSERT_NOT_NULL(scan_inner);
+ ASSERT_NULL(strstr(scan_inner, "\"nodes\""));
+ free(scan_inner);
+ free(scan_limited);
+
+ char *malformed_rpc = cbm_mcp_server_handle(server, "{");
+ ASSERT_NOT_NULL(malformed_rpc);
+ ASSERT_NOT_NULL(strstr(malformed_rpc, "-32700"));
+ free(malformed_rpc);
+ cbm_mcp_server_free(server);
+ ASSERT_TRUE(compare_graphs_fixture_close(&fixture));
+ PASS();
+}
+
+TEST(tool_compare_graphs_cancel_and_readonly_handles_release_issue525) {
+ compare_graphs_fixture_t fixture;
+ ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
+ long base_length = 0;
+ long target_length = 0;
+ unsigned char *base_before = mcp_read_file_bytes(fixture.base_path, &base_length);
+ unsigned char *target_before = mcp_read_file_bytes(fixture.target_path, &target_length);
+ ASSERT_NOT_NULL(base_before);
+ ASSERT_NOT_NULL(target_before);
+ char base_wal[800];
+ char base_shm[800];
+ char target_wal[800];
+ char target_shm[800];
+ snprintf(base_wal, sizeof(base_wal), "%s-wal", fixture.base_path);
+ snprintf(base_shm, sizeof(base_shm), "%s-shm", fixture.base_path);
+ snprintf(target_wal, sizeof(target_wal), "%s-wal", fixture.target_path);
+ snprintf(target_shm, sizeof(target_shm), "%s-shm", fixture.target_path);
+ ASSERT_FALSE(cbm_file_exists(base_wal));
+ ASSERT_FALSE(cbm_file_exists(base_shm));
+ ASSERT_FALSE(cbm_file_exists(target_wal));
+ ASSERT_FALSE(cbm_file_exists(target_shm));
+
+ cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
+ ASSERT_NOT_NULL(server);
+ ASSERT_TRUE(cbm_mcp_server_request_scope_begin(server));
+ ASSERT_TRUE(cbm_mcp_server_cancel_active(server));
+ char *cancelled = compare_graphs_call(server, NULL);
+ ASSERT_NOT_NULL(cancelled);
+ ASSERT_NOT_NULL(strstr(cancelled, "\"isError\":true"));
+ ASSERT_NOT_NULL(strstr(cancelled, "cancelled"));
+ char *cancelled_inner = extract_text_content(cancelled);
+ ASSERT_NOT_NULL(cancelled_inner);
+ ASSERT_NULL(strstr(cancelled_inner, "\"nodes\""));
+ free(cancelled_inner);
+ free(cancelled);
+ cbm_mcp_server_request_scope_end(server);
+
+ char moved_path[800];
+ snprintf(moved_path, sizeof(moved_path), "%s/base525.moved", fixture.cache);
+ ASSERT_EQ(rename(fixture.base_path, moved_path), 0);
+ ASSERT_EQ(rename(moved_path, fixture.base_path), 0);
+
+ char *success = compare_graphs_call(server, NULL);
+ ASSERT_NOT_NULL(success);
+ ASSERT_NOT_NULL(strstr(success, "\"isError\":false"));
+ free(success);
+ ASSERT_EQ(rename(fixture.base_path, moved_path), 0);
+ ASSERT_EQ(rename(moved_path, fixture.base_path), 0);
+ ASSERT_TRUE(mcp_file_matches_snapshot(fixture.base_path, base_before, base_length));
+ ASSERT_TRUE(mcp_file_matches_snapshot(fixture.target_path, target_before, target_length));
+ ASSERT_FALSE(cbm_file_exists(base_wal));
+ ASSERT_FALSE(cbm_file_exists(base_shm));
+ ASSERT_FALSE(cbm_file_exists(target_wal));
+ ASSERT_FALSE(cbm_file_exists(target_shm));
+
+ free(base_before);
+ free(target_before);
+ cbm_mcp_server_free(server);
+ ASSERT_TRUE(compare_graphs_fixture_close(&fixture));
+ PASS();
+}
+
/* Call get_code_snippet and extract inner text content.
* Caller must free returned string. */
static char *call_snippet(cbm_mcp_server_t *srv, const char *args_json) {
@@ -11551,6 +12050,11 @@ SUITE(mcp) {
RUN_TEST(tool_list_projects_empty);
RUN_TEST(tool_get_graph_schema_empty);
RUN_TEST(tool_unknown_tool);
+ RUN_TEST(tool_compare_graphs_registered_issue525);
+ RUN_TEST(tool_compare_graphs_streams_stable_deltas_issue525);
+ RUN_TEST(tool_compare_graphs_enforces_encoded_budget_issue525);
+ RUN_TEST(tool_compare_graphs_validation_and_scan_cap_are_atomic_issue525);
+ RUN_TEST(tool_compare_graphs_cancel_and_readonly_handles_release_issue525);
RUN_TEST(tool_search_graph_basic);
RUN_TEST(tool_search_graph_semantic_only_skips_structural_results_issue1295);
RUN_TEST(tool_trace_totals_respect_test_filter);
From 079d8dcb891ee2a0d45345124e1207a0c4b1993d Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Fri, 28 Aug 2026 19:21:05 +0200
Subject: [PATCH 13/31] fix: harden graph comparison identities
Signed-off-by: Martin Vogel
---
src/mcp/mcp.c | 23 ++++-
src/store/store.c | 71 ++++++++++++--
src/store/store.h | 15 ++-
tests/test_mcp.c | 243 ++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 336 insertions(+), 16 deletions(-)
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index 616bbf6a1..e7077c12f 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -2918,13 +2918,26 @@ static bool compare_parse_arguments(const char *args, char **base_project, char
return true;
}
+static char *sanitize_utf8_lossy(const char *s);
+
+static bool compare_add_identity_string(yyjson_mut_doc *doc, yyjson_mut_val *object,
+ const char *key, const char *value) {
+ char *sanitized = sanitize_utf8_lossy(value);
+ if (!sanitized) {
+ return false;
+ }
+ bool ok = yyjson_mut_obj_add_strcpy(doc, object, key, sanitized);
+ free(sanitized);
+ return ok;
+}
+
static yyjson_mut_val *compare_node_json(yyjson_mut_doc *doc,
const cbm_graph_node_identity_t *node) {
yyjson_mut_val *object = yyjson_mut_obj(doc);
if (!object ||
- !yyjson_mut_obj_add_strcpy(doc, object, "qualified_name", node->qualified_name) ||
- !yyjson_mut_obj_add_strcpy(doc, object, "label", node->label) ||
- !yyjson_mut_obj_add_strcpy(doc, object, "file_path", node->file_path)) {
+ !compare_add_identity_string(doc, object, "qualified_name", node->qualified_name) ||
+ !compare_add_identity_string(doc, object, "label", node->label) ||
+ !compare_add_identity_string(doc, object, "file_path", node->file_path)) {
return NULL;
}
return object;
@@ -2992,8 +3005,8 @@ static bool compare_edge_callback(void *context, bool added,
yyjson_mut_val *target = doc ? compare_node_json(doc, &edge->target) : NULL;
bool ok = item && source && target && yyjson_mut_obj_add_val(doc, item, "source", source) &&
yyjson_mut_obj_add_val(doc, item, "target", target) &&
- yyjson_mut_obj_add_strcpy(doc, item, "type", edge->type) &&
- yyjson_mut_obj_add_strcpy(doc, item, "local_name_gen", edge->local_name_gen);
+ compare_add_identity_string(doc, item, "type", edge->type) &&
+ compare_add_identity_string(doc, item, "local_name_gen", edge->local_name_gen);
if (ok) {
yyjson_mut_doc_set_root(doc, item);
}
diff --git a/src/store/store.c b/src/store/store.c
index 0f2a5ead6..b991bb58e 100644
--- a/src/store/store.c
+++ b/src/store/store.c
@@ -1227,7 +1227,36 @@ typedef struct {
bool has_row;
} graph_edge_cursor_t;
+#ifdef CBM_ENABLE_TEST_SEAMS
+static atomic_int graph_compare_test_bind_countdown = ATOMIC_VAR_INIT(CBM_NOT_FOUND);
+static atomic_int graph_compare_test_cancel_countdown = ATOMIC_VAR_INIT(CBM_NOT_FOUND);
+
+void cbm_store_compare_test_fail_bind_after(int successful_binds) {
+ atomic_store(&graph_compare_test_bind_countdown, successful_binds);
+}
+
+void cbm_store_compare_test_cancel_after(int successful_checks) {
+ atomic_store(&graph_compare_test_cancel_countdown, successful_checks);
+}
+
+static bool graph_compare_test_countdown_fires(atomic_int *countdown) {
+ int remaining = atomic_load(countdown);
+ while (remaining >= 0) {
+ int next = remaining == 0 ? CBM_NOT_FOUND : remaining - 1;
+ if (atomic_compare_exchange_weak(countdown, &remaining, next)) {
+ return remaining == 0;
+ }
+ }
+ return false;
+}
+#endif
+
static bool graph_compare_is_cancelled(const graph_compare_progress_t *progress) {
+#ifdef CBM_ENABLE_TEST_SEAMS
+ if (graph_compare_test_countdown_fires(&graph_compare_test_cancel_countdown)) {
+ return true;
+ }
+#endif
return progress && progress->cancel && progress->cancel(progress->context);
}
@@ -1235,6 +1264,15 @@ static int graph_compare_progress(void *context) {
return graph_compare_is_cancelled((const graph_compare_progress_t *)context) ? 1 : 0;
}
+static int graph_compare_bind_text(sqlite3_stmt *stmt, int column, const char *value) {
+#ifdef CBM_ENABLE_TEST_SEAMS
+ if (graph_compare_test_countdown_fires(&graph_compare_test_bind_countdown)) {
+ return SQLITE_NOMEM;
+ }
+#endif
+ return bind_text(stmt, column, value);
+}
+
/* SQLite's BINARY collation compares the unsigned bytes of the UTF-8 text.
* Keep the merge comparator byte-for-byte equivalent so neither cursor can
* be advanced past a row that SQLite considers distinct. */
@@ -1282,7 +1320,11 @@ static int graph_count_rows(cbm_store_t *store, const char *sql, const char *pro
store_set_error_sqlite(store, "compare count prepare");
return CBM_STORE_ERR;
}
- bind_text(stmt, SKIP_ONE, project);
+ if (graph_compare_bind_text(stmt, SKIP_ONE, project) != SQLITE_OK) {
+ store_set_error(store, "compare count bind failed");
+ sqlite3_finalize(stmt);
+ return CBM_STORE_ERR;
+ }
int step_rc = sqlite3_step(stmt);
if (step_rc != SQLITE_ROW) {
store_set_error_sqlite(store, "compare count");
@@ -1388,35 +1430,46 @@ static int graph_edge_cursor_step(cbm_store_t *store, graph_edge_cursor_t *curso
static int graph_prepare_node_cursor(cbm_store_t *store, const char *project,
graph_node_cursor_t *cursor) {
static const char sql[] =
- "SELECT qualified_name,label,coalesce(file_path,'') FROM nodes WHERE project=?1 "
+ "SELECT qualified_name,label,replace(coalesce(file_path,''),'\\','/') "
+ "FROM nodes WHERE project=?1 "
"ORDER BY qualified_name COLLATE BINARY,label COLLATE BINARY,"
- "coalesce(file_path,'') COLLATE BINARY;";
+ "replace(coalesce(file_path,''),'\\','/') COLLATE BINARY;";
if (sqlite3_prepare_v2(store->db, sql, CBM_NOT_FOUND, &cursor->stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(store, "compare node prepare");
return CBM_STORE_ERR;
}
- bind_text(cursor->stmt, SKIP_ONE, project);
+ if (graph_compare_bind_text(cursor->stmt, SKIP_ONE, project) != SQLITE_OK) {
+ store_set_error(store, "compare node bind failed");
+ sqlite3_finalize(cursor->stmt);
+ cursor->stmt = NULL;
+ return CBM_STORE_ERR;
+ }
return graph_node_cursor_step(store, cursor);
}
static int graph_prepare_edge_cursor(cbm_store_t *store, const char *project,
graph_edge_cursor_t *cursor) {
static const char sql[] =
- "SELECT sn.qualified_name,sn.label,coalesce(sn.file_path,''),"
- "tn.qualified_name,tn.label,coalesce(tn.file_path,''),e.type,"
+ "SELECT sn.qualified_name,sn.label,replace(coalesce(sn.file_path,''),'\\','/'),"
+ "tn.qualified_name,tn.label,replace(coalesce(tn.file_path,''),'\\','/'),e.type,"
"coalesce(e.local_name_gen,'') FROM edges e "
"JOIN nodes sn ON sn.id=e.source_id JOIN nodes tn ON tn.id=e.target_id "
"WHERE e.project=?1 ORDER BY "
"sn.qualified_name COLLATE BINARY,sn.label COLLATE BINARY,"
- "coalesce(sn.file_path,'') COLLATE BINARY,"
+ "replace(coalesce(sn.file_path,''),'\\','/') COLLATE BINARY,"
"tn.qualified_name COLLATE BINARY,tn.label COLLATE BINARY,"
- "coalesce(tn.file_path,'') COLLATE BINARY,"
+ "replace(coalesce(tn.file_path,''),'\\','/') COLLATE BINARY,"
"e.type COLLATE BINARY,coalesce(e.local_name_gen,'') COLLATE BINARY;";
if (sqlite3_prepare_v2(store->db, sql, CBM_NOT_FOUND, &cursor->stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(store, "compare edge prepare");
return CBM_STORE_ERR;
}
- bind_text(cursor->stmt, SKIP_ONE, project);
+ if (graph_compare_bind_text(cursor->stmt, SKIP_ONE, project) != SQLITE_OK) {
+ store_set_error(store, "compare edge bind failed");
+ sqlite3_finalize(cursor->stmt);
+ cursor->stmt = NULL;
+ return CBM_STORE_ERR;
+ }
return graph_edge_cursor_step(store, cursor);
}
diff --git a/src/store/store.h b/src/store/store.h
index 43c189792..d9047f28f 100644
--- a/src/store/store.h
+++ b/src/store/store.h
@@ -282,9 +282,12 @@ typedef bool (*cbm_graph_compare_node_fn)(void *context, bool added,
typedef bool (*cbm_graph_compare_edge_fn)(void *context, bool added,
const cbm_graph_edge_identity_t *edge);
+#define CBM_GRAPH_COMPARE_GENERATION_SIZE 128
+#define CBM_GRAPH_COMPARE_INDEX_MODE_SIZE 32
+
typedef struct {
- char generation[128];
- char index_mode[32];
+ char generation[CBM_GRAPH_COMPARE_GENERATION_SIZE];
+ char index_mode[CBM_GRAPH_COMPARE_INDEX_MODE_SIZE];
int64_t node_count;
int64_t edge_count;
} cbm_graph_compare_project_t;
@@ -308,6 +311,14 @@ int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project,
cbm_graph_compare_node_fn on_node, cbm_graph_compare_edge_fn on_edge,
void *context, cbm_graph_compare_result_t *out);
+#ifdef CBM_ENABLE_TEST_SEAMS
+/* Deterministic one-shot comparison fault seams. Values count successful
+ * comparison binds/cancellation checks before the injected event; -1 disables
+ * the seam. */
+void cbm_store_compare_test_fail_bind_after(int successful_binds);
+void cbm_store_compare_test_cancel_after(int successful_checks);
+#endif
+
/* ── Lifecycle ──────────────────────────────────────────────────── */
/* Open an in-memory database (for testing). */
diff --git a/tests/test_mcp.c b/tests/test_mcp.c
index fbf59823e..e867ac0e5 100644
--- a/tests/test_mcp.c
+++ b/tests/test_mcp.c
@@ -8247,6 +8247,245 @@ static bool compare_write_single_node_store(const char *path, const char *projec
return ok;
}
+static bool compare_write_identity_store(const char *path, const char *project,
+ const char *generation, const char *source_qn,
+ const char *source_label, const char *source_file,
+ const char *target_qn, const char *target_label,
+ const char *target_file, const char *edge_type) {
+ cbm_store_t *store = cbm_store_open_path(path);
+ if (!store ||
+ cbm_store_upsert_project(store, project, "/tmp/compare-identity-525") != CBM_STORE_OK) {
+ cbm_store_close(store);
+ return false;
+ }
+
+ bool ok = true;
+ int64_t source_id = 0;
+ int64_t target_id = 0;
+ if (source_qn) {
+ cbm_node_t source = {
+ .project = project,
+ .label = source_label,
+ .name = source_qn,
+ .qualified_name = source_qn,
+ .file_path = source_file,
+ .properties_json = "{}",
+ };
+ source_id = cbm_store_upsert_node(store, &source);
+ ok = source_id > 0;
+ }
+ if (ok && target_qn) {
+ cbm_node_t target = {
+ .project = project,
+ .label = target_label,
+ .name = target_qn,
+ .qualified_name = target_qn,
+ .file_path = target_file,
+ .properties_json = "{}",
+ };
+ target_id = cbm_store_upsert_node(store, &target);
+ ok = target_id > 0;
+ }
+ if (ok && edge_type) {
+ cbm_edge_t edge = {
+ .project = project,
+ .source_id = source_id,
+ .target_id = target_id,
+ .type = edge_type,
+ .properties_json = "{}",
+ };
+ ok = cbm_store_insert_edge(store, &edge) > 0;
+ }
+ ok = ok && compare_write_coverage(store, project, generation) &&
+ cbm_store_prepare_for_publish(store) == CBM_STORE_OK;
+ cbm_store_close(store);
+ return ok;
+}
+
+TEST(tool_compare_graphs_normalizes_legacy_path_separators_issue525) {
+ compare_graphs_fixture_t fixture;
+ ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
+ char base_path[768];
+ char target_path[768];
+ snprintf(base_path, sizeof(base_path), "%s/sepbase525.db", fixture.cache);
+ snprintf(target_path, sizeof(target_path), "%s/septarget525.db", fixture.cache);
+ ASSERT_TRUE(compare_write_identity_store(base_path, "sepbase525", "sep-base", "pkg.source",
+ "Function", "src\\same.c", "pkg.target", "Function",
+ "src\\target.c", "CALLS"));
+ ASSERT_TRUE(compare_write_identity_store(target_path, "septarget525", "sep-target",
+ "pkg.source", "Function", "src/same.c", "pkg.target",
+ "Function", "src/target.c", "CALLS"));
+
+ cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
+ ASSERT_NOT_NULL(server);
+ char *response = cbm_mcp_handle_tool(
+ server, "compare_graphs",
+ "{\"base_project\":\"sepbase525\",\"target_project\":\"septarget525\"}");
+ ASSERT_NOT_NULL(response);
+ char *inner = extract_text_content(response);
+ yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL;
+ ASSERT_NOT_NULL(doc);
+ yyjson_val *root = yyjson_doc_get_root(doc);
+ yyjson_val *nodes = yyjson_obj_get(root, "nodes");
+ yyjson_val *edges = yyjson_obj_get(root, "edges");
+ ASSERT_TRUE(compare_set_has(yyjson_obj_get(nodes, "added"), 0, 0, false, NULL));
+ ASSERT_TRUE(compare_set_has(yyjson_obj_get(nodes, "removed"), 0, 0, false, NULL));
+ ASSERT_TRUE(compare_set_has(yyjson_obj_get(edges, "added"), 0, 0, false, NULL));
+ ASSERT_TRUE(compare_set_has(yyjson_obj_get(edges, "removed"), 0, 0, false, NULL));
+ yyjson_doc_free(doc);
+ free(inner);
+ free(response);
+ cbm_mcp_server_free(server);
+ ASSERT_TRUE(compare_graphs_fixture_close(&fixture));
+ PASS();
+}
+
+TEST(tool_compare_graphs_sanitizes_legacy_invalid_utf8_issue525) {
+ static const char invalid_qn[] = "pkg.\xFF"
+ "node";
+ static const char invalid_label[] = "\xFE"
+ "Function";
+ static const char invalid_file[] = "src/\x80"
+ "bad.c";
+ static const char invalid_type[] = "\xFF"
+ "EDGE";
+ static const char safe_qn[] = "pkg.sink";
+ static const char replacement_qn[] = "pkg.\xEF\xBF\xBD"
+ "node";
+ static const char replacement_label[] = "\xEF\xBF\xBD"
+ "Function";
+ static const char replacement_file[] = "src/\xEF\xBF\xBD"
+ "bad.c";
+ static const char replacement_type[] = "\xEF\xBF\xBD"
+ "EDGE";
+
+ compare_graphs_fixture_t fixture;
+ ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
+ char base_path[768];
+ char target_path[768];
+ snprintf(base_path, sizeof(base_path), "%s/utfbase525.db", fixture.cache);
+ snprintf(target_path, sizeof(target_path), "%s/utftarget525.db", fixture.cache);
+ ASSERT_TRUE(compare_write_identity_store(base_path, "utfbase525", "utf-base", NULL, NULL, NULL,
+ NULL, NULL, NULL, NULL));
+ ASSERT_TRUE(compare_write_identity_store(target_path, "utftarget525", "utf-target", invalid_qn,
+ invalid_label, invalid_file, safe_qn, "Function",
+ "src/sink.c", invalid_type));
+
+ cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
+ ASSERT_NOT_NULL(server);
+ char *response = cbm_mcp_handle_tool(
+ server, "compare_graphs",
+ "{\"base_project\":\"utfbase525\",\"target_project\":\"utftarget525\"}");
+ ASSERT_NOT_NULL(response);
+ yyjson_doc *outer = yyjson_read(response, strlen(response), 0);
+ ASSERT_NOT_NULL(outer);
+ yyjson_doc_free(outer);
+ char *inner = extract_text_content(response);
+ yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL;
+ ASSERT_NOT_NULL(doc);
+ yyjson_val *root = yyjson_doc_get_root(doc);
+ yyjson_val *items =
+ yyjson_obj_get(yyjson_obj_get(yyjson_obj_get(root, "nodes"), "added"), "items");
+ bool found_node = false;
+ size_t index, maximum;
+ yyjson_val *item;
+ yyjson_arr_foreach(items, index, maximum, item) {
+ const char *qualified_name = yyjson_get_str(yyjson_obj_get(item, "qualified_name"));
+ if (qualified_name && strcmp(qualified_name, replacement_qn) == 0) {
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item, "label")), replacement_label);
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(item, "file_path")), replacement_file);
+ found_node = true;
+ }
+ }
+ ASSERT_TRUE(found_node);
+ yyjson_val *edge_items =
+ yyjson_obj_get(yyjson_obj_get(yyjson_obj_get(root, "edges"), "added"), "items");
+ ASSERT_EQ(yyjson_arr_size(edge_items), 1);
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(yyjson_arr_get(edge_items, 0), "type")),
+ replacement_type);
+ yyjson_doc_free(doc);
+ free(inner);
+ free(response);
+ cbm_mcp_server_free(server);
+ ASSERT_TRUE(compare_graphs_fixture_close(&fixture));
+ PASS();
+}
+
+TEST(tool_compare_graphs_bind_failures_are_atomic_issue525) {
+ compare_graphs_fixture_t fixture;
+ ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
+ cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
+ ASSERT_NOT_NULL(server);
+ static const int fail_after[] = {0, 4, 6};
+ for (size_t index = 0; index < sizeof(fail_after) / sizeof(fail_after[0]); index++) {
+ cbm_store_compare_test_fail_bind_after(fail_after[index]);
+ char *response = compare_graphs_call(server, NULL);
+ ASSERT_NOT_NULL(response);
+ ASSERT_NOT_NULL(strstr(response, "\"isError\":true"));
+ ASSERT_NOT_NULL(strstr(response, "query_failed"));
+ char *inner = extract_text_content(response);
+ ASSERT_NOT_NULL(inner);
+ ASSERT_NULL(strstr(inner, "\"nodes\""));
+ free(inner);
+ free(response);
+ }
+ cbm_store_compare_test_fail_bind_after(CBM_NOT_FOUND);
+ cbm_mcp_server_free(server);
+ ASSERT_TRUE(compare_graphs_fixture_close(&fixture));
+ PASS();
+}
+
+TEST(tool_compare_graphs_midscan_cancel_restores_store_state_issue525) {
+ compare_graphs_fixture_t fixture;
+ ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
+ cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
+ ASSERT_NOT_NULL(server);
+
+ cbm_store_compare_test_cancel_after(1);
+ char *cancelled = compare_graphs_call(server, NULL);
+ ASSERT_NOT_NULL(cancelled);
+ ASSERT_NOT_NULL(strstr(cancelled, "\"isError\":true"));
+ ASSERT_NOT_NULL(strstr(cancelled, "cancelled"));
+ char *inner = extract_text_content(cancelled);
+ ASSERT_NOT_NULL(inner);
+ ASSERT_NULL(strstr(inner, "\"nodes\""));
+ free(inner);
+ free(cancelled);
+ cbm_store_compare_test_cancel_after(CBM_NOT_FOUND);
+
+ cbm_store_t *base_store = cbm_store_open_path_query(fixture.base_path);
+ cbm_store_t *target_store = cbm_store_open_path_query(fixture.target_path);
+ ASSERT_NOT_NULL(base_store);
+ ASSERT_NOT_NULL(target_store);
+ cbm_graph_compare_result_t result = {0};
+ cbm_store_compare_test_cancel_after(1);
+ ASSERT_EQ(cbm_store_compare_graphs(base_store, "base525", target_store, "target525", 100, NULL,
+ NULL, NULL, NULL, &result),
+ CBM_STORE_CANCELLED);
+ ASSERT_EQ(result.nodes_added_total, 0);
+ ASSERT_EQ(result.nodes_removed_total, 0);
+ ASSERT_EQ(result.edges_added_total, 0);
+ ASSERT_EQ(result.edges_removed_total, 0);
+ cbm_store_compare_test_cancel_after(CBM_NOT_FOUND);
+ ASSERT_EQ(cbm_store_compare_graphs(base_store, "base525", target_store, "target525", 100, NULL,
+ NULL, NULL, NULL, &result),
+ CBM_STORE_OK);
+ ASSERT_EQ(result.nodes_added_total, 2);
+ ASSERT_EQ(result.nodes_removed_total, 2);
+ ASSERT_EQ(result.edges_added_total, 2);
+ ASSERT_EQ(result.edges_removed_total, 2);
+ cbm_store_close(target_store);
+ cbm_store_close(base_store);
+
+ char *success = compare_graphs_call(server, NULL);
+ ASSERT_NOT_NULL(success);
+ ASSERT_NOT_NULL(strstr(success, "\"isError\":false"));
+ free(success);
+ cbm_mcp_server_free(server);
+ ASSERT_TRUE(compare_graphs_fixture_close(&fixture));
+ PASS();
+}
+
TEST(tool_compare_graphs_enforces_encoded_budget_issue525) {
compare_graphs_fixture_t fixture;
ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
@@ -12052,6 +12291,10 @@ SUITE(mcp) {
RUN_TEST(tool_unknown_tool);
RUN_TEST(tool_compare_graphs_registered_issue525);
RUN_TEST(tool_compare_graphs_streams_stable_deltas_issue525);
+ RUN_TEST(tool_compare_graphs_normalizes_legacy_path_separators_issue525);
+ RUN_TEST(tool_compare_graphs_sanitizes_legacy_invalid_utf8_issue525);
+ RUN_TEST(tool_compare_graphs_bind_failures_are_atomic_issue525);
+ RUN_TEST(tool_compare_graphs_midscan_cancel_restores_store_state_issue525);
RUN_TEST(tool_compare_graphs_enforces_encoded_budget_issue525);
RUN_TEST(tool_compare_graphs_validation_and_scan_cap_are_atomic_issue525);
RUN_TEST(tool_compare_graphs_cancel_and_readonly_handles_release_issue525);
From 7ba26f5216b466c6e939e1d2ac58313cd65ed9a3 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Fri, 28 Aug 2026 20:16:22 +0200
Subject: [PATCH 14/31] fix: sanitize graph comparison metadata
Signed-off-by: Martin Vogel
---
src/mcp/mcp.c | 4 +-
src/store/store.c | 40 ++++++++++++++++---
src/store/store.h | 1 +
tests/test_mcp.c | 98 +++++++++++++++++++++++++++++++++++++++--------
4 files changed, 119 insertions(+), 24 deletions(-)
diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c
index e7077c12f..ada9e646d 100644
--- a/src/mcp/mcp.c
+++ b/src/mcp/mcp.c
@@ -3022,8 +3022,8 @@ static yyjson_mut_val *compare_project_json(yyjson_mut_doc *doc, const char *pro
const cbm_graph_compare_project_t *metadata) {
yyjson_mut_val *object = yyjson_mut_obj(doc);
if (!object || !yyjson_mut_obj_add_strcpy(doc, object, "project", project) ||
- !yyjson_mut_obj_add_strcpy(doc, object, "generation", metadata->generation) ||
- !yyjson_mut_obj_add_strcpy(doc, object, "index_mode", metadata->index_mode) ||
+ !compare_add_identity_string(doc, object, "generation", metadata->generation) ||
+ !compare_add_identity_string(doc, object, "index_mode", metadata->index_mode) ||
!yyjson_mut_obj_add_sint(doc, object, "node_count", metadata->node_count) ||
!yyjson_mut_obj_add_sint(doc, object, "edge_count", metadata->edge_count)) {
return NULL;
diff --git a/src/store/store.c b/src/store/store.c
index b991bb58e..ffb0047d1 100644
--- a/src/store/store.c
+++ b/src/store/store.c
@@ -1213,8 +1213,11 @@ int cbm_store_rollback(cbm_store_t *s) {
typedef struct {
cbm_graph_compare_cancel_fn cancel;
void *context;
+ bool progress_cancelled;
} graph_compare_progress_t;
+enum { GRAPH_COMPARE_PROGRESS_INTERVAL = 1000 };
+
typedef struct {
sqlite3_stmt *stmt;
cbm_graph_node_identity_t identity;
@@ -1230,6 +1233,7 @@ typedef struct {
#ifdef CBM_ENABLE_TEST_SEAMS
static atomic_int graph_compare_test_bind_countdown = ATOMIC_VAR_INIT(CBM_NOT_FOUND);
static atomic_int graph_compare_test_cancel_countdown = ATOMIC_VAR_INIT(CBM_NOT_FOUND);
+static atomic_bool graph_compare_test_progress_cancel = ATOMIC_VAR_INIT(false);
void cbm_store_compare_test_fail_bind_after(int successful_binds) {
atomic_store(&graph_compare_test_bind_countdown, successful_binds);
@@ -1239,6 +1243,10 @@ void cbm_store_compare_test_cancel_after(int successful_checks) {
atomic_store(&graph_compare_test_cancel_countdown, successful_checks);
}
+void cbm_store_compare_test_cancel_from_progress(bool enabled) {
+ atomic_store(&graph_compare_test_progress_cancel, enabled);
+}
+
static bool graph_compare_test_countdown_fires(atomic_int *countdown) {
int remaining = atomic_load(countdown);
while (remaining >= 0) {
@@ -1261,7 +1269,25 @@ static bool graph_compare_is_cancelled(const graph_compare_progress_t *progress)
}
static int graph_compare_progress(void *context) {
- return graph_compare_is_cancelled((const graph_compare_progress_t *)context) ? 1 : 0;
+ graph_compare_progress_t *progress = (graph_compare_progress_t *)context;
+#ifdef CBM_ENABLE_TEST_SEAMS
+ if (atomic_load(&graph_compare_test_progress_cancel)) {
+ progress->progress_cancelled = true;
+ return true;
+ }
+#endif
+ bool cancelled = graph_compare_is_cancelled(progress);
+ progress->progress_cancelled = cancelled;
+ return cancelled;
+}
+
+static int graph_compare_progress_interval(void) {
+#ifdef CBM_ENABLE_TEST_SEAMS
+ if (atomic_load(&graph_compare_test_progress_cancel)) {
+ return 1;
+ }
+#endif
+ return GRAPH_COMPARE_PROGRESS_INTERVAL;
}
static int graph_compare_bind_text(sqlite3_stmt *stmt, int column, const char *value) {
@@ -1594,8 +1620,10 @@ int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project,
int rc = CBM_STORE_OK;
bool base_transaction = false;
bool target_transaction = false;
- sqlite3_progress_handler(base_store->db, 1000, graph_compare_progress, &progress);
- sqlite3_progress_handler(target_store->db, 1000, graph_compare_progress, &progress);
+ int progress_interval = graph_compare_progress_interval();
+ sqlite3_progress_handler(base_store->db, progress_interval, graph_compare_progress, &progress);
+ sqlite3_progress_handler(target_store->db, progress_interval, graph_compare_progress,
+ &progress);
rc = exec_sql(base_store, "BEGIN;");
if (rc == CBM_STORE_OK) {
@@ -1622,10 +1650,12 @@ int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project,
rc = graph_compare_edges(base_store, base_project, target_store, target_project, &progress,
on_edge, out);
}
- if (rc == CBM_STORE_OK && graph_compare_is_cancelled(&progress)) {
+ if (rc == CBM_STORE_OK &&
+ (progress.progress_cancelled || graph_compare_is_cancelled(&progress))) {
rc = CBM_STORE_CANCELLED;
}
- if (rc != CBM_STORE_OK && graph_compare_is_cancelled(&progress)) {
+ if (rc != CBM_STORE_OK &&
+ (progress.progress_cancelled || graph_compare_is_cancelled(&progress))) {
rc = CBM_STORE_CANCELLED;
}
diff --git a/src/store/store.h b/src/store/store.h
index d9047f28f..0409071f1 100644
--- a/src/store/store.h
+++ b/src/store/store.h
@@ -317,6 +317,7 @@ int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project,
* the seam. */
void cbm_store_compare_test_fail_bind_after(int successful_binds);
void cbm_store_compare_test_cancel_after(int successful_checks);
+void cbm_store_compare_test_cancel_from_progress(bool enabled);
#endif
/* ── Lifecycle ──────────────────────────────────────────────────── */
diff --git a/tests/test_mcp.c b/tests/test_mcp.c
index e867ac0e5..6099c1db2 100644
--- a/tests/test_mcp.c
+++ b/tests/test_mcp.c
@@ -7956,11 +7956,11 @@ typedef struct {
const char *file_path;
} compare_node_spec_t;
-static bool compare_write_coverage(cbm_store_t *store, const char *project,
- const char *generation) {
+static bool compare_write_coverage_mode(cbm_store_t *store, const char *project,
+ const char *generation, const char *index_mode) {
cbm_coverage_meta_t meta = {
.generation = generation,
- .index_mode = "full",
+ .index_mode = index_mode,
.recorded_at = "2026-08-28T00:00:00Z",
.recording_status = "complete",
.ignored_files_stored = 0,
@@ -7971,6 +7971,11 @@ static bool compare_write_coverage(cbm_store_t *store, const char *project,
return cbm_store_coverage_replace_ex(store, project, NULL, 0, &meta) == CBM_STORE_OK;
}
+static bool compare_write_coverage(cbm_store_t *store, const char *project,
+ const char *generation) {
+ return compare_write_coverage_mode(store, project, generation, "full");
+}
+
static bool compare_write_fixture_store(const char *path, const char *project, bool target) {
static const compare_node_spec_t base_nodes[] = {
{"pkg.common", "Function", "src/common.c"}, {"pkg.base", "Function", "src/base.c"},
@@ -8248,10 +8253,11 @@ static bool compare_write_single_node_store(const char *path, const char *projec
}
static bool compare_write_identity_store(const char *path, const char *project,
- const char *generation, const char *source_qn,
- const char *source_label, const char *source_file,
- const char *target_qn, const char *target_label,
- const char *target_file, const char *edge_type) {
+ const char *generation, const char *index_mode,
+ const char *source_qn, const char *source_label,
+ const char *source_file, const char *target_qn,
+ const char *target_label, const char *target_file,
+ const char *edge_type) {
cbm_store_t *store = cbm_store_open_path(path);
if (!store ||
cbm_store_upsert_project(store, project, "/tmp/compare-identity-525") != CBM_STORE_OK) {
@@ -8296,7 +8302,7 @@ static bool compare_write_identity_store(const char *path, const char *project,
};
ok = cbm_store_insert_edge(store, &edge) > 0;
}
- ok = ok && compare_write_coverage(store, project, generation) &&
+ ok = ok && compare_write_coverage_mode(store, project, generation, index_mode) &&
cbm_store_prepare_for_publish(store) == CBM_STORE_OK;
cbm_store_close(store);
return ok;
@@ -8309,10 +8315,10 @@ TEST(tool_compare_graphs_normalizes_legacy_path_separators_issue525) {
char target_path[768];
snprintf(base_path, sizeof(base_path), "%s/sepbase525.db", fixture.cache);
snprintf(target_path, sizeof(target_path), "%s/septarget525.db", fixture.cache);
- ASSERT_TRUE(compare_write_identity_store(base_path, "sepbase525", "sep-base", "pkg.source",
- "Function", "src\\same.c", "pkg.target", "Function",
- "src\\target.c", "CALLS"));
- ASSERT_TRUE(compare_write_identity_store(target_path, "septarget525", "sep-target",
+ ASSERT_TRUE(compare_write_identity_store(base_path, "sepbase525", "sep-base", "full",
+ "pkg.source", "Function", "src\\same.c", "pkg.target",
+ "Function", "src\\target.c", "CALLS"));
+ ASSERT_TRUE(compare_write_identity_store(target_path, "septarget525", "sep-target", "full",
"pkg.source", "Function", "src/same.c", "pkg.target",
"Function", "src/target.c", "CALLS"));
@@ -8358,6 +8364,14 @@ TEST(tool_compare_graphs_sanitizes_legacy_invalid_utf8_issue525) {
"bad.c";
static const char replacement_type[] = "\xEF\xBF\xBD"
"EDGE";
+ static const char invalid_generation[] = "utf-\xFF"
+ "generation";
+ static const char invalid_index_mode[] = "\xFE"
+ "full";
+ static const char replacement_generation[] = "utf-\xEF\xBF\xBD"
+ "generation";
+ static const char replacement_index_mode[] = "\xEF\xBF\xBD"
+ "full";
compare_graphs_fixture_t fixture;
ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
@@ -8365,11 +8379,11 @@ TEST(tool_compare_graphs_sanitizes_legacy_invalid_utf8_issue525) {
char target_path[768];
snprintf(base_path, sizeof(base_path), "%s/utfbase525.db", fixture.cache);
snprintf(target_path, sizeof(target_path), "%s/utftarget525.db", fixture.cache);
- ASSERT_TRUE(compare_write_identity_store(base_path, "utfbase525", "utf-base", NULL, NULL, NULL,
- NULL, NULL, NULL, NULL));
- ASSERT_TRUE(compare_write_identity_store(target_path, "utftarget525", "utf-target", invalid_qn,
- invalid_label, invalid_file, safe_qn, "Function",
- "src/sink.c", invalid_type));
+ ASSERT_TRUE(compare_write_identity_store(base_path, "utfbase525", "utf-base", "full", NULL,
+ NULL, NULL, NULL, NULL, NULL, NULL));
+ ASSERT_TRUE(compare_write_identity_store(
+ target_path, "utftarget525", invalid_generation, invalid_index_mode, invalid_qn,
+ invalid_label, invalid_file, safe_qn, "Function", "src/sink.c", invalid_type));
cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(server);
@@ -8384,6 +8398,9 @@ TEST(tool_compare_graphs_sanitizes_legacy_invalid_utf8_issue525) {
yyjson_doc *doc = inner ? yyjson_read(inner, strlen(inner), 0) : NULL;
ASSERT_NOT_NULL(doc);
yyjson_val *root = yyjson_doc_get_root(doc);
+ yyjson_val *target = yyjson_obj_get(root, "target");
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(target, "generation")), replacement_generation);
+ ASSERT_STR_EQ(yyjson_get_str(yyjson_obj_get(target, "index_mode")), replacement_index_mode);
yyjson_val *items =
yyjson_obj_get(yyjson_obj_get(yyjson_obj_get(root, "nodes"), "added"), "items");
bool found_node = false;
@@ -8486,6 +8503,52 @@ TEST(tool_compare_graphs_midscan_cancel_restores_store_state_issue525) {
PASS();
}
+TEST(tool_compare_graphs_progress_cancel_clears_handler_issue525) {
+ compare_graphs_fixture_t fixture;
+ ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
+ cbm_mcp_server_t *server = cbm_mcp_server_new(NULL);
+ ASSERT_NOT_NULL(server);
+
+ cbm_store_compare_test_cancel_from_progress(true);
+ char *cancelled = compare_graphs_call(server, NULL);
+ ASSERT_NOT_NULL(cancelled);
+ ASSERT_NOT_NULL(strstr(cancelled, "\"isError\":true"));
+ ASSERT_NOT_NULL(strstr(cancelled, "cancelled"));
+ char *inner = extract_text_content(cancelled);
+ ASSERT_NOT_NULL(inner);
+ ASSERT_NULL(strstr(inner, "\"nodes\""));
+ free(inner);
+ free(cancelled);
+
+ cbm_store_t *base_store = cbm_store_open_path_query(fixture.base_path);
+ cbm_store_t *target_store = cbm_store_open_path_query(fixture.target_path);
+ ASSERT_NOT_NULL(base_store);
+ ASSERT_NOT_NULL(target_store);
+ cbm_graph_compare_result_t result = {0};
+ ASSERT_EQ(cbm_store_compare_graphs(base_store, "base525", target_store, "target525", 100, NULL,
+ NULL, NULL, NULL, &result),
+ CBM_STORE_CANCELLED);
+ ASSERT_EQ(result.nodes_added_total, 0);
+ ASSERT_EQ(result.nodes_removed_total, 0);
+ ASSERT_EQ(result.edges_added_total, 0);
+ ASSERT_EQ(result.edges_removed_total, 0);
+
+ static const char expensive_query[] =
+ "WITH RECURSIVE sequence(value) AS (VALUES(0) UNION ALL "
+ "SELECT value+1 FROM sequence WHERE value<5000) SELECT sum(value) FROM sequence;";
+ int base_query_rc = cbm_store_exec(base_store, expensive_query);
+ int target_query_rc = cbm_store_exec(target_store, expensive_query);
+ cbm_store_compare_test_cancel_from_progress(false);
+ cbm_store_close(target_store);
+ cbm_store_close(base_store);
+ ASSERT_EQ(base_query_rc, CBM_STORE_OK);
+ ASSERT_EQ(target_query_rc, CBM_STORE_OK);
+
+ cbm_mcp_server_free(server);
+ ASSERT_TRUE(compare_graphs_fixture_close(&fixture));
+ PASS();
+}
+
TEST(tool_compare_graphs_enforces_encoded_budget_issue525) {
compare_graphs_fixture_t fixture;
ASSERT_TRUE(compare_graphs_fixture_open(&fixture));
@@ -12295,6 +12358,7 @@ SUITE(mcp) {
RUN_TEST(tool_compare_graphs_sanitizes_legacy_invalid_utf8_issue525);
RUN_TEST(tool_compare_graphs_bind_failures_are_atomic_issue525);
RUN_TEST(tool_compare_graphs_midscan_cancel_restores_store_state_issue525);
+ RUN_TEST(tool_compare_graphs_progress_cancel_clears_handler_issue525);
RUN_TEST(tool_compare_graphs_enforces_encoded_budget_issue525);
RUN_TEST(tool_compare_graphs_validation_and_scan_cap_are_atomic_issue525);
RUN_TEST(tool_compare_graphs_cancel_and_readonly_handles_release_issue525);
From 706bb2ce81da399f88b6d010d6c28272434aa9f5 Mon Sep 17 00:00:00 2001
From: Martin Vogel
Date: Fri, 28 Aug 2026 21:30:06 +0200
Subject: [PATCH 15/31] feat(lang): add Chialisp support with a first-party
tree-sitter grammar
Chialisp (.clsp/.clib/.clinc) is the Chia smart-coin language. The only
public grammar, Quexington/tree-sitter-chialisp, cannot parse it: on the five
reference files from chia-blockchain@main it produced 3-34 ERROR nodes each.
Three defects account for all of them, and each desynchronised the rest of the
file rather than losing one node:
* comments were `/;.*\r\n/` -- CRLF-only, while real files are LF;
* `(include foo.clib)` was rejected because of the dot in the filename;
* `(defconstant NAME )` accepted only a primitive after the name.
So the grammar is written here instead, in tools/tree-sitter-chialisp/, and
files as first-party: its vendored LICENSE is the project's own, byte-identical
to the repository root, carrying no third-party copyright.
It is deliberately GENERIC -- source_file / list / symbol / string / number /
hex / dot / comment, modelling the clvm_tools reader rather than the Chialisp
form vocabulary. `mod`, `defun`, `defconstant` and `include` are ordinary head
symbols; which lists are definitions is decided in extract_defs.c. A dialect
that adds a form (cl-21 -> cl-23 -> cl-26 each added several) therefore needs
no regenerated parser. Acceptance: all five reference files parse with zero
ERROR and zero MISSING nodes; the three defects above are pinned as tests.
Extraction, and the four decisions in it:
* `Constant` is admitted to the cross-file name registry
(cbm_label_is_registry_symbol). Chialisp's condition codes live as
`(defconstant ...)` in a shared .clib and are referenced by name from every
puzzle; labelled but unregistered they are seeded nowhere, and roughly half
of a Chialisp graph would be present as nodes yet never the target of an
edge. All three registry-seeding sites route through that one predicate, so
they cannot diverge. No other language emits `Constant`, so the blast radius
is exactly Chialisp. Pinned by a two-file graph test that goes red when the
label is removed from the predicate.
* `export` / `namespace` are NOT definition heads. `(export foo)` names a
function already defined in the same file, so treating it as a def head
mints a second node for one symbol. They stay in the not-a-call filter.
* A parameter list is a `list` too, but it binds rather than invokes. Without
that gate `(defun check_conditions (HEIGHTLOCK conditions) ...)` mints a
phantom call to HEIGHTLOCK, and real Chialisp is mostly such definitions.
* `(include *standard-cl-26*)` is a dialect directive, not a file dependency;
recording it would invent a dependency on a file that does not exist.
Three helpers that the defs, calls and unified walks all need -- the quote-data
gate, comment-skipping named-child access, and the Chialisp def-head set -- live
in helpers.c rather than being copied per translation unit. They previously
existed as byte-identical duplicates that had already drifted: extract_defs.c
skipped comments when reading a def head while its mirror in extract_unified.c
did not, so a comment between a head and its name desynchronised defs from call
scope. Both now use the same accessor.
Ledger: 161 -> 162 grammars across the seven published surfaces, ABI-14 86 -> 87,
first-party 13 -> 14, CBM_LANG_COUNT 164 -> 165.
Distilled from PR #919 by darkverbito, whose extraction logic -- the CLVM
not-a-call vocabulary, the quoted-data gate, the filename-derived `mod` name and
the include/embed import handling -- is kept and is the substance of this change.
The grammar that PR vendored is not used.
Co-authored-by: darkverbito <8049985+darkverbito@users.noreply.github.com>
Signed-off-by: Martin Vogel
---
.gitattributes | 4 +
README.md | 12 +-
THIRD_PARTY.md | 12 +-
docs/index.html | 20 +-
docs/llms.txt | 2 +-
internal/cbm/cbm.h | 5 +-
internal/cbm/extract_calls.c | 105 ++-
internal/cbm/extract_defs.c | 92 ++-
internal/cbm/extract_imports.c | 41 +
internal/cbm/extract_unified.c | 42 +-
internal/cbm/extract_usages.c | 26 +-
internal/cbm/grammar_chialisp.c | 3 +
internal/cbm/helpers.c | 73 +-
internal/cbm/helpers.h | 30 +
internal/cbm/lang_specs.c | 14 +
internal/cbm/vendored/grammars/MANIFEST.md | 8 +-
.../cbm/vendored/grammars/chialisp/LICENSE | 22 +
.../cbm/vendored/grammars/chialisp/parser.c | 485 ++++++++++++
.../grammars/chialisp/tree_sitter/alloc.h | 54 ++
.../grammars/chialisp/tree_sitter/array.h | 291 +++++++
.../grammars/chialisp/tree_sitter/parser.h | 286 +++++++
pkg/chocolatey/codebase-memory-mcp.nuspec | 2 +-
pkg/npm/README.md | 4 +-
scripts/audit-license-provenance.py | 6 +-
scripts/package-release.sh | 2 +-
scripts/vendored-checksums.txt | 7 +-
server.json | 2 +-
src/discover/language.c | 6 +
src/discover/userconfig.c | 1 +
tests/repro/repro_call_argument_matrix_b.c | 23 +-
tests/repro/repro_call_node_manifest.c | 9 +-
tests/repro/repro_invariant_breadth.c | 710 +++++++++---------
tests/repro/repro_language_registry.c | 7 +-
tests/test_extraction.c | 228 +++++-
tests/test_grammar_labels.c | 1 +
tests/test_grammar_regression.c | 18 +-
tests/test_lang_contract.c | 79 +-
tests/test_language.c | 10 +
tests/test_language_count_contract.sh | 2 +-
tools/tree-sitter-chialisp/grammar.js | 77 ++
tools/tree-sitter-chialisp/package.json | 21 +
tools/tree-sitter-chialisp/src/grammar.json | 185 +++++
.../tree-sitter-chialisp/src/node-types.json | 106 +++
tools/tree-sitter-chialisp/src/parser.c | 485 ++++++++++++
.../src/tree_sitter/alloc.h | 54 ++
.../src/tree_sitter/array.h | 291 +++++++
.../src/tree_sitter/parser.h | 286 +++++++
.../test/corpus/atoms.txt | 119 +++
.../test/corpus/comments.txt | 83 ++
.../test/corpus/forms.txt | 136 ++++
tools/tree-sitter-chialisp/tree-sitter.json | 16 +
51 files changed, 4114 insertions(+), 489 deletions(-)
create mode 100644 internal/cbm/grammar_chialisp.c
create mode 100644 internal/cbm/vendored/grammars/chialisp/LICENSE
create mode 100644 internal/cbm/vendored/grammars/chialisp/parser.c
create mode 100644 internal/cbm/vendored/grammars/chialisp/tree_sitter/alloc.h
create mode 100644 internal/cbm/vendored/grammars/chialisp/tree_sitter/array.h
create mode 100644 internal/cbm/vendored/grammars/chialisp/tree_sitter/parser.h
create mode 100644 tools/tree-sitter-chialisp/grammar.js
create mode 100644 tools/tree-sitter-chialisp/package.json
create mode 100644 tools/tree-sitter-chialisp/src/grammar.json
create mode 100644 tools/tree-sitter-chialisp/src/node-types.json
create mode 100644 tools/tree-sitter-chialisp/src/parser.c
create mode 100644 tools/tree-sitter-chialisp/src/tree_sitter/alloc.h
create mode 100644 tools/tree-sitter-chialisp/src/tree_sitter/array.h
create mode 100644 tools/tree-sitter-chialisp/src/tree_sitter/parser.h
create mode 100644 tools/tree-sitter-chialisp/test/corpus/atoms.txt
create mode 100644 tools/tree-sitter-chialisp/test/corpus/comments.txt
create mode 100644 tools/tree-sitter-chialisp/test/corpus/forms.txt
create mode 100644 tools/tree-sitter-chialisp/tree-sitter.json
diff --git a/.gitattributes b/.gitattributes
index a76ea0bd6..1b2d4fb75 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -11,3 +11,7 @@ internal/cbm/vendored/grammars/**/scanner.c linguist-generated=true diff=false
# the git hooks are extensionless and need explicit entries. (#1272)
scripts/git-hooks/commit-msg text eol=lf
scripts/hooks/pre-commit text eol=lf
+
+# Chialisp grammar corpus: one case asserts CRLF comment termination, so this
+# file must keep its exact bytes on every platform (no eol conversion). (#919)
+tools/tree-sitter-chialisp/test/corpus/comments.txt -text
diff --git a/README.md b/README.md
index 4744d135c..bc1d0b0d2 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
[](LICENSE)
[](https://github.com/DeusData/codebase-memory-mcp/actions/workflows/dry-run.yml)
[](https://github.com/DeusData/codebase-memory-mcp)
-[](https://github.com/DeusData/codebase-memory-mcp)
+[](https://github.com/DeusData/codebase-memory-mcp)
[](#hybrid-lsp)
[](https://github.com/DeusData/codebase-memory-mcp)
[](https://github.com/DeusData/codebase-memory-mcp)
@@ -16,7 +16,7 @@
**The fastest and most efficient code intelligence engine for AI coding agents.** Full-indexes an average repository in milliseconds, the Linux kernel (28M LOC, 75K files) in 3 minutes. Answers structural queries in under 1ms. Ships as a native executable with a small verified runtime-asset set for macOS, Linux, and Windows — download, run `install`, done.
-High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 161 languages, enhanced with [**Hybrid LSP** semantic type resolution](#hybrid-lsp) for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 15 MCP tools. No language runtime, hosted service, or API key. Plug and play across 45 supported automatic/conditional client surfaces.
+High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-sitter/) AST analysis across all 162 languages, enhanced with [**Hybrid LSP** semantic type resolution](#hybrid-lsp) for Python, TypeScript / JavaScript / JSX / TSX, PHP, C#, Go, C, C++, Java, Kotlin, Rust, and Perl — producing a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. 15 MCP tools. No language runtime, hosted service, or API key. Plug and play across 45 supported automatic/conditional client surfaces.
> **Research** — The design and benchmarks behind this project are described in the preprint [*Codebase-Memory: Tree-Sitter-Based Knowledge Graphs for LLM Code Exploration via MCP*](https://arxiv.org/abs/2603.27277) (arXiv:2603.27277). Evaluated across 31 real-world repositories: 83% answer quality, 10× fewer tokens, 2.1× fewer tool calls vs. file-by-file exploration.
@@ -32,7 +32,7 @@ High-quality parsing through [tree-sitter](https://tree-sitter.github.io/tree-si
- **Extreme indexing speed** — Linux kernel (28M LOC, 75K files) in 3 minutes. RAM-first pipeline: LZ4 compression, in-memory SQLite, fused Aho-Corasick pattern matching. Memory released after indexing.
- **Plug and play** — native executable plus authenticated release-owned assets for macOS (arm64/amd64), Linux (arm64/amd64), and Windows (amd64). The native install needs no Docker, language runtime, or API keys. Download → `install` → restart agent → done.
-- **161 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks.
+- **162 languages** — vendored tree-sitter grammars compiled into the binary. Nothing to install, nothing that breaks.
- **120x fewer tokens** — 5 structural queries: ~3,400 tokens vs ~412,000 via file-by-file search. One graph query replaces dozens of grep/read cycles.
- **45 supported automatic/conditional client surfaces** — `install` configures detected clients and safely activates conditional clients only when their documented platform, marker, or explicit existing config path is present. See [Multi-Agent Support](#multi-agent-support) for the complete matrix and manual/UI-only boundaries.
- **Built-in graph visualization** — 3D interactive UI at `localhost:9749`, served from the binary itself.
@@ -796,14 +796,14 @@ codebase-memory-mcp ships a **lightweight C implementation of language type-reso
**Two-layer architecture:**
-1. **Tree-sitter pass** — fast, syntactic, runs for every one of the 161 languages. Extracts definitions, calls, imports.
+1. **Tree-sitter pass** — fast, syntactic, runs for every one of the 162 languages. Extracts definitions, calls, imports.
2. **Hybrid LSP pass** — type-aware, runs above the tree-sitter pass per-language. Refines call edges using the import graph plus a per-file or pre-built cross-file definition registry. Languages without a Hybrid LSP pass yet fall back to textual resolution, so you always get *some* answer.
The result is a knowledge graph accurate enough to drive `trace_path` across packages, inheritance hierarchies, and stdlib calls — without paying for a language server process per project.
## Language Support
-161 languages, all parsed via vendored tree-sitter grammars compiled into the binary. Benchmarked against 64 real open-source repositories (78 to 49K nodes):
+162 languages, all parsed via vendored tree-sitter grammars compiled into the binary. Benchmarked against 64 real open-source repositories (78 to 49K nodes):
| Tier | Score | Languages |
|------|-------|-----------|
@@ -829,7 +829,7 @@ src/
traces/ Runtime trace ingestion
ui/ Local HTTP server + verified external 3D-UI asset pack
foundation/ Platform abstractions (threads, filesystem, logging, memory)
-internal/cbm/ Vendored tree-sitter grammars (161 languages) + AST extraction engine
+internal/cbm/ Vendored tree-sitter grammars (162 languages) + AST extraction engine
```
## Security
diff --git a/THIRD_PARTY.md b/THIRD_PARTY.md
index d148f9f65..fdcb0d5e4 100644
--- a/THIRD_PARTY.md
+++ b/THIRD_PARTY.md
@@ -52,9 +52,15 @@ License summary:
- `clojure` ([sogaiu/tree-sitter-clojure](https://github.com/sogaiu/tree-sitter-clojure)) is **CC0-1.0**;
`fennel` is **CC0-1.0**; `jinja2` and `just` are **Apache-2.0**;
`pine` is **ISC** (declared by its upstream).
-- The grammars authored in-house for this project (`cobol`, `form`, `janet`,
- `magma`, `protobuf`, `wolfram`) are **MIT** under the project's own license,
- (c) DeusData. Seven further grammars (`arkts`, `assembly`, `cfml`,
+- The grammars authored in-house for this project (`chialisp`, `cobol`, `form`,
+ `janet`, `magma`, `protobuf`, `wolfram`) are **MIT** under the project's own
+ license, (c) DeusData. Each ships the repository's own LICENSE, byte-identical
+ to the root copy; they carry no third-party copyright because there is no
+ third party. `chialisp` is a generic s-expression grammar for the Chia
+ smart-coin language, written for this project because no usable public
+ grammar exists; its source and corpus tests live in
+ `tools/tree-sitter-chialisp/`.
+- Seven further grammars (`arkts`, `assembly`, `cfml`,
`cfscript`, `dotenv`, `pine`, `qml`) are self-maintained forks that retain
their original upstream authors' licenses — see the manifest for per-grammar
provenance. `arkts` is a first-party derivative of
diff --git a/docs/index.html b/docs/index.html
index ac5ecb81f..56496d5d8 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -4,7 +4,7 @@
codebase-memory-mcp — Code Intelligence Knowledge Graph for AI Coding Agents
-
+
@@ -12,7 +12,7 @@
-
+
@@ -24,7 +24,7 @@
-
+
@@ -41,7 +41,7 @@
"applicationSubCategory": "Model Context Protocol (MCP) server",
"operatingSystem": "macOS, Linux, Windows",
"softwareVersion": "0.8.1",
- "description": "An open-source MCP server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links, so AI coding agents answer structural questions with roughly 120x fewer tokens than file-by-file search. Parses 161 languages via tree-sitter with Hybrid LSP semantic type resolution for 9 language families. Ships as a native executable with authenticated release-owned assets; the native install needs no language runtime.",
+ "description": "An open-source MCP server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links, so AI coding agents answer structural questions with roughly 120x fewer tokens than file-by-file search. Parses 162 languages via tree-sitter with Hybrid LSP semantic type resolution for 9 language families. Ships as a native executable with authenticated release-owned assets; the native install needs no language runtime.",
"url": "https://deusdata.github.io/codebase-memory-mcp/",
"downloadUrl": "https://github.com/DeusData/codebase-memory-mcp/releases/latest",
"codeRepository": "https://github.com/DeusData/codebase-memory-mcp",
@@ -125,7 +125,7 @@
"name": "What is codebase-memory-mcp?",
"acceptedAnswer": {
"@type": "Answer",
- "text": "codebase-memory-mcp is an open-source Model Context Protocol (MCP) server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. AI coding agents query that graph instead of reading files one by one, answering structural questions with roughly 120x fewer tokens. It parses 161 languages and ships as a native executable with authenticated release-owned assets; the native install needs no language runtime."
+ "text": "codebase-memory-mcp is an open-source Model Context Protocol (MCP) server that indexes a codebase into a persistent knowledge graph of functions, classes, call chains, HTTP routes, and cross-service links. AI coding agents query that graph instead of reading files one by one, answering structural questions with roughly 120x fewer tokens. It parses 162 languages and ships as a native executable with authenticated release-owned assets; the native install needs no language runtime."
}
},
{
@@ -141,7 +141,7 @@
"name": "Which programming languages does codebase-memory-mcp support?",
"acceptedAnswer": {
"@type": "Answer",
- "text": "It supports 161 languages through vendored tree-sitter grammars compiled into the binary, including Python, Go, JavaScript, TypeScript, Rust, Java, C, C++, C#, PHP, Ruby, Kotlin, Swift, and many more. Nine language families — Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust — additionally get Hybrid LSP semantic type resolution."
+ "text": "It supports 162 languages through vendored tree-sitter grammars compiled into the binary, including Python, Go, JavaScript, TypeScript, Rust, Java, C, C++, C#, PHP, Ruby, Kotlin, Swift, and many more. Nine language families — Python, TypeScript/JavaScript, PHP, C#, Go, C/C++, Java, Kotlin, and Rust — additionally get Hybrid LSP semantic type resolution."
}
},
{
@@ -444,7 +444,7 @@
The fastest, most efficient code intelligence engine for AI coding agents. It indexes any
repository into a persistent knowledge graph — full-indexing an average repo in seconds and the
Linux kernel in 3 minutes — so your agent answers structural questions with ~120x fewer tokens.
- Tree-sitter parsing across 161 languages, Hybrid LSP type resolution, native executable with verified assets.
+ Tree-sitter parsing across 162 languages, Hybrid LSP type resolution, native executable with verified assets.
~120x
fewer tokens
@@ -483,7 +483,7 @@
Protocol (MCP) server that indexes a codebase into a persistent knowledge graph of functions,
classes, call chains, HTTP routes, and cross-service links. Instead of reading files one at a time,
an AI coding agent queries the graph — answering structural questions with roughly 120x fewer
- tokens. It parses 161 languages and ships as a native executable with authenticated release-owned assets;
+ tokens. It parses 162 languages and ships as a native executable with authenticated release-owned assets;
the native install needs no language runtime.
@@ -606,7 +606,7 @@
- The two-layer pipeline runs a fast syntactic tree-sitter pass for every one of the 161 languages,
+ The two-layer pipeline runs a fast syntactic tree-sitter pass for every one of the 162 languages,
then a type-aware Hybrid LSP pass on top for the families above. Languages without a Hybrid LSP pass
yet fall back to textual resolution, so you always get an answer.
@@ -699,7 +699,7 @@
Features
-
161 languages
+
162 languages
Python, Go, JS, TS, TSX, Rust, Java, C++, C#, C, PHP, Ruby, Kotlin, Scala, Zig, Elixir, Haskell, OCaml, Swift, Dart, Lean 4, and many more via vendored tree-sitter grammars compiled into the binary.