Merge origin/main into feat/issue-2061-swift-identity

Brings the branch up to date with main (612eee251). Two textual
conflicts, both where main added its Java external-call guard (#2667)
next to this branch's Swift candidate guard:

- src/pipeline/pass_calls.c: the registry lookup still runs only when
  the Swift candidates allow it, and now passes java_external.
- src/pipeline/pass_parallel.c: the registry fallback condition keeps
  both !java_external and count == 0.

No other file conflicted.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-11-05 01:49:25 +01:00
28 changed files with 2410 additions and 189 deletions
+65
View File
@@ -0,0 +1,65 @@
# FreeBSD build, INFORMATIONAL ONLY. Builds the headless binary (the C core,
# no embedded UI) inside a FreeBSD VM with the canonical scripts/build.sh,
# then checks that the binary starts (--version). The UI build is not run here:
# its npm dependency tree pulls in onnxruntime-node, which publishes no FreeBSD
# build, so `npm ci` stops with EBADPLATFORM. The UI bundle itself is
# platform-independent JavaScript and is built on every other leg.
#
# NON-GATING: workflow_dispatch only. This workflow is not part of the ci-ok
# aggregate and is not called by release.yml or dry-run.yml, so a FreeBSD red
# never blocks a pull request or a release. Nothing from it is uploaded or
# published, and it runs with read-only permissions.
name: FreeBSD build (informational)
on:
workflow_dispatch:
permissions:
contents: read
jobs:
build-freebsd:
name: build (freebsd-${{ matrix.release }}, ${{ matrix.arch }}, clang)
strategy:
fail-fast: false
matrix:
release: ['15.1', '14.4']
arch: ['x86_64']
# arch: ['x86_64', 'aarch64']
# aarch64 is disabled for now: it has not been proven with this
# workflow. Its earlier blocker was the frontend build (lightningcss
# publishes no freebsd-arm64 binary), which this headless job no
# longer runs. Re-enable after a green aarch64 proof run.
include:
- arch: x86_64
sync: rsync
# - arch: aarch64
# sync: scp
# Must be an x86_64 host (ubuntu-latest). QEMU guest emulation on the arm
# runners (ubuntu-*-arm) degrades so badly that runs hit 3+ hour timeouts.
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Build and run inside a FreeBSD ${{ matrix.release }} (${{ matrix.arch }}) VM
# https://github.com/vmactions/freebsd-vm
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1.5.9
with:
release: ${{ matrix.release }}
arch: ${{ matrix.arch }}
# Sync options: rsync, sshfs, nfs, scp
sync: ${{ matrix.sync }}
# Nothing from the VM is used after the run, so skip the copy back.
copyback: false
mem: 6144
cpu: 4
prepare: |
pkg update -f
pkg install -y gmake sqlite3 pkgconf bash
which python3 >/dev/null 2>&1 || ln -sf $(which python3.15 python3.14 python3.13 python3.12 python3.11 python3.10 2>/dev/null | head -n 1) /usr/local/bin/python3
run: |
scripts/build.sh CC=clang CXX=clang++
build/c/codebase-memory-mcp --version
+2 -1
View File
@@ -903,7 +903,8 @@ TEST_PY_LSP_STRESS_SRCS = tests/test_py_lsp_stress.c
TEST_PY_LSP_SCALE_SRCS = tests/test_py_lsp_scale.c
TEST_TS_LSP_SRCS = tests/test_ts_lsp.c
TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c
TEST_JAVA_LSP_SRCS = tests/test_java_lsp.c tests/test_java_lsp_coverage.c \
tests/test_java_typed_calls.c
TEST_KOTLIN_LSP_SRCS = tests/test_kotlin_lsp.c tests/test_kotlin_typed_calls.c
TEST_RUST_LSP_SRCS = tests/test_rust_lsp.c
+2
View File
@@ -145,6 +145,8 @@ Installation also equips supported coding agents to use the graph in daily work.
Hook augmentation is context-only and fail-open: it does not replace or block the requested tool call. Features vary by client and platform; some clients use durable instructions or a parent-agent handoff instead of hooks or direct subagent MCP access. Codex users review and trust installed hooks through `/hooks`. See [Multi-Agent Support](#multi-agent-support) and [Sessions, compaction, and subagents](#sessions-compaction-and-subagents) for exact support and activation requirements.
Hooks are enabled by default. Run `codebase-memory-mcp install --no-hooks` to remove CBM-managed hooks while keeping MCP registration and other integration files; the choice persists for later installs and updates. Run `codebase-memory-mcp install --hooks` to re-enable them, or inspect the current preference with `codebase-memory-mcp config get install_hooks`.
### Session Coordination Daemon
CBM automatically shares one per-account coordination daemon across Claude Code, Codex, OpenCode, and every other configured client. There is no opt-in setting for MCP servers or hook clients: the first daemon-backed CBM session starts it, each session registers its own work, and a session-managed daemon stops after the final session. A daemon started with `daemon start` remains running until stopped. The daemon owns long-lived background services such as watchers, shared indexing jobs, and the optional UI. Closing one session cancels work owned only by that session, while work still needed by another session continues.
+4
View File
@@ -79,6 +79,7 @@ codebase-memory-mcp config set auto_index true
codebase-memory-mcp config set auto_index_limit 50000
codebase-memory-mcp config set watcher_enabled false
codebase-memory-mcp config reset auto_index
codebase-memory-mcp config get install_hooks
```
Current keys:
@@ -96,6 +97,9 @@ Current keys:
| `index_max_source_mb` | `off` | Optional maximum accepted source size in MiB in one discovery run. |
| `index_max_rss_mb` | `off` | Optional maximum charged memory in MiB for the complete contained index-worker process tree (`64..1048576`). On macOS this is phys_footprint, not resident size. |
| `index_max_duration_seconds` | `off` | Optional maximum wall-clock duration in seconds for the whole index request, including crash/hang recovery (`1..86400`). Like `index_max_rss_mb`, it stops a worker only before it starts to publish the new index; see [INDEX_RESOURCE_LIMITS.md](INDEX_RESOURCE_LIMITS.md). |
| `install_hooks` | `true` | Install CBM-managed client hooks. `install --no-hooks` persists `false`; `install --hooks` restores `true`. |
Bare installs and updates respect the stored hook preference. MCP registration, skills, instructions, and profiles remain installed when hooks are disabled.
For a watched project, `index_status` names the polling method in `watch.strategy`:
`git` (HEAD and dirty-state polling), `tree` (a non-git root polled by the
+167 -13
View File
@@ -510,7 +510,19 @@ const char *java_resolve_type_name(JavaLSPContext *ctx, const char *name) {
}
}
/* 3. java.lang auto-import. Try registered first, then the conventional QN. */
/* 3. Java package prefix. A type of the own package shadows every
* on-demand import, java.lang's included (JLS 6.4.1). */
if (ctx->package_name && ctx->package_name[0]) {
char buf[JAVA_LSP_BUF];
int n = snprintf(buf, sizeof(buf), "%s.%s", ctx->package_name, name);
if (n > 0 && (size_t)n < sizeof(buf)) {
if (cbm_registry_lookup_type(ctx->registry, buf)) {
return cbm_arena_strdup(ctx->arena, buf);
}
}
}
/* 4. java.lang auto-import. Try registered first, then the conventional QN. */
for (int i = 0; JAVA_LANG_TYPES[i]; i++) {
if (strcmp(JAVA_LANG_TYPES[i], name) == 0) {
char buf[JAVA_LSP_BUF];
@@ -521,7 +533,7 @@ const char *java_resolve_type_name(JavaLSPContext *ctx, const char *name) {
}
}
/* 4. On-demand imports. */
/* 5. On-demand imports. */
for (int i = 0; i < ctx->import_count; i++) {
if (ctx->import_kinds[i] != CBM_JAVA_IMPORT_ON_DEMAND)
continue;
@@ -534,17 +546,6 @@ const char *java_resolve_type_name(JavaLSPContext *ctx, const char *name) {
}
}
/* 5. Java package prefix. */
if (ctx->package_name && ctx->package_name[0]) {
char buf[JAVA_LSP_BUF];
int n = snprintf(buf, sizeof(buf), "%s.%s", ctx->package_name, name);
if (n > 0 && (size_t)n < sizeof(buf)) {
if (cbm_registry_lookup_type(ctx->registry, buf)) {
return cbm_arena_strdup(ctx->arena, buf);
}
}
}
/* Exact already-qualified name. */
if (cbm_registry_lookup_type(ctx->registry, name)) {
return cbm_arena_strdup(ctx->arena, name);
@@ -969,6 +970,12 @@ const CBMRegisteredFunc *java_lookup_method(JavaLSPContext *ctx, const char *cla
return fallback;
}
/* A type declared in the project: the cross registrar and the own-file pass
* stamp namespace_qn on every project type; the JDK seed leaves it NULL. */
static bool java_is_project_type(const CBMRegisteredType *rt) {
return rt && rt->namespace_qn && rt->namespace_qn[0];
}
/* ── Method-invocation evaluation ─────────────────────────────────── */
static const CBMType *java_return_type_of(const CBMRegisteredFunc *f) {
@@ -1797,11 +1804,135 @@ void java_lsp_process_file(JavaLSPContext *ctx, TSNode root) {
/* ── Call-edge resolution ─────────────────────────────────────────── */
/* True when `callee_qn` names a member (or a constructor) of a registered type
* the project does not declare — a JDK seed type. Such a target has no graph
* node, and the pipeline must not hand it to a same-named project method. */
static bool java_callee_is_external(const JavaLSPContext *ctx, const char *callee_qn) {
if (!ctx->registry || !callee_qn)
return false;
const CBMRegisteredType *self = cbm_registry_lookup_type(ctx->registry, callee_qn);
if (self)
return !java_is_project_type(self);
const char *dot = strrchr(callee_qn, '.');
if (!dot || dot == callee_qn)
return false;
char owner[JAVA_LSP_BUF];
size_t n = (size_t)(dot - callee_qn);
if (n >= sizeof(owner))
return false;
memcpy(owner, callee_qn, n);
owner[n] = '\0';
const CBMRegisteredType *rt = cbm_registry_lookup_type(ctx->registry, owner);
return rt && !java_is_project_type(rt);
}
/* `T`, `ID`, `SELF`: a type-variable spelling — one or two characters, or
* capitals, digits and '_' only — that the file imports neither by name nor
* through its package (`import org.jooq.impl.*;` makes `DSL` a type). A type
* variable the resolver failed to bind can reach a receiver qualified by some
* package; it is unknown, not a library type. */
static bool java_is_type_var_spelling(const JavaLSPContext *ctx, const char *pkg, size_t pkg_len,
const char *simple) {
size_t n = strlen(simple);
bool caps = n > 0;
for (size_t i = 0; i < n && caps; i++) {
caps = isupper((unsigned char)simple[i]) || isdigit((unsigned char)simple[i]) ||
simple[i] == '_';
}
if (!caps && n > 2) {
return false;
}
for (int i = 0; i < ctx->import_count; i++) {
const char *target = ctx->import_target_qns[i];
if (ctx->import_kinds[i] == CBM_JAVA_IMPORT_TYPE && ctx->import_local_names[i] &&
strcmp(ctx->import_local_names[i], simple) == 0) {
return false;
}
if (ctx->import_kinds[i] == CBM_JAVA_IMPORT_ON_DEMAND && target &&
strlen(target) == pkg_len && strncmp(target, pkg, pkg_len) == 0) {
return false;
}
}
return true;
}
/* Whether the shared registry holds no project type under `prefix`
* ("org.jooq."), memoised per package. */
static bool java_package_is_external(JavaLSPContext *ctx, const CBMTypeRegistry *base,
const char *prefix) {
for (int i = 0; i < JAVA_EXT_MEMO; i++) {
if (ctx->ext_memo_pkg[i] && strcmp(ctx->ext_memo_pkg[i], prefix) == 0) {
return ctx->ext_memo_val[i];
}
}
CBMArena scratch;
cbm_arena_init(&scratch);
CBMQNPrefixIter it;
bool project = false;
/* The prefix search reads one layer, and the layer above the shared
* registry is a per-file overlay that is often empty: walk the chain. */
for (const CBMTypeRegistry *reg = base; reg && !project; reg = reg->fallback) {
if (!cbm_registry_types_with_qn_prefix(reg, prefix, true, &scratch, &it))
continue;
for (int ti = cbm_qn_prefix_iter_next(&it); ti >= 0 && !project;
ti = cbm_qn_prefix_iter_next(&it)) {
project = java_is_project_type(&reg->types[ti]);
}
}
cbm_arena_destroy(&scratch);
int slot = ctx->ext_memo_next++ % JAVA_EXT_MEMO;
ctx->ext_memo_pkg[slot] = prefix;
ctx->ext_memo_val[slot] = !project;
return !project;
}
/* A receiver type the registry does not know, spelled with a package the
* project declares no type in (`org.jooq.DSLContext` from an import): a
* library type. The package is the segments before the first capitalised
* one, so a nested type (`com.acme.Order.OrderBuilder`, registered flattened
* as `com.acme.OrderBuilder`) is judged by `com.acme`. Cross-file runs only —
* the shared registry is the one that knows every project package, and its
* type order makes the probe a binary search. A path-derived QN
* (project-prefixed) is an unresolved project spelling, never a library. */
static bool java_receiver_is_external(JavaLSPContext *ctx, const char *recv_qn) {
const CBMTypeRegistry *base = ctx->registry ? ctx->registry->fallback : NULL;
if (!base || !recv_qn || !islower((unsigned char)recv_qn[0]))
return false;
const char *type = NULL;
for (const char *p = recv_qn; (p = strchr(p, '.')) != NULL; p++) {
if (isupper((unsigned char)p[1])) {
type = p + 1;
break;
}
}
if (!type)
return false;
size_t pkg_len = (size_t)(type - 1 - recv_qn);
const char *last = strrchr(recv_qn, '.');
if (java_is_type_var_spelling(ctx, recv_qn, pkg_len, last + 1))
return false;
/* The project segment; a file at the repository root has a module QN
* that is the project name alone. */
const char *mod_dot = ctx->module_qn ? strchr(ctx->module_qn, '.') : NULL;
size_t mod_len = mod_dot ? (size_t)(mod_dot - ctx->module_qn)
: (ctx->module_qn ? strlen(ctx->module_qn) : 0);
if (mod_len && strncmp(recv_qn, ctx->module_qn, mod_len) == 0 && recv_qn[mod_len] == '.')
return false;
if (ctx->package_name && strlen(ctx->package_name) == pkg_len &&
strncmp(ctx->package_name, recv_qn, pkg_len) == 0)
return false;
/* "org.jooq." */
return java_package_is_external(ctx, base, cbm_arena_strndup(ctx->arena, recv_qn, pkg_len + 1));
}
static void java_emit_resolved_orig_kind(JavaLSPContext *ctx, const char *callee_qn,
const char *orig, const char *strategy, float confidence,
CBMResolvedKind kind) {
if (!ctx->resolved_calls || !ctx->enclosing_method_qn || !callee_qn)
return;
if (kind == CBM_RESOLVED_INVOCATION && java_callee_is_external(ctx, callee_qn)) {
strategy = "lsp_external";
}
CBMResolvedCall rc = {0};
rc.caller_qn = ctx->enclosing_method_qn;
rc.callee_qn = callee_qn;
@@ -2145,6 +2276,15 @@ static void resolve_method_call(JavaLSPContext *ctx, TSNode call) {
"lsp_interface_dispatch", 0.80f);
return;
}
/* A library type the registry does not hold: no method of it can be
* looked up, but no project method is its target either. A seeded
* JDK type with no such method stays a diagnostic below, as Rust's
* gate (#2053) needs a positively resolved std method. */
if (!rt && java_receiver_is_external(ctx, recv_qn)) {
java_emit_resolved(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", recv_qn, mname),
"lsp_external", 0.90f);
return;
}
java_emit_unresolved(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", recv_qn, mname),
"no_method_match");
return;
@@ -3214,6 +3354,9 @@ static void register_local_func_or_type_from_file(JavaLSPContext *ctx, CBMTypeRe
stub.qualified_name = d->qualified_name;
stub.short_name = d->name;
stub.is_interface = (strcmp(d->label, "Interface") == 0);
stub.namespace_qn = (ctx->package_name && ctx->package_name[0]) ? ctx->package_name
: (module_qn && module_qn[0]) ? module_qn
: d->qualified_name;
cbm_registry_add_type(reg, stub);
}
@@ -3740,6 +3883,11 @@ void cbm_java_register_lsp_defs(CBMArena *arena, CBMTypeRegistry *reg, const CBM
rt.qualified_name = d->qualified_name;
rt.short_name = d->short_name;
rt.is_interface = (strcmp(d->label, "Interface") == 0) || d->is_interface;
/* Project marker (java_is_project_type) and the package that
* qualifies the type's own member signatures. */
rt.namespace_qn = (d->namespace_name && d->namespace_name[0]) ? d->namespace_name
: (d->def_module_qn && d->def_module_qn[0]) ? d->def_module_qn
: d->qualified_name;
/* Embedded types from "|"-separated text. */
if (d->embedded_types && d->embedded_types[0]) {
int n = 1;
@@ -3883,6 +4031,9 @@ CBMTypeRegistry *cbm_java_build_cross_registry(CBMArena *arena, CBMLSPDef *defs,
cbm_java_register_lsp_defs(arena, reg, jvm + type_count, total - type_count);
}
cbm_registry_finalize(reg);
/* java_receiver_is_external asks "does the project declare a type in
* this package?" per unresolved receiver: a binary search on this order. */
cbm_registry_build_type_qn_order(reg, arena);
reg->read_only = true; /* seal: shared Tier-2 registry is read-only during resolve */
return reg;
}
@@ -3958,6 +4109,9 @@ void cbm_run_java_lsp_cross_with_registry(CBMArena *arena, CBMFileResult *result
}
}
/* The per-file fallback when no shared cross registry was built. Its
* registry has no fallback layer, so java_receiver_is_external never fires
* here: library calls keep the textual registry fallback, as before. */
void cbm_run_java_lsp_cross(CBMArena *arena, const char *source, int source_len,
const char *module_qn, CBMLSPDef *defs, int def_count,
const char **import_names, const char **import_qns, int import_count,
+8
View File
@@ -35,6 +35,8 @@
#include "../cbm.h"
#include "go_lsp.h" /* CBMLSPDef, CBMResolvedCallArray reused across languages */
#define JAVA_EXT_MEMO 8
/* Java `use`-style import kinds. Mirrors PHP's enum with Java semantics. */
enum {
CBM_JAVA_IMPORT_TYPE = 0, /* import com.foo.Bar; — type import */
@@ -90,6 +92,12 @@ typedef struct {
/* Debug mode (CBM_LSP_DEBUG env). */
bool debug;
/* java_receiver_is_external's answers for the packages probed so far
* (a few per file), so each costs one registry walk. */
const char *ext_memo_pkg[JAVA_EXT_MEMO];
bool ext_memo_val[JAVA_EXT_MEMO];
int ext_memo_next;
} JavaLSPContext;
/* ── Initialization / configuration ───────────────────────────────── */
+5 -4
View File
@@ -193,17 +193,18 @@ else
cbm_remove_build_dir "$ROOT" "$BUILD_DIR"
fi
# Step 2: Build (Makefile applies $ARCHFLAGS for the target arch on macOS)
# Step 2: Build (Makefile applies $ARCHFLAGS for the target arch on macOS;
# $MAKE comes from env.sh: gmake on FreeBSD, make elsewhere)
if [[ "$PHASE" == vendored ]]; then
make -j"$NPROC" -f Makefile.cbm cbm-third-party \
"$MAKE" -j"$NPROC" -f Makefile.cbm cbm-third-party \
CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}"
echo "=== Third-party objects built in ${BUILD_DIR} (no binary) ==="
exit 0
elif $WITH_UI; then
make -j"$NPROC" -f Makefile.cbm cbm-with-ui \
"$MAKE" -j"$NPROC" -f Makefile.cbm cbm-with-ui \
CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}"
else
make -j"$NPROC" -f Makefile.cbm cbm "${RETRACE_ARGS[@]+"${RETRACE_ARGS[@]}"}" \
"$MAKE" -j"$NPROC" -f Makefile.cbm cbm "${RETRACE_ARGS[@]+"${RETRACE_ARGS[@]}"}" \
CFLAGS_EXTRA="$CFLAGS_EXTRA" "${EXTRA_MAKE_ARGS[@]+"${EXTRA_MAKE_ARGS[@]}"}"
fi
+15 -2
View File
@@ -6,8 +6,9 @@
# Exports:
# ARCH — target architecture (arm64 / x86_64)
# ARCHFLAGS — "-arch <arch>" on macOS (target slice for clang/ld), empty elsewhere
# MAKE — make program for Makefile.cbm (gmake on FreeBSD, make elsewhere)
# NPROC — number of CPU cores
# OS — darwin / linux / windows
# OS — darwin / linux / windows / freebsd
set -euo pipefail
@@ -16,10 +17,22 @@ OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
case "$OS" in
darwin) OS="darwin" ;;
linux) OS="linux" ;;
freebsd*) OS="freebsd" ;;
mingw*|msys*|cygwin*) OS="windows" ;;
*) OS="unknown" ;;
esac
# ── Select the make program ────────────────────────────────────
# Makefile.cbm is written in GNU make syntax. FreeBSD ships BSD make as its
# default make, so builds there run gmake (pkg install gmake); every other
# platform runs make. Not exported: build.sh sources this file and runs
# $MAKE in the same shell.
if [[ "$OS" == "freebsd" ]]; then
MAKE="gmake"
else
MAKE="make"
fi
# ── Detect / override architecture ─────────────────────────────
# Default: native HARDWARE architecture (not Rosetta-translated).
# On macOS under Rosetta, uname -m returns x86_64 even on Apple Silicon.
@@ -128,5 +141,5 @@ fi
# ── Print environment summary ──────────────────────────────────
print_env() {
local context="$1"
echo "=== $context: os=$OS arch=$ARCH cores=$NPROC cc=${CC:-default} cxx=${CXX:-default} ==="
echo "=== $context: os=$OS arch=$ARCH cores=$NPROC cc=${CC:-default} cxx=${CXX:-default} make=$MAKE ==="
}
+1 -1
View File
@@ -51,7 +51,7 @@ src/git/git_context.c 18
src/main.c 32
src/mcp/compact_out.c 39
src/mcp/index_supervisor.c 8
src/mcp/mcp.c 769
src/mcp/mcp.c 765
src/pipeline/artifact.c 38
src/pipeline/fqn.c 23
src/pipeline/lsp_resolve.h 4
+19
View File
@@ -1863,6 +1863,25 @@ if ! grep -q 'hook-augment' "$GATE_SCRIPT"; then
fi
echo "OK 8e: shim installed, non-blocking, delegates to hook-augment"
HOME="$FAKE_HOME" XDG_CONFIG_HOME="$FAKE_HOME/.config" APPDATA="$FAKE_HOME/AppData/Roaming" LOCALAPPDATA="$FAKE_HOME/AppData/Local" PATH="$FAKE_HOME/.local/bin:$PATH" \
"$BINARY" install --no-hooks --skip-binary --clients=claude -y >/dev/null
# Claude hooks are registered shell-free (#1733): 'hook-augment' is the owned
# marker; the shim names only cover legacy shell registrations.
if grep -qE 'hook-augment|cbm-(code-discovery-gate|session-reminder|subagent-reminder)' "$FAKE_HOME/.claude/settings.json" 2>/dev/null ||
[ -e "$GATE_SCRIPT" ] ||
! grep -q 'codebase-memory-mcp' "$FAKE_HOME/.claude.json" 2>/dev/null; then
echo "FAIL 8e-i: --no-hooks did not remove owned Claude hooks while preserving MCP"
exit 1
fi
HOME="$FAKE_HOME" XDG_CONFIG_HOME="$FAKE_HOME/.config" APPDATA="$FAKE_HOME/AppData/Roaming" LOCALAPPDATA="$FAKE_HOME/AppData/Local" PATH="$FAKE_HOME/.local/bin:$PATH" \
"$BINARY" install --hooks --skip-binary --clients=claude -y >/dev/null
if ! grep -q 'hook-augment' "$FAKE_HOME/.claude/settings.json" 2>/dev/null ||
[ ! -e "$GATE_SCRIPT" ]; then
echo "FAIL 8e-i: --hooks did not restore owned Claude hooks"
exit 1
fi
echo "OK 8e-i: Claude hook opt-out and re-enable preserve MCP"
# 8f-8h: Codex TOML
if ! grep -q '\[mcp_servers.codebase-memory-mcp\]' "$FAKE_HOME/.codex/config.toml"; then
echo "FAIL 8f: Codex TOML missing MCP section"
+356 -128
View File
@@ -8096,6 +8096,7 @@ static const config_key_def_t CONFIG_KEYS[] = {
"Run the background watcher thread (auto-reindex); false to disable"},
{CBM_CONFIG_WATCH_NON_GIT, "false",
"Also poll non-git project roots for changes (file-tree scan); off by default"},
{CBM_CONFIG_INSTALL_HOOKS, "true", "Install managed client hooks"},
{CBM_CONFIG_UI_LANG, "auto", "Pin graph UI language: en, zh, or auto"},
{CBM_CONFIG_UI_ENABLED, "false", "Serve the graph UI on a loopback HTTP port"},
{CBM_CONFIG_UI_PORT, "9749", "Port for the graph UI listener when enabled"},
@@ -8836,6 +8837,11 @@ typedef struct {
static cbm_install_plan_t *g_install_plan = NULL;
static int g_agent_install_errors = 0;
static int g_agent_uninstall_errors = 0;
static bool g_install_hooks = true;
static void print_hooks_skipped(void) {
printf(" hooks: skipped (install --hooks to enable)\n");
}
/* Every agent configuration uninstall could not clean, kept for the closing
* summary. A cleanup failure no longer stops executable and index removal
@@ -8925,6 +8931,9 @@ static void plan_record(const char *agent, const char *kind, const char *path) {
if (!g_install_plan || !path || !path[0]) {
return;
}
if (!g_install_hooks && strcmp(kind, "hook") == 0) {
return;
}
cbm_install_plan_t *pl = g_install_plan;
if (pl->count >= pl->cap) {
int ncap = pl->cap ? pl->cap * 2 : CLI_BUF_16;
@@ -9052,6 +9061,122 @@ static void uninstall_tiered_profile_prompts(const char *label, const char *veri
cbm_graph_profile_dialect_t dialect,
const char *legacy_verify_content, bool dry_run);
static int remove_claude_owned_hook_scripts(const char *config_dir, const char *binary_path,
bool uninstall) {
char current_gate[CLI_BUF_8K];
char current_session[CLI_BUF_8K];
char current_subagent[CLI_BUF_8K];
char released_gate[CLI_BUF_8K];
const char *const gate_legacy[] = {released_gate};
const char *const session_legacy[] = {cmm_released_session_script};
const char *const subagent_legacy[] = {cmm_released_subagent_script};
size_t gate_legacy_count =
cbm_build_released_gate_script(binary_path, released_gate, sizeof(released_gate)) == CLI_OK
? 1U
: 0U;
static const struct {
const char *name;
const char *legacy_name;
const char *prefix;
} hook_types[] = {
{CMM_HOOK_GATE_SCRIPT, CMM_HOOK_GATE_SCRIPT_LEGACY, cmm_gate_script_prefix},
{CMM_SESSION_REMINDER_SCRIPT, CMM_SESSION_REMINDER_SCRIPT_LEGACY,
cmm_session_script_prefix},
{CMM_SUBAGENT_REMINDER_SCRIPT, CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY,
cmm_subagent_script_prefix},
};
struct {
const char *name;
const char *legacy_name;
const char *current;
const char *const *legacy;
size_t legacy_count;
bool current_valid;
} owned_scripts[] = {
{hook_types[0].name, hook_types[0].legacy_name, current_gate, gate_legacy,
gate_legacy_count,
cbm_build_current_hook_script(hook_types[0].prefix, binary_path, current_gate,
sizeof(current_gate)) == CLI_OK},
{hook_types[1].name, hook_types[1].legacy_name, current_session, session_legacy, 1U,
cbm_build_current_hook_script(hook_types[1].prefix, binary_path, current_session,
sizeof(current_session)) == CLI_OK},
{hook_types[2].name, hook_types[2].legacy_name, current_subagent, subagent_legacy, 1U,
cbm_build_current_hook_script(hook_types[2].prefix, binary_path, current_subagent,
sizeof(current_subagent)) == CLI_OK},
};
char hooks_dir[CLI_BUF_1K];
int hooks_written = snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir);
bool hooks_dir_valid = hooks_written > 0 && (size_t)hooks_written < sizeof(hooks_dir);
int rc = CLI_OK;
const char *op = uninstall ? "hook_script_uninstall" : "hook_script_disable";
for (size_t i = 0; i < sizeof(owned_scripts) / sizeof(owned_scripts[0]); i++) {
char script_path[CLI_BUF_1K];
int script_written = hooks_dir_valid ? snprintf(script_path, sizeof(script_path), "%s/%s",
hooks_dir, owned_scripts[i].name)
: CLI_ERR;
bool script_path_valid = script_written > 0 && (size_t)script_written < sizeof(script_path);
if (!owned_scripts[i].current_valid) {
record_agent_config_error(uninstall, "Claude Code", op, owned_scripts[i].name);
rc = CLI_ERR;
continue;
}
if (!script_path_valid ||
cbm_remove_owned_hook_script(script_path, owned_scripts[i].current,
owned_scripts[i].legacy,
owned_scripts[i].legacy_count) < CLI_OK) {
record_agent_config_error(uninstall, "Claude Code", op,
script_path_valid ? script_path : owned_scripts[i].name);
rc = CLI_ERR;
}
#ifdef _WIN32
if (!hooks_dir_valid ||
cbm_remove_owned_legacy_hook_script(hooks_dir, owned_scripts[i].legacy_name,
owned_scripts[i].current, owned_scripts[i].legacy,
owned_scripts[i].legacy_count) != CLI_OK) {
char legacy_path[CLI_BUF_1K];
int written = hooks_dir_valid ? snprintf(legacy_path, sizeof(legacy_path), "%s/%s",
hooks_dir, owned_scripts[i].legacy_name)
: CLI_ERR;
record_agent_config_error(uninstall, "Claude Code", "legacy_hook_script_uninstall",
written > 0 && (size_t)written < sizeof(legacy_path)
? legacy_path
: owned_scripts[i].legacy_name);
rc = CLI_ERR;
}
#endif
}
return rc;
}
/* Augment's SessionStart and PostToolUse view scripts are exact-owned: each is
* rebuilt from the binary path the install embedded and deleted only on a
* byte-for-byte match, so a script the user edited stays. Uninstall and the
* install-hook opt-out share this rule. */
static void remove_augment_owned_hook_scripts(const char *binary_path, const char *session_path,
const char *coverage_path, bool uninstall) {
const struct {
const char *path;
int (*build)(const char *, char *, size_t);
const char *build_op;
const char *remove_op;
} scripts[] = {
{session_path, cbm_build_augment_session_script, "session_script_build",
uninstall ? "session_script_uninstall" : "session_script_disable"},
{coverage_path, cbm_build_augment_coverage_script, "coverage_script_build",
uninstall ? "coverage_script_uninstall" : "coverage_script_disable"},
};
for (size_t i = 0; i < sizeof(scripts) / sizeof(scripts[0]); i++) {
char script[CLI_BUF_8K];
if (scripts[i].build(binary_path, script, sizeof(script)) != CLI_OK) {
record_agent_config_error(uninstall, "Augment/Auggie", scripts[i].build_op,
scripts[i].path);
} else if (cbm_text_remove_owned_document(scripts[i].path, script) < 0) {
record_agent_config_error(uninstall, "Augment/Auggie", scripts[i].remove_op,
scripts[i].path);
}
}
}
static void install_claude_code_config(const char *home, const char *binary_path, bool force,
bool dry_run) {
char config_dir[CLI_BUF_1K];
@@ -9130,6 +9255,23 @@ static void install_claude_code_config(const char *home, const char *binary_path
char settings_path[CLI_BUF_1K];
snprintf(settings_path, sizeof(settings_path), "%s/settings.json", config_dir);
if (!g_install_hooks) {
if (!dry_run) {
if (cbm_remove_claude_hooks_with_binary(settings_path, binary_path) != CLI_OK) {
record_agent_config_error(false, "Claude Code", "hook_disable", settings_path);
}
if (cbm_remove_session_hooks(settings_path, binary_path) != CLI_OK) {
record_agent_config_error(false, "Claude Code", "hook_disable", settings_path);
}
if (cbm_remove_claude_subagent_hooks_with_binary(settings_path, binary_path) !=
CLI_OK) {
record_agent_config_error(false, "Claude Code", "hook_disable", settings_path);
}
remove_claude_owned_hook_scripts(config_dir, binary_path, false);
}
print_hooks_skipped();
goto claude_hooks_done;
}
bool gate_ok = dry_run;
bool session_ok = dry_run;
bool subagent_ok = dry_run;
@@ -9209,6 +9351,7 @@ static void install_claude_code_config(const char *home, const char *binary_path
}
}
claude_hooks_done:
/* Migration nudge: when CLAUDE_CONFIG_DIR is set and a legacy ~/.claude tree
* still exists, mention it so users can clean up stale artifacts. */
if (home && home[0]) {
@@ -9866,6 +10009,14 @@ static void install_copilot_durable_context(const char *home, const char *binary
plan_record("Copilot", "hook", hook_path);
return;
}
if (!g_install_hooks) {
if (!dry_run && cbm_file_exists(hook_path) &&
cbm_remove_copilot_hooks(hook_path, binary_path) != CLI_OK) {
record_agent_config_error(false, "Copilot", "hook_disable", hook_path);
}
print_hooks_skipped();
return;
}
bool hook_ok = true;
if (!dry_run && (!cbm_mkdir_p_ex(hooks_dir, CLI_OCTAL_PERM, CBM_MKDIR_FOLLOW_OWNED) ||
cbm_upsert_copilot_hooks(binary_path, hook_path) != CLI_OK)) {
@@ -10042,6 +10193,14 @@ static void install_qoder_durable_context(const char *home, const char *binary_p
}
return;
}
if (!g_install_hooks) {
if (!dry_run && config_resolved && cbm_file_exists(settings_path) &&
cbm_remove_qoder_context_hook(settings_path, binary_path) != CLI_OK) {
record_agent_config_error(false, "Qoder CLI", "hook_disable", settings_path);
}
print_hooks_skipped();
return;
}
if (!hook_supported) {
printf(" hooks: withheld because no documented executor is available\n");
return;
@@ -10101,6 +10260,14 @@ static void install_gitlab_durable_context(const cbm_agent_registry_context_t *r
}
return;
}
if (!g_install_hooks) {
if (!dry_run && cbm_file_exists(hooks_path) &&
cbm_remove_gitlab_session_hook(hooks_path, binary_path) != CLI_OK) {
record_agent_config_error(false, "GitLab Duo CLI", "hook_disable", hooks_path);
}
print_hooks_skipped();
return;
}
if (!hook_supported) {
printf(" hook: withheld on Windows (vendor hook shell is undocumented)\n");
return;
@@ -10138,6 +10305,14 @@ static void install_devin_durable_context(const cbm_agent_registry_context_t *re
}
return;
}
if (!g_install_hooks) {
if (!dry_run && config_resolved && cbm_file_exists(config_path) &&
cbm_remove_devin_context_hooks(config_path, binary_path) != CLI_OK) {
record_agent_config_error(false, "Devin CLI / Local", "hook_disable", config_path);
}
print_hooks_skipped();
return;
}
if (!hook_supported) {
printf(" hooks: withheld on Windows (vendor hook shell is undocumented)\n");
return;
@@ -10275,6 +10450,14 @@ static void install_kimi_durable_context(const cbm_agent_registry_context_t *reg
plan_record("Kimi Code CLI", "hook", config_path);
return;
}
if (!g_install_hooks) {
if (!dry_run && cbm_file_exists(config_path) &&
cbm_remove_kimi_context_hook(config_path) != CLI_OK) {
record_agent_config_error(false, "Kimi Code CLI", "hook_disable", config_path);
}
print_hooks_skipped();
return;
}
bool installed = true;
if (!dry_run && (!prepare_config_parent(config_path) ||
cbm_upsert_kimi_context_hook(config_path, binary_path) != CLI_OK)) {
@@ -10494,6 +10677,22 @@ static void install_gemini_config(const char *home, const char *binary_path, boo
plan_record("Gemini CLI", "hook", cp); /* BeforeTool + SessionStart in settings.json */
return;
}
if (!g_install_hooks) {
if (!dry_run) {
int tool_result = cbm_remove_gemini_hooks(cp);
int coverage_result = CLI_OK;
#ifndef _WIN32
coverage_result = cbm_remove_gemini_coverage_hook(cp, binary_path);
#endif
int session_result = cbm_remove_gemini_session_hooks(cp);
if (tool_result != CLI_OK || coverage_result != CLI_OK || session_result != CLI_OK) {
record_agent_config_error(false, "Gemini CLI", "hook_disable", cp);
}
}
print_hooks_skipped();
printf(" subagents: Scout + Verify + Auditor\n");
return;
}
if (!dry_run) {
if (cbm_upsert_gemini_hooks(cp) != CLI_OK) {
record_agent_config_error(false, "Gemini CLI", "before_tool_hook_install", cp);
@@ -10537,8 +10736,9 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const
cbm_build_augment_command(binary_path, command, sizeof(command)) == CLI_OK &&
cbm_build_augment_command_windows(binary_path, command_windows,
sizeof(command_windows)) == CLI_OK;
cbm_toml_codex_hook_action_t preflight_action =
use_hooks_json ? CBM_TOML_CODEX_HOOK_REMOVE : CBM_TOML_CODEX_HOOK_UPSERT;
cbm_toml_codex_hook_action_t preflight_action = !g_install_hooks || use_hooks_json
? CBM_TOML_CODEX_HOOK_REMOVE
: CBM_TOML_CODEX_HOOK_UPSERT;
cbm_toml_codex_hook_failure_t preflight_failure = CBM_TOML_CODEX_HOOK_FAILURE_NONE;
int preflight_result = commands_ok ? cbm_reconcile_codex_hooks_command_detailed(
cp, command, command_windows, preflight_action,
@@ -10586,7 +10786,20 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const
} else {
bool hook_ok = true;
int pretool_written = 1;
if (!dry_run && use_hooks_json) {
if (!g_install_hooks && !dry_run) {
int toml_result = cbm_reconcile_codex_hooks_command(
cp, command, command_windows, CBM_TOML_CODEX_HOOK_REMOVE, false);
int json_result = !cbm_file_exists(hooks_json)
? CLI_OK
: cbm_remove_paired_lifecycle_hooks_json(hooks_json, command);
/* #2719: the PreToolUse Bash hook shares hooks.json with the
* lifecycle pair, so the opt-out removes it the way uninstall does. */
int pretool_result = !cbm_file_exists(hooks_json)
? CLI_OK
: cbm_remove_codex_pretool_hooks_json(hooks_json, command);
hook_ok =
toml_result == CLI_OK && json_result == CLI_OK && pretool_result == CLI_OK;
} else if (!dry_run && use_hooks_json) {
hook_ok =
cbm_upsert_paired_lifecycle_hooks_json(hooks_json, command, command_windows,
NULL, CMM_HOOK_TIMEOUT_SEC) == CLI_OK &&
@@ -10600,7 +10813,11 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const
&pretool_written) == CLI_OK;
}
if (!hook_ok) {
record_agent_config_error(false, "Codex CLI", "hook_install", hook_target);
record_agent_config_error(false, "Codex CLI",
g_install_hooks ? "hook_install" : "hook_disable",
hook_target);
} else if (!g_install_hooks) {
print_hooks_skipped();
} else {
printf(pretool_written
? " hooks: SessionStart + SubagentStart + PreToolUse Bash (dynamic "
@@ -10646,8 +10863,17 @@ static void install_cli_agent_configs(const cbm_detected_agents_t *agents, const
char plugin_path[CLI_BUF_1K];
snprintf(plugin_path, sizeof(plugin_path), "%s/.config/opencode/plugins/cbm-augment.ts",
home);
install_generated_client_extension("OpenCode", plugin_path, binary_path,
cbm_client_adapter_opencode, dry_run);
if (g_install_hooks) {
install_generated_client_extension("OpenCode", plugin_path, binary_path,
cbm_client_adapter_opencode, dry_run);
} else if (!g_install_plan) {
if (!dry_run && cbm_file_exists(plugin_path) &&
cbm_text_remove_managed_block(plugin_path, CBM_ADAPTER_MARKER_START,
CBM_ADAPTER_MARKER_END) != 0) {
record_agent_config_error(false, "OpenCode", "hook_disable", plugin_path);
}
print_hooks_skipped();
}
}
if (agents->antigravity) {
char cp[CLI_BUF_1K];
@@ -10929,6 +11155,13 @@ static void install_editor_agent_configs(const cbm_detected_agents_t *agents, co
bool workspace_ok = cbm_openclaw_workspace_path(home, cp, workspace, sizeof(workspace));
(void)install_generic_agent_config("OpenClaw", binary_path, cp, NULL, dry_run,
cbm_install_openclaw_mcp);
if (!g_install_hooks && !g_install_plan) {
if (!dry_run && cbm_file_exists(cp) &&
cbm_remove_openclaw_compaction(cp) != CLI_OK) {
record_agent_config_error(false, "OpenClaw", "hook_disable", cp);
}
print_hooks_skipped();
}
if (workspace_ok) {
char agents_path[CLI_BUF_1K];
char tools_path[CLI_BUF_1K];
@@ -10951,16 +11184,16 @@ static void install_editor_agent_configs(const cbm_detected_agents_t *agents, co
record_agent_config_error(false, "OpenClaw", "tools_context_install",
tools_path);
}
if (cbm_upsert_openclaw_compaction(cp) != CLI_OK) {
if (g_install_hooks && cbm_upsert_openclaw_compaction(cp) != CLI_OK) {
compaction_installed = false;
record_agent_config_error(false, "OpenClaw", "compaction_install", cp);
}
}
printf(" instructions: %s\n", agents_path);
printf(" tools context: %s\n", tools_path);
if (compaction_installed) {
if (g_install_hooks && compaction_installed) {
printf(" compaction: reinjects Codebase Memory\n");
} else {
} else if (g_install_hooks) {
printf(" compaction: could not update exact-owned augmentation\n");
}
}
@@ -11047,14 +11280,19 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents
} else {
int hook_result = CBM_YAML_IDENTITY_EDIT_OK;
if (!dry_run) {
hook_result = prepare_config_parent(cp)
? cbm_upsert_hermes_context_hook(cp, binary_path)
: CBM_YAML_IDENTITY_EDIT_ERROR;
hook_result = g_install_hooks
? (prepare_config_parent(cp)
? cbm_upsert_hermes_context_hook(cp, binary_path)
: CBM_YAML_IDENTITY_EDIT_ERROR)
: cbm_remove_hermes_context_hook(cp, binary_path);
}
if (hook_result == CBM_YAML_IDENTITY_EDIT_FOREIGN) {
record_agent_config_error(false, "Hermes", "pre_llm_hook_foreign", cp);
} else if (hook_result != CBM_YAML_IDENTITY_EDIT_OK) {
record_agent_config_error(false, "Hermes", "pre_llm_hook_install", cp);
record_agent_config_error(
false, "Hermes", g_install_hooks ? "pre_llm_hook_install" : "hook_disable", cp);
} else if (!g_install_hooks) {
print_hooks_skipped();
} else {
printf(" hook: %s (pre_llm_call)\n", cp);
}
@@ -11121,7 +11359,12 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents
} else {
bool hook_ok = true;
if (!dry_run) {
if (!cbm_install_augment_session_script(binary_path, session_hp)) {
if (!g_install_hooks) {
int session_result = cbm_remove_augment_session_hook(cp, session_hp);
int coverage_result = cbm_remove_augment_coverage_hook(cp, coverage_hp);
hook_ok = session_result == CLI_OK && coverage_result == CLI_OK;
remove_augment_owned_hook_scripts(binary_path, session_hp, coverage_hp, false);
} else if (!cbm_install_augment_session_script(binary_path, session_hp)) {
hook_ok = false;
record_agent_config_error(false, "Augment/Auggie", "session_script_install",
session_hp);
@@ -11129,16 +11372,23 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents
hook_ok = false;
record_agent_config_error(false, "Augment/Auggie", "session_hook_install", cp);
}
if (!cbm_install_augment_coverage_script(binary_path, coverage_hp)) {
hook_ok = false;
record_agent_config_error(false, "Augment/Auggie", "coverage_script_install",
coverage_hp);
} else if (cbm_upsert_augment_coverage_hook(cp, coverage_hp) != CLI_OK) {
hook_ok = false;
record_agent_config_error(false, "Augment/Auggie", "coverage_hook_install", cp);
if (g_install_hooks) {
if (!cbm_install_augment_coverage_script(binary_path, coverage_hp)) {
hook_ok = false;
record_agent_config_error(false, "Augment/Auggie",
"coverage_script_install", coverage_hp);
} else if (cbm_upsert_augment_coverage_hook(cp, coverage_hp) != CLI_OK) {
hook_ok = false;
record_agent_config_error(false, "Augment/Auggie", "coverage_hook_install",
cp);
}
}
}
if (hook_ok) {
if (!hook_ok && !g_install_hooks) {
record_agent_config_error(false, "Augment/Auggie", "hook_disable", cp);
} else if (!g_install_hooks) {
print_hooks_skipped();
} else if (hook_ok) {
printf(" hooks: SessionStart + PostToolUse view coverage\n");
}
}
@@ -11199,11 +11449,20 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents
#else
bool windows = false;
#endif
if (cbm_upsert_qwen_lifecycle_hooks(cp, binary_path, windows) != CLI_OK) {
record_agent_config_error(false, "Qwen Code", "lifecycle_hook_install", cp);
int hook_result = g_install_hooks
? cbm_upsert_qwen_lifecycle_hooks(cp, binary_path, windows)
: cbm_remove_qwen_lifecycle_hooks(cp, binary_path, windows);
if (hook_result != CLI_OK) {
record_agent_config_error(
false, "Qwen Code", g_install_hooks ? "lifecycle_hook_install" : "hook_disable",
cp);
} else if (!g_install_hooks) {
print_hooks_skipped();
} else {
printf(" hooks: SessionStart + SubagentStart + PostToolUse ReadFile\n");
}
} else if (!g_install_hooks) {
print_hooks_skipped();
}
}
if (agents->copilot_cli) {
@@ -11248,6 +11507,12 @@ static void install_additional_agent_configs(const cbm_detected_agents_t *agents
if (hook_supported) {
plan_record("Factory Droid", "hook", hp);
}
} else if (!g_install_hooks) {
if (!dry_run && cbm_file_exists(hp) &&
cbm_remove_factory_hooks(hp, binary_path) != CLI_OK) {
record_agent_config_error(false, "Factory Droid", "hook_disable", hp);
}
print_hooks_skipped();
} else if (!hook_supported) {
printf(" hooks: withheld on Windows (vendor documents Bash only)\n");
} else {
@@ -11798,6 +12063,44 @@ static bool cli_binary_is_externally_managed(const char *self_path, bool self_pa
return cli_external_manager_name(self_path) != NULL;
}
static int cli_resolve_install_hooks(bool override_set, bool override_value) {
bool enabled = true;
if (!override_set) {
const char *cache_dir = cbm_resolve_cache_dir();
char db_path[CLI_BUF_1K];
int written =
cache_dir ? snprintf(db_path, sizeof(db_path), "%s/_config.db", cache_dir) : CLI_ERR;
if (written <= 0 || (size_t)written >= sizeof(db_path)) {
(void)fprintf(stderr, "error: config database path is unavailable\n");
return CLI_ERR;
}
if (cbm_file_exists(db_path)) {
cbm_config_t *cfg = cbm_config_open(cache_dir);
if (!cfg) {
(void)fprintf(stderr, "error: cannot read install_hooks preference\n");
return CLI_ERR;
}
enabled = cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, true);
cbm_config_close(cfg);
}
} else {
enabled = override_value;
}
g_install_hooks = enabled;
return CLI_OK;
}
static int cli_persist_install_hooks(void) {
const char *cache_dir = cbm_resolve_cache_dir();
cbm_config_t *cfg = cache_dir ? cbm_config_open(cache_dir) : NULL;
if (!cfg) {
return CLI_ERR;
}
int result = cbm_config_set(cfg, CBM_CONFIG_INSTALL_HOOKS, g_install_hooks ? "true" : "false");
cbm_config_close(cfg);
return result;
}
/* Build the agent.install.plan.v1 receipt (#388): a machine-readable list of
* the config / instruction / skill / agent / hook files `install` WOULD write, produced by
* running the real install dispatch in record-only mode (no mutation, no
@@ -11946,6 +12249,7 @@ typedef struct {
bool skip_binary;
bool delete_indexes;
bool skip_config;
bool persist_install_hooks;
bool force;
bool dry_run;
} cli_install_activation_t;
@@ -12001,6 +12305,13 @@ static int cli_install_activate(void *opaque) {
}
printf("Installed binary -> %s\n\n", activation->bin_target);
}
if (!activation->dry_run && activation->persist_install_hooks &&
cli_persist_install_hooks() != CLI_OK) {
cli_activation_transaction_finalize_committed_or_fail_stop(
&activation->binary_transaction, "install_transaction_config_failure_finalize");
(void)fprintf(stderr, "error: cannot persist install_hooks preference\n");
return CLI_ACTIVATION_PARTIAL;
}
/* Config and PATH refreshes are install mutations too. Keep them in this
* callback so the startup lock covers the complete filesystem window,
* including same-binary and non-force installs. */
@@ -12075,6 +12386,7 @@ static int cli_install_activate(void *opaque) {
int cbm_cmd_install(int argc, char **argv) {
parse_auto_answer(argc, argv);
g_client_selection = NULL;
bool dry_run = false;
bool force = false;
bool plan = false;
@@ -12082,6 +12394,8 @@ int cbm_cmd_install(int argc, char **argv) {
bool skip_config = false;
bool skip_binary = false;
bool force_binary = false;
bool hooks_flag = false;
bool no_hooks_flag = false;
const char *requested_clients = NULL;
const char *requested_bin_dir = NULL;
for (int i = 0; i < argc; i++) {
@@ -12095,6 +12409,10 @@ int cbm_cmd_install(int argc, char **argv) {
reset_indexes = true;
} else if (strcmp(argv[i], "--skip-config") == 0) {
skip_config = true;
} else if (strcmp(argv[i], "--hooks") == 0) {
hooks_flag = true;
} else if (strcmp(argv[i], "--no-hooks") == 0) {
no_hooks_flag = true;
} else if (strncmp(argv[i], "--clients=", SLEN("--clients=")) == 0) {
requested_clients = argv[i] + SLEN("--clients=");
if (!requested_clients[0]) {
@@ -12132,11 +12450,19 @@ int cbm_cmd_install(int argc, char **argv) {
}
}
if (hooks_flag && no_hooks_flag) {
(void)fprintf(stderr, "error: --hooks and --no-hooks cannot be used together\n");
return CLI_TRUE;
}
const char *home = cbm_get_home_dir();
if (!home) {
(void)fprintf(stderr, "error: HOME not set (use USERPROFILE on Windows)\n");
return CLI_TRUE;
}
if (cli_resolve_install_hooks(hooks_flag || no_hooks_flag, hooks_flag) != CLI_OK) {
return CLI_TRUE;
}
char bin_dir[CLI_BUF_1K];
int bin_dir_length = requested_bin_dir
@@ -12403,6 +12729,7 @@ int cbm_cmd_install(int argc, char **argv) {
.copy_binary = do_copy,
.delete_indexes = delete_indexes,
.skip_config = skip_config,
.persist_install_hooks = hooks_flag || no_hooks_flag,
.skip_binary = skip_binary,
.force = force,
.dry_run = dry_run,
@@ -12508,86 +12835,7 @@ static void uninstall_claude_code(const char *home, const char *installed_binary
record_agent_config_error(true, "Claude Code", "subagent_hook_uninstall",
settings_path);
}
char current_gate[CLI_BUF_8K];
char current_session[CLI_BUF_8K];
char current_subagent[CLI_BUF_8K];
char released_gate[CLI_BUF_8K];
const char *const gate_legacy[] = {released_gate};
const char *const session_legacy[] = {cmm_released_session_script};
const char *const subagent_legacy[] = {cmm_released_subagent_script};
size_t gate_legacy_count = cbm_build_released_gate_script(installed_binary, released_gate,
sizeof(released_gate)) == CLI_OK
? 1U
: 0U;
static const struct {
const char *name;
const char *legacy_name;
const char *prefix;
} hook_types[] = {
{CMM_HOOK_GATE_SCRIPT, CMM_HOOK_GATE_SCRIPT_LEGACY, cmm_gate_script_prefix},
{CMM_SESSION_REMINDER_SCRIPT, CMM_SESSION_REMINDER_SCRIPT_LEGACY,
cmm_session_script_prefix},
{CMM_SUBAGENT_REMINDER_SCRIPT, CMM_SUBAGENT_REMINDER_SCRIPT_LEGACY,
cmm_subagent_script_prefix},
};
struct {
const char *name;
const char *legacy_name;
const char *current;
const char *const *legacy;
size_t legacy_count;
bool current_valid;
} owned_scripts[] = {
{hook_types[0].name, hook_types[0].legacy_name, current_gate, gate_legacy,
gate_legacy_count,
cbm_build_current_hook_script(hook_types[0].prefix, installed_binary, current_gate,
sizeof(current_gate)) == CLI_OK},
{hook_types[1].name, hook_types[1].legacy_name, current_session, session_legacy, 1U,
cbm_build_current_hook_script(hook_types[1].prefix, installed_binary, current_session,
sizeof(current_session)) == CLI_OK},
{hook_types[2].name, hook_types[2].legacy_name, current_subagent, subagent_legacy, 1U,
cbm_build_current_hook_script(hook_types[2].prefix, installed_binary, current_subagent,
sizeof(current_subagent)) == CLI_OK},
};
char hooks_dir[CLI_BUF_1K];
int hooks_written = snprintf(hooks_dir, sizeof(hooks_dir), "%s/hooks", config_dir);
bool hooks_dir_valid = hooks_written > 0 && (size_t)hooks_written < sizeof(hooks_dir);
for (size_t i = 0; i < sizeof(owned_scripts) / sizeof(owned_scripts[0]); i++) {
char script_path[CLI_BUF_1K];
int script_written = hooks_dir_valid
? snprintf(script_path, sizeof(script_path), "%s/%s",
hooks_dir, owned_scripts[i].name)
: CLI_ERR;
bool script_path_valid =
script_written > 0 && (size_t)script_written < sizeof(script_path);
if (!owned_scripts[i].current_valid) {
record_agent_config_error(true, "Claude Code", "hook_script_uninstall",
owned_scripts[i].name);
continue;
}
if (!script_path_valid ||
cbm_remove_owned_hook_script(script_path, owned_scripts[i].current,
owned_scripts[i].legacy,
owned_scripts[i].legacy_count) < CLI_OK) {
record_agent_config_error(true, "Claude Code", "hook_script_uninstall",
script_path_valid ? script_path : owned_scripts[i].name);
}
#ifdef _WIN32
if (!hooks_dir_valid ||
cbm_remove_owned_legacy_hook_script(
hooks_dir, owned_scripts[i].legacy_name, owned_scripts[i].current,
owned_scripts[i].legacy, owned_scripts[i].legacy_count) != CLI_OK) {
char legacy_path[CLI_BUF_1K];
int written = hooks_dir_valid ? snprintf(legacy_path, sizeof(legacy_path), "%s/%s",
hooks_dir, owned_scripts[i].legacy_name)
: CLI_ERR;
record_agent_config_error(true, "Claude Code", "legacy_hook_script_uninstall",
written > 0 && (size_t)written < sizeof(legacy_path)
? legacy_path
: owned_scripts[i].legacy_name);
}
#endif
}
remove_claude_owned_hook_scripts(config_dir, installed_binary, true);
}
printf(" removed PreToolUse + SessionStart + SubagentStart hooks\n");
}
@@ -13491,30 +13739,7 @@ static void uninstall_additional_agents(const cbm_detected_agents_t *agents, con
if (cbm_remove_augment_coverage_hook(cp, coverage_hp) != CLI_OK) {
record_agent_config_error(true, "Augment/Auggie", "coverage_hook_uninstall", cp);
}
char session_script[CLI_BUF_8K];
if (cbm_build_augment_session_script(binary_path, session_script,
sizeof(session_script)) != CLI_OK) {
record_agent_config_error(true, "Augment/Auggie", "session_script_build",
session_hp);
} else {
int script_rc = cbm_text_remove_owned_document(session_hp, session_script);
if (script_rc < 0) {
record_agent_config_error(true, "Augment/Auggie", "session_script_uninstall",
session_hp);
}
}
char coverage_script[CLI_BUF_8K];
if (cbm_build_augment_coverage_script(binary_path, coverage_script,
sizeof(coverage_script)) != CLI_OK) {
record_agent_config_error(true, "Augment/Auggie", "coverage_script_build",
coverage_hp);
} else {
int script_rc = cbm_text_remove_owned_document(coverage_hp, coverage_script);
if (script_rc < 0) {
record_agent_config_error(true, "Augment/Auggie", "coverage_script_uninstall",
coverage_hp);
}
}
remove_augment_owned_hook_scripts(binary_path, session_hp, coverage_hp, true);
}
printf(" removed SessionStart + PostToolUse hooks + dedicated subagent\n");
}
@@ -14595,6 +14820,9 @@ int cbm_cmd_update(int argc, char **argv) {
"v0.10.0 there is one build per platform and it always includes the "
"graph UI.\n");
}
if (cli_resolve_install_hooks(false, true) != CLI_OK) {
return CLI_TRUE;
}
/* #1566: we cannot update a binary a package manager owns, and pretending
* otherwise is the dishonest-success pattern this project keeps fixing:
+1
View File
@@ -532,6 +532,7 @@ bool cbm_config_load_index_policy(cbm_config_t *cfg, cbm_index_resource_policy_t
#define CBM_CONFIG_AUTO_WATCH "auto_watch"
#define CBM_CONFIG_IGNORE_WORKTREES "ignore_worktrees"
#define CBM_CONFIG_DOC_CANDIDATES "doc_candidates"
#define CBM_CONFIG_INSTALL_HOOKS "install_hooks"
#define CBM_CONFIG_UI_LANG "ui-lang"
#define CBM_CONFIG_WATCHER_ENABLED "watcher_enabled"
#define CBM_CONFIG_WATCH_NON_GIT "watch_non_git"
+1 -1
View File
@@ -1142,7 +1142,7 @@ static void print_help(void) {
"[args]\n");
printf(" Run one tool locally, then exit\n");
printf(" codebase-memory-mcp install [-y|-n] [--force] [--dry-run] "
"[--dir=<path>] [--skip-config]\n");
"[--dir=<path>] [--skip-config] [--hooks|--no-hooks]\n");
printf(" [--clients=<tokens>] Run "
"'install --clients' to list tokens\n");
printf(" codebase-memory-mcp uninstall [-y|-n] [--dry-run] [--delete-indexes]\n");
+197 -4
View File
@@ -17617,6 +17617,28 @@ static mcp_scan_cause_t mcp_run_shell_command_cancellable_bounded(
uint64_t deadline_ms, bool deadline_enabled, bool deadline_latched, bool exit_one_is_no_match,
bool git_child, cbm_proc_result_t *result_out);
#ifndef _WIN32
/* POSIX-only: supervised run of one argv stage (no shell). Same latch and
* containment contract as the shell runner; the measured file's size plus
* output_base counts toward output_limit so chained stages share one budget.
* tolerate_exit_one maps a plain exit-1 (grep's no-match) to SUCCESS. */
static mcp_scan_cause_t mcp_run_argv_stage_cancellable_bounded(
cbm_mcp_server_t *srv, const char *bin, const char *const *argv,
const char *const *env_overrides, const char *stdout_capture, uint64_t output_base,
size_t output_limit, uint64_t deadline_ms, bool deadline_enabled, bool tolerate_exit_one,
bool *cancellation_latched, bool *deadline_latched, bool *output_limit_latched,
cbm_proc_result_t *result_out);
/* POSIX-only: the unscoped search_code scan as find/sort argv stages feeding the
* in-process scanner. */
static mcp_scan_cause_t mcp_run_search_scan_argv(cbm_mcp_server_t *srv, bool use_regex,
const char *file_pattern, const char *pattern,
const char *filelist, const char *root_path,
char output_path[CBM_SZ_2K], size_t output_limit,
uint64_t deadline_ms, bool deadline_latched,
cbm_proc_result_t *result_out);
#endif
static char *search_code_timeout_result(void) {
static const char message[] = "search_code scan exceeded its execution deadline";
static const char fallback[] =
@@ -18016,10 +18038,22 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) {
scan_cause = MCP_SCAN_COMMAND_FAILURE;
}
} else {
scan_cause = mcp_run_shell_command_cancellable_bounded(
srv, scan_command, output_path, scan_output_limit, scan_deadline_ms, true,
scan_deadline_latched, /*exit_one_is_no_match=*/false, /*git_child=*/false,
&scan_result);
#ifndef _WIN32
if (!srv->search_scan_command_override) {
/* POSIX unscoped scans run as find/sort/grep argv stages: no
* shell parses search scopes or file patterns anymore (#2634).
* The command override keeps the shell runner for tests. */
scan_cause = mcp_run_search_scan_argv(
srv, use_regex, file_pattern, pattern, filelist, root_path, output_path,
scan_output_limit, scan_deadline_ms, scan_deadline_latched, &scan_result);
} else
#endif
{
scan_cause = mcp_run_shell_command_cancellable_bounded(
srv, scan_command, output_path, scan_output_limit, scan_deadline_ms, true,
scan_deadline_latched, /*exit_one_is_no_match=*/false, /*git_child=*/false,
&scan_result);
}
}
/* Both POSIX commands wrap grep and map its no-match status to 0, so any
* non-zero exit (a failed find/sort, an unreadable operand, a broken
@@ -18539,6 +18573,165 @@ static int mcp_run_shell_command_cancellable(cbm_mcp_server_t *srv, const char *
return cause == MCP_SCAN_COMMAND_FAILURE || cause == MCP_SCAN_SUPERVISION_FAILURE ? -1 : 0;
}
#ifndef _WIN32
/* Latch poll shared by argv stages: request-cancellation, wall-clock deadline,
* and the cumulative measured-file output budget. Mirrors the latch updates of
* the shell runner so both paths fail the same way. */
static void mcp_argv_stage_poll_latches(cbm_mcp_server_t *srv, const char *measured_path,
uint64_t output_base, size_t output_limit,
uint64_t deadline_ms, bool deadline_enabled,
bool *cancellation_latched, bool *deadline_latched,
bool *output_limit_latched) {
*cancellation_latched = *cancellation_latched || mcp_request_cancelled(srv);
*deadline_latched = *deadline_latched || (deadline_enabled && cbm_now_ms() >= deadline_ms);
if (!*output_limit_latched && output_limit > 0 && measured_path && measured_path[0]) {
int64_t measured_size = cbm_file_size(measured_path);
*output_limit_latched =
measured_size > 0 && output_base + (uint64_t)measured_size > output_limit;
}
}
static mcp_scan_cause_t mcp_run_argv_stage_cancellable_bounded(
cbm_mcp_server_t *srv, const char *bin, const char *const *argv,
const char *const *env_overrides, const char *stdout_capture, uint64_t output_base,
size_t output_limit, uint64_t deadline_ms, bool deadline_enabled, bool tolerate_exit_one,
bool *cancellation_latched, bool *deadline_latched, bool *output_limit_latched,
cbm_proc_result_t *result_out) {
memset(result_out, 0, sizeof(*result_out));
mcp_argv_stage_poll_latches(srv, stdout_capture, output_base, output_limit, deadline_ms,
deadline_enabled, cancellation_latched, deadline_latched,
output_limit_latched);
if (*cancellation_latched || *deadline_latched || *output_limit_latched) {
result_out->tree_quiesced = true; /* no child was spawned */
if (*cancellation_latched) {
return MCP_SCAN_CANCELLED;
}
return *deadline_latched ? MCP_SCAN_DEADLINE : MCP_SCAN_OUTPUT_LIMIT;
}
cbm_proc_opts_t options = {
.bin = bin,
.argv = argv,
.env_overrides = env_overrides,
.log_file = NULL,
.quiet_timeout_ms = 0,
.cancel_grace_ms = CBM_SUBPROCESS_DEFAULT_CANCEL_GRACE_MS,
.delete_log_on_exit = false,
.strip_git_repo_env = false,
.stdout_file = stdout_capture,
};
cbm_subprocess_t *process = NULL;
if (cbm_subprocess_spawn(&options, &process) != 0) {
result_out->tree_quiesced = true;
return MCP_SCAN_COMMAND_FAILURE;
}
cbm_proc_poll_t state;
for (;;) {
mcp_argv_stage_poll_latches(srv, stdout_capture, output_base, output_limit, deadline_ms,
deadline_enabled, cancellation_latched, deadline_latched,
output_limit_latched);
if (*cancellation_latched || *deadline_latched || *output_limit_latched) {
(void)cbm_subprocess_request_cancel(process);
}
state = cbm_subprocess_poll(process, result_out);
if (state != CBM_PROC_POLL_RUNNING) {
break;
}
cbm_usleep(10000);
}
bool contained = state == CBM_PROC_POLL_TERMINAL && result_out->tree_quiesced &&
!result_out->supervision_failed;
cbm_subprocess_destroy(process);
mcp_argv_stage_poll_latches(srv, stdout_capture, output_base, output_limit, deadline_ms,
deadline_enabled, cancellation_latched, deadline_latched,
output_limit_latched);
if (!contained) {
return MCP_SCAN_SUPERVISION_FAILURE;
}
if (*cancellation_latched) {
return MCP_SCAN_CANCELLED;
}
if (*deadline_latched) {
return MCP_SCAN_DEADLINE;
}
if (*output_limit_latched) {
return MCP_SCAN_OUTPUT_LIMIT;
}
if (tolerate_exit_one && result_out->outcome == CBM_PROC_EXIT_NONZERO &&
result_out->exit_code == 1) {
return MCP_SCAN_SUCCESS; /* ordinary grep no-match */
}
return result_out->outcome == CBM_PROC_CLEAN ? MCP_SCAN_SUCCESS
: MCP_SCAN_CONTAINED_COMMAND_FAILURE;
}
/* The unscoped (unindexed or not-fully-indexed root) search_code scan with no
* shell anywhere: find writes the NUL-delimited candidate list and LC_ALL=C
* sort orders it in place, both as argv stages, then the list feeds the
* in-process scanner (search_scan_in_process). Search scopes and file patterns
* reach find as plain arguments instead of shell-string interpolations (#2634).
* find/sort failures fail closed, and an empty list is a successful empty
* scan. */
static mcp_scan_cause_t mcp_run_search_scan_argv(cbm_mcp_server_t *srv, bool use_regex,
const char *file_pattern, const char *pattern,
const char *filelist, const char *root_path,
char output_path[CBM_SZ_2K], size_t output_limit,
uint64_t deadline_ms, bool deadline_latched,
cbm_proc_result_t *result_out) {
bool cancellation_latched = false;
bool output_limit_latched = false;
const char *find_argv[9];
int fa = 0;
find_argv[fa++] = "find";
find_argv[fa++] = root_path;
find_argv[fa++] = "-type";
find_argv[fa++] = "f";
if (file_pattern) {
find_argv[fa++] = "-name";
find_argv[fa++] = file_pattern;
}
find_argv[fa++] = "-print0";
find_argv[fa] = NULL;
mcp_scan_cause_t cause = mcp_run_argv_stage_cancellable_bounded(
srv, "find", find_argv, NULL, filelist, 0, 0, deadline_ms, true, false,
&cancellation_latched, &deadline_latched, &output_limit_latched, result_out);
if (cause != MCP_SCAN_SUCCESS) {
return cause;
}
const char *sort_argv[] = {"sort", "-z", "-o", filelist, filelist, NULL};
const char *sort_env[] = {"LC_ALL=C", NULL};
cause = mcp_run_argv_stage_cancellable_bounded(
srv, "sort", sort_argv, sort_env, NULL, 0, 0, deadline_ms, true, false,
&cancellation_latched, &deadline_latched, &output_limit_latched, result_out);
if (cause != MCP_SCAN_SUCCESS) {
return cause;
}
if (!mcp_command_output_path(output_path)) {
return MCP_SCAN_COMMAND_FAILURE;
}
FILE *out = cbm_fopen(output_path, "wb");
if (!out) {
(void)cbm_unlink(output_path);
output_path[0] = '\0';
return MCP_SCAN_COMMAND_FAILURE;
}
/* The sorted, NUL-separated file list feeds the same in-process scanner an
* indexed project uses, so matching has one implementation. */
mcp_scan_cause_t scan_cause =
search_scan_in_process(srv, filelist, pattern, use_regex, out, output_limit, deadline_ms);
if (fclose(out) != 0 && scan_cause == MCP_SCAN_SUCCESS) {
scan_cause = MCP_SCAN_COMMAND_FAILURE;
}
if (scan_cause != MCP_SCAN_SUCCESS) {
(void)cbm_unlink(output_path);
output_path[0] = '\0';
}
return scan_cause;
}
#endif
/* Does `node`'s line range overlap any recorded hunk for `file`? Used to scope
* seed detection to the actually-changed lines rather than the whole file.
* Non-static (declared in mcp_internal.h) so tests can exercise the overlap
+12
View File
@@ -636,6 +636,18 @@ static inline bool cbm_pipeline_kotlin_external_target(CBMLanguage lang, const c
strncmp(callee_qn, "javax.", strlen("javax.")) == 0;
}
/* Java: the LSP typed the receiver as a type OUTSIDE the project (a JDK or
* library class, `lsp_external`), so the callee is `java.util.Optional.map`
* and no project method. Without this gate the tail/unique-leaf node policy
* and then the textual registry bound such a call to whatever project method
* shares the leaf (`Optional.map` -> `Filters.map`). Such a call gets no
* CALLS edge, as #2053 does for Rust. Pure; unit-tested. */
static inline bool cbm_pipeline_java_external_target(CBMLanguage lang, const CBMResolvedCall *lsp) {
return lang == CBM_LANG_JAVA && lsp && lsp->strategy &&
strcmp(lsp->strategy, "lsp_external") == 0 && lsp->callee_qn &&
strchr(lsp->callee_qn, '.') != NULL;
}
/* Rust (#2053): true when the Rust LSP positively resolved a call to a
* registered symbol that lives OUTSIDE the project — a std/core/alloc seed
* method (`root.join()` on `root: &Path` -> std.path.Path.join) or a seeded
+25 -16
View File
@@ -1009,21 +1009,25 @@ static const cbm_gbuf_node_t *calls_field_target(cbm_pipeline_ctx_t *ctx, const
}
static bool calls_emit_lsp_target(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const CBMResolvedCall *lsp, bool allow_tail, CBMLanguage lang,
const CBMImportArray *imports, const cbm_gbuf_node_t *source_node,
const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count, const char *route_mount,
CBMCallEvidence *evidence) {
const CBMResolvedCall *lsp, bool allow_tail, bool java_external,
CBMLanguage lang, const CBMImportArray *imports,
const cbm_gbuf_node_t *source_node, const char *module_qn,
const char **imp_keys, const char **imp_vals, int imp_count,
const char *route_mount, CBMCallEvidence *evidence) {
if (!lsp) {
return false;
}
bool exact_external_target =
call->requires_lsp_resolution && cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn);
/* A Java call on a JDK/library type binds its exact QN only: no tail
* match onto a same-named project method. MUST match pass_parallel.c. */
const cbm_gbuf_node_t *target_node =
exact_external_target ? cbm_pipeline_lsp_target_node_strict(ctx->gbuf, ctx->project_name,
lsp->callee_qn, allow_tail)
: cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name,
lsp->callee_qn, allow_tail);
java_external ? cbm_pipeline_lsp_target_node_strict(ctx->gbuf, ctx->project_name,
lsp->callee_qn, false)
: exact_external_target ? cbm_pipeline_lsp_target_node_strict(ctx->gbuf, ctx->project_name,
lsp->callee_qn, allow_tail)
: cbm_pipeline_lsp_target_node(ctx->gbuf, ctx->project_name,
lsp->callee_qn, allow_tail);
if (!target_node || source_node->id == target_node->id) {
return false;
}
@@ -1051,15 +1055,16 @@ static bool calls_emit_lsp_target(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
return true;
}
/* Vendor receivers and cross-language name guesses cannot bind project calls.
* Keep the registry policy shared with the parallel resolver. */
/* Vendor receivers, Java library receivers and cross-language name guesses
* cannot bind project calls. Keep the registry policy shared with the
* parallel resolver. */
static cbm_resolution_t calls_registry_resolution(cbm_pipeline_ctx_t *ctx, const CBMCall *call,
const char *module_qn, const char **imp_keys,
const char **imp_vals, int imp_count,
CBMLanguage lang, bool rust_external,
bool php_vendor_call) {
bool php_vendor_call, bool java_external) {
cbm_resolution_t res = {0};
if (!rust_external && !php_vendor_call) {
if (!rust_external && !php_vendor_call && !java_external) {
/* Python LEGB fallback first (MUST match pass_parallel.c). */
res =
cbm_registry_resolve_call_scoped(ctx->registry, call->callee_name, module_qn, imp_keys,
@@ -1127,8 +1132,12 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
* Service/route classification below still runs. Mirrors pass_parallel.c. */
bool php_vendor_call = cbm_pipeline_php_vendor_call(php_vendor, result, call) ||
cbm_pipeline_lsp_external_receiver(lsp);
if (calls_emit_lsp_target(ctx, call, lsp, allow_tail, lang, imports, source_node, module_qn,
imp_keys, imp_vals, imp_count, route_mount, evidence)) {
/* A Java call on a JDK/library type is never bound to a project method
* found by name (no tail match, no registry guess). MUST match
* pass_parallel.c. */
bool java_external = cbm_pipeline_java_external_target(lang, lsp);
if (calls_emit_lsp_target(ctx, call, lsp, allow_tail, java_external, lang, imports, source_node,
module_qn, imp_keys, imp_vals, imp_count, route_mount, evidence)) {
return SKIP_ONE;
}
@@ -1227,7 +1236,7 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
cbm_resolution_t res = {0};
if (swift_candidates >= 0) {
res = calls_registry_resolution(ctx, call, module_qn, imp_keys, imp_vals, imp_count, lang,
rust_external, php_vendor_call);
rust_external, php_vendor_call, java_external);
}
/* `p->open(fd)`: the registry does not split a callee on the arrow, and a
* member is not a name it could resolve anyway. The object's type decides,
+1
View File
@@ -2597,6 +2597,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *
cross_registries.c = cbm_c_build_cross_registry(xa, all_defs, def_count);
cross_registries.cs = cbm_cs_build_cross_registry(xa, all_defs, def_count);
cross_registries.ts = cbm_ts_build_cross_registry(xa, all_defs, def_count);
cross_registries.java = cbm_java_build_cross_registry(xa, all_defs, def_count);
}
int processed = 0;
+7 -2
View File
@@ -3400,13 +3400,18 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
memory_order_relaxed);
_rc_t0 = extract_now_ns();
const cbm_gbuf_node_t *lsp_target = NULL;
/* Mirrors pass_calls.c: a Java call on a JDK/library type is never
* bound to a project method found by name. */
bool java_external = cbm_pipeline_java_external_target(lang, lsp);
if (lsp) {
/* Canonicalise to the gbuf node's QN so res.qualified_name matches
* the gbuf even when the cross-file fallback had to prefix the
* project name. */
bool exact_external_target = call->requires_lsp_resolution &&
cbm_pipeline_kotlin_external_target(lang, lsp->callee_qn);
lsp_target = exact_external_target
lsp_target = java_external ? cbm_pipeline_lsp_target_node_strict(
rc->main_gbuf, rc->project_name, lsp->callee_qn, false)
: exact_external_target
? cbm_pipeline_lsp_target_node_strict(rc->main_gbuf, rc->project_name,
lsp->callee_qn, allow_tail)
: cbm_pipeline_lsp_target_node(rc->main_gbuf, rc->project_name,
@@ -3451,7 +3456,7 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
candidates, CBM_SZ_256);
}
if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution &&
!rust_external && !php_vendor_call && count == 0) {
!rust_external && !php_vendor_call && !java_external && count == 0) {
/* Python LEGB fallback first (MUST match pass_calls.c). */
res = cbm_registry_resolve_call_scoped(rc->registry, call->callee_name, module_qn,
imp_keys, imp_vals, imp_count, lang,
+38 -13
View File
@@ -380,10 +380,37 @@ static int semantic_manifest_walk_controls(semantic_manifest_builder_t *builder,
const char *rel_dir, int depth, char **excluded_dirs,
int excluded_count);
/* Control files are the only regular files the walk records. */
static bool semantic_manifest_control_name(const char *rel_dir, const char *name) {
bool root_control = (!rel_dir || !rel_dir[0]) && (strcmp(name, ".cbmignore") == 0 ||
strcmp(name, ".codebase-memory.json") == 0);
/* A build context's ignore file (or BuildKit's <Dockerfile>.dockerignore)
* shapes which files an image holds (pass_docker.c image_ignore), and
* discovery indexes neither. */
static const char DOCKERIGNORE[] = ".dockerignore";
size_t nl = strlen(name);
bool dockerignore = nl >= sizeof(DOCKERIGNORE) - SKIP_ONE &&
strcmp(name + nl - (sizeof(DOCKERIGNORE) - SKIP_ONE), DOCKERIGNORE) == 0;
return strcmp(name, ".gitignore") == 0 || root_control ||
semantic_manifest_package_control(name) || dockerignore;
}
/* True when the directory listing alone shows that the entry is not a
* directory. A Windows listing always carries the attributes; on POSIX a
* d_type of DT_UNKNOWN (0), which readdir could not resolve, proves nothing. */
static bool semantic_manifest_listed_non_directory(const cbm_dirent_t *entry) {
#ifdef _WIN32
return !entry->is_dir;
#else
return entry->d_type != 0 && !entry->is_dir;
#endif
}
static int semantic_manifest_control_entry(semantic_manifest_builder_t *builder,
const char *project, const char *abs_dir,
const char *rel_dir, int depth, char **excluded_dirs,
int excluded_count, const char *name) {
int excluded_count, const cbm_dirent_t *entry) {
const char *name = entry->name;
if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0)
return 0;
char abs_path[CBM_SZ_4K], rel_path[CBM_SZ_4K];
@@ -393,6 +420,15 @@ static int semantic_manifest_control_entry(semantic_manifest_builder_t *builder,
if (abs_n < 0 || (size_t)abs_n >= sizeof(abs_path) || rel_n < 0 ||
(size_t)rel_n >= sizeof(rel_path))
return CBM_NOT_FOUND;
/* Only directories and control files contribute, so an entry the listing
* already shows to be neither is skipped without reading its metadata.
* That read can fail on such an entry: on Windows the plain path of a file
* named nul names the NUL device (#2646), and the plain path of a name
* ending in a dot or a space does not reach the file. */
bool control = semantic_manifest_control_name(rel_dir, name);
if (!control && semantic_manifest_listed_non_directory(entry)) {
return 0;
}
cbm_path_info_t info;
if (cbm_path_info_utf8(abs_path, &info) != 0)
return CBM_NOT_FOUND;
@@ -405,17 +441,6 @@ static int semantic_manifest_control_entry(semantic_manifest_builder_t *builder,
return semantic_manifest_walk_controls(builder, project, abs_path, rel_path, depth + 1,
excluded_dirs, excluded_count);
}
bool root_control = (!rel_dir || !rel_dir[0]) && (strcmp(name, ".cbmignore") == 0 ||
strcmp(name, ".codebase-memory.json") == 0);
/* A build context's ignore file (or BuildKit's <Dockerfile>.dockerignore)
* shapes which files an image holds (pass_docker.c image_ignore), and
* discovery indexes neither. */
static const char DOCKERIGNORE[] = ".dockerignore";
size_t nl = strlen(name);
bool dockerignore = nl >= sizeof(DOCKERIGNORE) - SKIP_ONE &&
strcmp(name + nl - (sizeof(DOCKERIGNORE) - SKIP_ONE), DOCKERIGNORE) == 0;
bool control = strcmp(name, ".gitignore") == 0 || root_control ||
semantic_manifest_package_control(name) || dockerignore;
return info.is_regular && control ? semantic_manifest_add(builder, project, rel_path, abs_path)
: 0;
}
@@ -440,7 +465,7 @@ static int semantic_manifest_walk_controls(semantic_manifest_builder_t *builder,
rc = cbm_pipeline_frozen_checkpoint(builder->frozen);
if (rc == 0)
rc = semantic_manifest_control_entry(builder, project, abs_dir, rel_dir, depth,
excluded_dirs, excluded_count, entry->name);
excluded_dirs, excluded_count, entry);
}
cbm_closedir(dir);
return rc;
+64 -4
View File
@@ -154,6 +154,15 @@ struct cbm_watcher {
* daemon log names the stuck project instead of only repeating the warning. */
#define INDEX_FAIL_SUSTAINED 10
/* Success cooldown (#2835): after a successful re-index the next poll waits
* INDEX_COOLDOWN_FACTOR x the index's own duration, counted from when the
* index finished, so short rebuilds spend at most ~1/(K+1) of wall time
* indexing one project. Sub-second indexes stay at the plain cadence. The
* cooldown is capped at INDEX_FAIL_CEILING_MS (5 min) so that, under
* continuous writes, a project never falls more than one rebuild plus five
* minutes behind. */
#define INDEX_COOLDOWN_FACTOR 3
/* Stale-root pruning (#286): a watched project whose root directory stays
* missing is pruned — its cached DB is deleted and the watch entry removed.
* Deletion is destructive (the DB can hold user-authored data such as the
@@ -175,7 +184,17 @@ struct cbm_watcher {
/* ── Time helper ────────────────────────────────────────────────── */
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
/* Test seam: nonzero freezes the watcher's scheduling clock at this value. */
static int64_t test_clock_ns = 0;
#endif
static int64_t now_ns(void) {
#if defined(CBM_ENABLE_TEST_SEAMS) && CBM_ENABLE_TEST_SEAMS
if (test_clock_ns != 0) {
return test_clock_ns;
}
#endif
struct timespec ts;
cbm_clock_gettime(CLOCK_MONOTONIC, &ts);
return ((int64_t)ts.tv_sec * NS_PER_SEC) + ts.tv_nsec;
@@ -204,6 +223,20 @@ int cbm_watcher_index_backoff_ms(int interval_ms, int consecutive_failures) {
return (int)(delay < interval_ms ? interval_ms : delay);
}
int cbm_watcher_success_delay_ms(int interval_ms, int64_t index_elapsed_ms) {
if (interval_ms < 0) {
interval_ms = 0;
}
if (index_elapsed_ms <= 0) {
return interval_ms;
}
/* Divide instead of multiply so a huge elapsed cannot overflow. */
int64_t cooldown = index_elapsed_ms > INDEX_FAIL_CEILING_MS / INDEX_COOLDOWN_FACTOR
? INDEX_FAIL_CEILING_MS
: index_elapsed_ms * INDEX_COOLDOWN_FACTOR;
return (int)(cooldown > interval_ms ? cooldown : interval_ms);
}
int cbm_watcher_poll_interval_ms(int file_count) {
int ms = POLL_BASE_MS + ((file_count / POLL_FILE_STEP) * CBM_MSEC_PER_SEC);
if (ms > POLL_MAX_MS) {
@@ -1693,12 +1726,22 @@ static void poll_project(const char *key, void *val, void *ud) {
}
cbm_log_info("watcher.changed", "project", s->project_name, "strategy",
s->is_git ? "git" : "tree");
int delay_ms = -1; /* < 0: use the failure-backoff cadence below */
int64_t anchor_ns = ctx->now; /* success re-anchors to when the index finished */
if (ctx->w->index_fn) {
int64_t index_start = now_ns();
int rc = ctx->w->index_fn(s->project_name, s->root_path, ctx->w->user_data);
if (rc == 0) {
ctx->reindexed++;
s->index_failures = 0;
commit_baselines(ctx->w, s);
anchor_ns = now_ns();
delay_ms =
cbm_watcher_success_delay_ms(s->interval_ms, (anchor_ns - index_start) / US_PER_MS);
if (delay_ms > s->interval_ms) {
cbm_log_info("watcher.cooldown", "project", s->project_name, "delay_ms",
itoa_buf(delay_ms));
}
} else if (rc > 0) {
/* Busy-skip: baseline stays uncommitted, next poll retries. */
cbm_log_info("watcher.index.retry", "project", s->project_name);
@@ -1721,10 +1764,12 @@ static void poll_project(const char *key, void *val, void *ud) {
}
}
/* Failures back off; success and busy-skip keep the adaptive cadence. */
s->next_poll_ns =
ctx->now +
((int64_t)cbm_watcher_index_backoff_ms(s->interval_ms, s->index_failures) * US_PER_MS);
/* Success pays back the index's cost; failures back off; busy-skip keeps
* the adaptive cadence. */
if (delay_ms < 0) {
delay_ms = cbm_watcher_index_backoff_ms(s->interval_ms, s->index_failures);
}
s->next_poll_ns = anchor_ns + ((int64_t)delay_ms * US_PER_MS);
}
/* Callback to snapshot project state pointers into an array. */
@@ -1876,4 +1921,19 @@ int cbm_watcher_test_pending_free_count(cbm_watcher_t *w) {
cbm_mutex_unlock(&w->projects_lock);
return count;
}
void cbm_watcher_test_set_clock_ns(int64_t ns) {
test_clock_ns = ns;
}
int64_t cbm_watcher_test_next_poll_ns(cbm_watcher_t *w, const char *project) {
if (!w || !project) {
return -1;
}
cbm_mutex_lock(&w->projects_lock);
project_state_t *s = cbm_ht_get(w->projects, project);
int64_t next = s ? s->next_poll_ns : -1;
cbm_mutex_unlock(&w->projects_lock);
return next;
}
#endif
+18
View File
@@ -145,6 +145,15 @@ int cbm_watcher_index_failure_count(cbm_watcher_t *w, const char *project_name);
* otherwise unobservable — cbm_watcher_free frees the same list, so a poll
* that stopped draining would neither crash nor leak. Test builds only. */
int cbm_watcher_test_pending_free_count(cbm_watcher_t *w);
/* Test seam: freeze the watcher's monotonic scheduling clock at `ns`
* (nonzero), or restore the real clock (0). Process-global; single-threaded
* tests only. Lets a test advance time inside index_fn without sleeping. */
void cbm_watcher_test_set_clock_ns(int64_t ns);
/* Test seam: a project's scheduled next poll (monotonic ns), read under
* projects_lock; -1 when it is not watched. */
int64_t cbm_watcher_test_next_poll_ns(cbm_watcher_t *w, const char *project);
#endif
/* Per-project watch status, for index_status (#2167). */
@@ -195,6 +204,15 @@ int cbm_watcher_poll_interval_ms(int file_count);
* Negative inputs are treated as zero. */
int cbm_watcher_index_backoff_ms(int interval_ms, int consecutive_failures);
/* Return the delay (ms) before the next poll after a SUCCESSFUL re-index that
* took `index_elapsed_ms`: max(interval_ms, INDEX_COOLDOWN_FACTOR (3) x
* index_elapsed_ms), with the cooldown term capped at INDEX_FAIL_CEILING_MS
* (5 min). The caller counts it from when the index finished, so short
* rebuilds spend at most ~1/4 of wall time indexing a project; sub-second
* indexes keep `interval_ms`.
* Never below `interval_ms`; overflow-safe; negative inputs act as zero. */
int cbm_watcher_success_delay_ms(int interval_ms, int64_t index_elapsed_ms);
/* Classify a stat() errno observed on a watched project root: returns true
* only for values that mean the root itself is gone (ENOENT, ENOTDIR) and
* may count toward stale-root pruning (#286). Any other failure (EACCES,
+552
View File
@@ -2483,6 +2483,540 @@ TEST(cli_activation_commands_reject_malformed_and_unknown_flags) {
PASS();
}
TEST(cli_install_hooks_preference_persists_and_migrates_owned_state) {
char tmpdir[512];
snprintf(tmpdir, sizeof(tmpdir), "%s/cli-install-hooks-XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "CLAUDE_CONFIG_DIR", "PATH"};
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
saved_env[i] = save_test_env(env_names[i]);
cbm_unsetenv(env_names[i]);
}
char cache_dir[640];
char claude_dir[640];
char settings_path[768];
char mcp_path[768];
char db_path[768];
char gate_path[768];
char session_path[768];
char subagent_path[768];
snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir);
snprintf(claude_dir, sizeof(claude_dir), "%s/.claude", tmpdir);
snprintf(settings_path, sizeof(settings_path), "%s/settings.json", claude_dir);
snprintf(mcp_path, sizeof(mcp_path), "%s/.claude.json", tmpdir);
snprintf(db_path, sizeof(db_path), "%s/_config.db", cache_dir);
#ifdef _WIN32
snprintf(gate_path, sizeof(gate_path), "%s/hooks/cbm-code-discovery-gate.cmd", claude_dir);
snprintf(session_path, sizeof(session_path), "%s/hooks/cbm-session-reminder.cmd", claude_dir);
snprintf(subagent_path, sizeof(subagent_path), "%s/hooks/cbm-subagent-reminder.cmd", claude_dir);
#else
snprintf(gate_path, sizeof(gate_path), "%s/hooks/cbm-code-discovery-gate", claude_dir);
snprintf(session_path, sizeof(session_path), "%s/hooks/cbm-session-reminder", claude_dir);
snprintf(subagent_path, sizeof(subagent_path), "%s/hooks/cbm-subagent-reminder", claude_dir);
#endif
test_mkdirp(claude_dir);
write_test_file(settings_path,
"{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Bash\",\"hooks\":[{\"type\":"
"\"command\",\"command\":\"/usr/bin/foreign-hook\"}]}]}}\n");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("CBM_CACHE_DIR", cache_dir, 1);
cbm_setenv("PATH", tmpdir, 1);
cli_activation_fake_t fake = {.mutation_reserve_result = 1};
cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake);
cbm_cli_set_activation_ops_for_test(&ops);
char *conflict_a[] = {"--hooks", "--no-hooks"};
char *conflict_b[] = {"--no-hooks", "--hooks"};
int conflict_a_rc = cli_test_cmd_install(2, conflict_a);
int conflict_b_rc = cli_test_cmd_install(2, conflict_b);
char *default_args[] = {"--skip-binary", "--clients=claude", "--yes"};
int default_rc = cli_test_cmd_install(3, default_args);
char *default_state = read_test_file_alloc(settings_path);
bool default_hook = default_state && strstr(default_state, "hook-augment") &&
strstr(default_state, "/usr/bin/foreign-hook");
bool default_scripts = cbm_file_exists(gate_path) && cbm_file_exists(session_path) &&
cbm_file_exists(subagent_path);
free(default_state);
char *no_hooks_plan_args[] = {"--plan", "--no-hooks"};
int no_hooks_plan_rc = cli_test_cmd_install(2, no_hooks_plan_args);
char *disabled_plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp");
yyjson_doc *disabled_doc =
disabled_plan ? yyjson_read(disabled_plan, strlen(disabled_plan), 0) : NULL;
yyjson_val *disabled_root = disabled_doc ? yyjson_doc_get_root(disabled_doc) : NULL;
yyjson_val *disabled_hooks =
disabled_root ? yyjson_obj_get(disabled_root, "hooks_planned") : NULL;
yyjson_val *disabled_configs =
disabled_root ? yyjson_obj_get(disabled_root, "config_files_planned") : NULL;
bool disabled_plan_ok = disabled_hooks && yyjson_is_arr(disabled_hooks) &&
yyjson_arr_size(disabled_hooks) == 0U && disabled_configs &&
yyjson_is_arr(disabled_configs) &&
yyjson_arr_size(disabled_configs) > 0U;
yyjson_doc_free(disabled_doc);
free(disabled_plan);
char *no_hooks_dry_args[] = {"--dry-run", "--no-hooks", "--skip-binary", "--clients=claude",
"--yes"};
int no_hooks_dry_rc = cli_test_cmd_install(5, no_hooks_dry_args);
struct stat db_status;
bool previews_read_only = stat(db_path, &db_status) != 0;
char *no_hooks_args[] = {"--no-hooks", "--skip-binary", "--clients=claude", "--yes"};
int no_hooks_rc = cli_test_cmd_install(4, no_hooks_args);
cbm_config_t *cfg = cbm_config_open(cache_dir);
bool stored_false = cfg && !cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, true);
cbm_config_close(cfg);
char *disabled_state = read_test_file_alloc(settings_path);
char *disabled_mcp = read_test_file_alloc(mcp_path);
bool disabled_ok = disabled_state && strstr(disabled_state, "/usr/bin/foreign-hook") &&
!strstr(disabled_state, "hook-augment") && disabled_mcp &&
strstr(disabled_mcp, "codebase-memory-mcp");
bool no_hooks_scripts_gone = !cbm_file_exists(gate_path) && !cbm_file_exists(session_path) &&
!cbm_file_exists(subagent_path);
free(disabled_state);
free(disabled_mcp);
char *bare_args[] = {"--skip-binary", "--clients=claude", "--yes"};
int bare_disabled_rc = cli_test_cmd_install(3, bare_args);
disabled_state = read_test_file_alloc(settings_path);
bool bare_disabled = disabled_state && strstr(disabled_state, "/usr/bin/foreign-hook") &&
!strstr(disabled_state, "hook-augment");
bool bare_scripts_gone =
!cbm_file_exists(gate_path) && !cbm_file_exists(session_path) &&
!cbm_file_exists(subagent_path);
free(disabled_state);
char *hooks_plan_args[] = {"--plan", "--hooks"};
char *hooks_dry_args[] = {"--dry-run", "--hooks", "--skip-binary", "--clients=claude", "--yes"};
int hooks_plan_rc = cli_test_cmd_install(2, hooks_plan_args);
int hooks_dry_rc = cli_test_cmd_install(5, hooks_dry_args);
cfg = cbm_config_open(cache_dir);
bool previews_retained_false = cfg && !cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, true);
cbm_config_close(cfg);
char *hooks_args[] = {"--hooks", "--skip-binary", "--clients=claude", "--yes"};
int hooks_rc = cli_test_cmd_install(4, hooks_args);
cfg = cbm_config_open(cache_dir);
bool stored_true = cfg && cbm_config_get_bool(cfg, CBM_CONFIG_INSTALL_HOOKS, false);
cbm_config_close(cfg);
char *enabled_state = read_test_file_alloc(settings_path);
bool enabled_ok = enabled_state && strstr(enabled_state, "hook-augment") &&
strstr(enabled_state, "/usr/bin/foreign-hook");
bool hooks_scripts_back = cbm_file_exists(gate_path) && cbm_file_exists(session_path) &&
cbm_file_exists(subagent_path);
free(enabled_state);
int bare_enabled_rc = cli_test_cmd_install(3, bare_args);
bool bare_scripts_enabled = cbm_file_exists(gate_path) && cbm_file_exists(session_path) &&
cbm_file_exists(subagent_path);
const char *foreign_session_script =
#ifdef _WIN32
"@echo off\r\nC:\\tmp\\user-owned-session-hook.cmd\r\n";
#else
"#!/bin/sh\n/usr/bin/user-owned-session-hook\n";
#endif
write_test_file(session_path, foreign_session_script);
int foreign_no_hooks_rc = cli_test_cmd_install(4, no_hooks_args);
char *preserved_session = read_test_file_alloc(session_path);
bool foreign_session_preserved =
foreign_no_hooks_rc == 0 && preserved_session &&
strcmp(preserved_session, foreign_session_script) == 0 && !cbm_file_exists(gate_path) &&
!cbm_file_exists(subagent_path);
free(preserved_session);
bool results_ok =
conflict_a_rc == 1 && conflict_b_rc == 1 && default_rc == 0 && default_hook &&
default_scripts && no_hooks_plan_rc == 0 && disabled_plan_ok && no_hooks_dry_rc == 0 &&
previews_read_only && no_hooks_rc == 0 && stored_false && disabled_ok &&
no_hooks_scripts_gone && bare_disabled_rc == 0 && bare_disabled && bare_scripts_gone &&
hooks_plan_rc == 0 && hooks_dry_rc == 0 && previews_retained_false && hooks_rc == 0 &&
stored_true && enabled_ok && hooks_scripts_back && bare_enabled_rc == 0 &&
bare_scripts_enabled && foreign_session_preserved && fake.mutation_reserve_count > 0;
cbm_cli_set_activation_ops_for_test(NULL);
cbm_set_auto_answer_for_test(0);
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
restore_test_env(env_names[i], saved_env[i]);
}
test_rmdir_r(tmpdir);
ASSERT_TRUE(results_ok);
PASS();
}
TEST(cli_no_hooks_plan_covers_every_hook_installer_family) {
char tmpdir[512];
snprintf(tmpdir, sizeof(tmpdir), "%s/cli-hook-families-XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "PATH", "XDG_CONFIG_HOME"};
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
saved_env[i] = save_test_env(env_names[i]);
cbm_unsetenv(env_names[i]);
}
const char *const dirs[] = {".claude", ".codex", ".gemini",
".config/opencode", ".augment", ".openclaw/workspace",
".hermes", ".qwen", ".copilot",
".factory", ".qoder", ".kimi-code",
".config/devin", ".gitlab/duo", "bin"};
char path[768];
for (size_t i = 0U; i < sizeof(dirs) / sizeof(dirs[0]); i++) {
snprintf(path, sizeof(path), "%s/%s", tmpdir, dirs[i]);
test_mkdirp(path);
}
snprintf(path, sizeof(path), "%s/.openclaw/openclaw.json", tmpdir);
write_test_file(path, "{}\n");
snprintf(path, sizeof(path), "%s/.copilot/mcp-config.json", tmpdir);
write_test_file(path, "{}\n");
/* #1180: a bare ~/.claude is not a Claude Code install; its settings.json is. */
snprintf(path, sizeof(path), "%s/.claude/settings.json", tmpdir);
write_test_file(path, "{}\n");
const char *const commands[] = {"qodercli", "kimi", "duo", "devin", "gemini",
"copilot", "auggie", "hermes", "droid"};
char bin_dir[640];
snprintf(bin_dir, sizeof(bin_dir), "%s/bin", tmpdir);
for (size_t i = 0U; i < sizeof(commands) / sizeof(commands[0]); i++) {
#ifdef _WIN32
snprintf(path, sizeof(path), "%s/%s.exe", bin_dir, commands[i]);
#else
snprintf(path, sizeof(path), "%s/%s", bin_dir, commands[i]);
#endif
write_test_file(path, "exit 0\n");
chmod(path, 0700);
}
char cache_dir[640];
snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir);
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("CBM_CACHE_DIR", cache_dir, 1);
cbm_setenv("PATH", bin_dir, 1);
char *no_hooks_args[] = {"--plan", "--no-hooks"};
int no_hooks_rc = cli_test_cmd_install(2, no_hooks_args);
char *plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp");
yyjson_doc *doc = plan ? yyjson_read(plan, strlen(plan), 0) : NULL;
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
yyjson_val *hooks = root ? yyjson_obj_get(root, "hooks_planned") : NULL;
yyjson_val *configs = root ? yyjson_obj_get(root, "config_files_planned") : NULL;
yyjson_val *instructions = root ? yyjson_obj_get(root, "instruction_files_planned") : NULL;
const char *const detected[] = {"claude-code", "codex", "gemini", "opencode",
"augment-auggie", "openclaw", "hermes", "qwen",
"copilot-cli", "factory-droid", "qoder", "kimi",
"gitlab-duo", "devin"};
bool families = no_hooks_rc == 0 && hooks && yyjson_is_arr(hooks) &&
yyjson_arr_size(hooks) == 0U && configs && yyjson_is_arr(configs) &&
yyjson_arr_size(configs) > 0U && instructions && yyjson_is_arr(instructions) &&
yyjson_arr_size(instructions) > 0U && plan && !strstr(plan, "cbm-augment.ts");
for (size_t i = 0U; families && i < sizeof(detected) / sizeof(detected[0]); i++) {
families = test_json_string_array_contains(root, "agents_detected", detected[i]);
}
yyjson_doc_free(doc);
free(plan);
char *hooks_args[] = {"--plan", "--hooks"};
int hooks_rc = cli_test_cmd_install(2, hooks_args);
char *enabled_plan = cbm_build_install_plan_json(tmpdir, "/opt/codebase-memory-mcp");
yyjson_doc *enabled_doc =
enabled_plan ? yyjson_read(enabled_plan, strlen(enabled_plan), 0) : NULL;
yyjson_val *enabled_root = enabled_doc ? yyjson_doc_get_root(enabled_doc) : NULL;
yyjson_val *enabled_hooks = enabled_root ? yyjson_obj_get(enabled_root, "hooks_planned") : NULL;
bool enabled_plan_ok =
enabled_hooks && yyjson_is_arr(enabled_hooks) && yyjson_arr_size(enabled_hooks) > 0U;
yyjson_doc_free(enabled_doc);
free(enabled_plan);
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
restore_test_env(env_names[i], saved_env[i]);
}
test_rmdir_r(tmpdir);
ASSERT_TRUE(families);
ASSERT_EQ(hooks_rc, 0);
ASSERT_TRUE(enabled_plan_ok);
PASS();
}
/* Counts the PreToolUse hooks in a Codex hooks.json: ours run hook-augment, the
* foreign one is matched by its exact command. */
static void test_codex_pretool_hook_counts(const char *path, const char *foreign_command,
size_t *owned, size_t *foreign) {
*owned = 0U;
*foreign = 0U;
char *text = read_test_file_alloc(path);
yyjson_doc *doc = text ? yyjson_read(text, strlen(text), 0) : NULL;
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
yyjson_val *hooks = root ? yyjson_obj_get(root, "hooks") : NULL;
yyjson_val *entries = hooks ? yyjson_obj_get(hooks, "PreToolUse") : NULL;
size_t entry_index;
size_t entry_count;
yyjson_val *entry;
if (entries && yyjson_is_arr(entries)) {
yyjson_arr_foreach(entries, entry_index, entry_count, entry) {
yyjson_val *entry_hooks = yyjson_obj_get(entry, "hooks");
size_t hook_index;
size_t hook_count;
yyjson_val *hook;
if (!entry_hooks || !yyjson_is_arr(entry_hooks)) {
continue;
}
yyjson_arr_foreach(entry_hooks, hook_index, hook_count, hook) {
const char *command = yyjson_get_str(yyjson_obj_get(hook, "command"));
if (command && strstr(command, "hook-augment")) {
(*owned)++;
} else if (command && strcmp(command, foreign_command) == 0) {
(*foreign)++;
}
}
}
}
yyjson_doc_free(doc);
free(text);
}
/* The Codex PreToolUse Bash hook (#2719) follows the persisted opt-out in both
* hook representations: --no-hooks removes ours from hooks.json and from the
* config.toml managed block, a bare install keeps it off, the user's own
* PreToolUse hook and the MCP entry stay, and --hooks restores ours. */
TEST(cli_no_hooks_removes_codex_pretool_hook) {
char tmpdir[512];
snprintf(tmpdir, sizeof(tmpdir), "%s/cli-codex-no-hooks-XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "CODEX_HOME", "PATH"};
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
saved_env[i] = save_test_env(env_names[i]);
cbm_unsetenv(env_names[i]);
}
char cache_dir[640];
char codex_dir[640];
char hooks_path[768];
char config_path[768];
snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir);
snprintf(codex_dir, sizeof(codex_dir), "%s/.codex", tmpdir);
snprintf(hooks_path, sizeof(hooks_path), "%s/hooks.json", codex_dir);
snprintf(config_path, sizeof(config_path), "%s/config.toml", codex_dir);
test_mkdirp(codex_dir);
/* An installed binary at the default target names the Codex hook command;
* without it --skip-binary would configure the test runner itself. */
char binary_dir[640];
char binary_path[768];
snprintf(binary_dir, sizeof(binary_dir), "%s/.local/bin", tmpdir);
test_mkdirp(binary_dir);
#ifdef _WIN32
snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp.exe", binary_dir);
#else
snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp", binary_dir);
#endif
write_test_file(binary_path, "installed binary\n");
const char *foreign_command = "/usr/bin/foreign-shell-audit";
write_test_file(hooks_path,
"{\"hooks\":{\"PreToolUse\":[{\"matcher\":\"Bash\",\"hooks\":[{\"type\":"
"\"command\",\"command\":\"/usr/bin/foreign-shell-audit\"}]}]}}\n");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("CBM_CACHE_DIR", cache_dir, 1);
cbm_setenv("PATH", tmpdir, 1);
cli_activation_fake_t fake = {.mutation_reserve_result = 1};
cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake);
cbm_cli_set_activation_ops_for_test(&ops);
size_t owned = 0U;
size_t foreign = 0U;
char *default_args[] = {"--skip-binary", "--clients=codex", "--yes"};
int default_rc = cli_test_cmd_install(3, default_args);
test_codex_pretool_hook_counts(hooks_path, foreign_command, &owned, &foreign);
bool default_json = default_rc == 0 && owned == 1U && foreign == 1U;
char *no_hooks_args[] = {"--no-hooks", "--skip-binary", "--clients=codex", "--yes"};
int no_hooks_rc = cli_test_cmd_install(4, no_hooks_args);
test_codex_pretool_hook_counts(hooks_path, foreign_command, &owned, &foreign);
char *json_state = read_test_file_alloc(hooks_path);
char *toml_state = read_test_file_alloc(config_path);
bool json_disabled = no_hooks_rc == 0 && owned == 0U && foreign == 1U && json_state &&
!strstr(json_state, "hook-augment");
bool json_mcp_kept = toml_state && strstr(toml_state, "[mcp_servers.codebase-memory-mcp]") &&
!strstr(toml_state, "hook-augment");
free(json_state);
free(toml_state);
int bare_rc = cli_test_cmd_install(3, default_args);
test_codex_pretool_hook_counts(hooks_path, foreign_command, &owned, &foreign);
bool bare_disabled = bare_rc == 0 && owned == 0U && foreign == 1U;
/* Without hooks.json the config.toml managed block carries the hooks. */
(void)remove(hooks_path);
char *hooks_args[] = {"--hooks", "--skip-binary", "--clients=codex", "--yes"};
int toml_enable_rc = cli_test_cmd_install(4, hooks_args);
toml_state = read_test_file_alloc(config_path);
bool toml_enabled = toml_enable_rc == 0 && toml_state &&
test_count_substring(toml_state, "[[hooks.PreToolUse]]") == 1U;
free(toml_state);
int toml_disable_rc = cli_test_cmd_install(4, no_hooks_args);
toml_state = read_test_file_alloc(config_path);
bool toml_disabled = toml_disable_rc == 0 && toml_state &&
!strstr(toml_state, "hooks.PreToolUse") &&
!strstr(toml_state, "hook-augment") &&
strstr(toml_state, "[mcp_servers.codebase-memory-mcp]");
free(toml_state);
int restore_rc = cli_test_cmd_install(4, hooks_args);
toml_state = read_test_file_alloc(config_path);
bool restored = restore_rc == 0 && toml_state &&
test_count_substring(toml_state, "[[hooks.PreToolUse]]") == 1U;
free(toml_state);
cbm_cli_set_activation_ops_for_test(NULL);
cbm_set_auto_answer_for_test(0);
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
restore_test_env(env_names[i], saved_env[i]);
}
test_rmdir_r(tmpdir);
ASSERT_TRUE(default_json);
ASSERT_TRUE(json_mcp_kept);
ASSERT_TRUE(toml_enabled);
ASSERT_TRUE(toml_disabled);
ASSERT_TRUE(restored);
ASSERT_TRUE(json_disabled);
ASSERT_TRUE(bare_disabled);
PASS();
}
/* The Augment SessionStart and PostToolUse view scripts follow the persisted
* opt-out the way uninstall treats them: --no-hooks deletes both exact-owned
* scripts and keeps the MCP entry, a bare install keeps them off, --hooks
* restores them, and a script the user edited is kept. */
TEST(cli_no_hooks_removes_augment_owned_scripts) {
char tmpdir[512];
snprintf(tmpdir, sizeof(tmpdir), "%s/cli-augment-no-hooks-XXXXXX", cbm_tmpdir());
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
const char *const env_names[] = {"HOME", "CBM_CACHE_DIR", "PATH"};
char *saved_env[sizeof(env_names) / sizeof(env_names[0])];
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
saved_env[i] = save_test_env(env_names[i]);
cbm_unsetenv(env_names[i]);
}
char cache_dir[640];
char augment_dir[640];
char settings_path[768];
char session_path[768];
char coverage_path[768];
snprintf(cache_dir, sizeof(cache_dir), "%s/cache", tmpdir);
snprintf(augment_dir, sizeof(augment_dir), "%s/.augment", tmpdir);
snprintf(settings_path, sizeof(settings_path), "%s/settings.json", augment_dir);
#ifdef _WIN32
snprintf(session_path, sizeof(session_path), "%s/hooks/codebase-memory-session.ps1",
augment_dir);
snprintf(coverage_path, sizeof(coverage_path), "%s/hooks/codebase-memory-coverage.ps1",
augment_dir);
#else
snprintf(session_path, sizeof(session_path), "%s/hooks/codebase-memory-session.sh",
augment_dir);
snprintf(coverage_path, sizeof(coverage_path), "%s/hooks/codebase-memory-coverage.sh",
augment_dir);
#endif
test_mkdirp(augment_dir);
/* An installed binary at the default target is the path the scripts embed;
* without it --skip-binary would configure the test runner itself. */
char binary_dir[640];
char binary_path[768];
snprintf(binary_dir, sizeof(binary_dir), "%s/.local/bin", tmpdir);
test_mkdirp(binary_dir);
#ifdef _WIN32
snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp.exe", binary_dir);
#else
snprintf(binary_path, sizeof(binary_path), "%s/codebase-memory-mcp", binary_dir);
#endif
write_test_file(binary_path, "installed binary\n");
cbm_setenv("HOME", tmpdir, 1);
cbm_setenv("CBM_CACHE_DIR", cache_dir, 1);
cbm_setenv("PATH", tmpdir, 1);
cli_activation_fake_t fake = {.mutation_reserve_result = 1};
cbm_cli_activation_ops_t ops = cli_activation_fake_ops(&fake);
cbm_cli_set_activation_ops_for_test(&ops);
char *default_args[] = {"--skip-binary", "--clients=augment", "--yes"};
int default_rc = cli_test_cmd_install(3, default_args);
bool default_scripts =
default_rc == 0 && cbm_file_exists(session_path) && cbm_file_exists(coverage_path);
char *no_hooks_args[] = {"--no-hooks", "--skip-binary", "--clients=augment", "--yes"};
int no_hooks_rc = cli_test_cmd_install(4, no_hooks_args);
bool scripts_removed =
no_hooks_rc == 0 && !cbm_file_exists(session_path) && !cbm_file_exists(coverage_path);
char *settings = read_test_file_alloc(settings_path);
bool mcp_kept = settings && strstr(settings, "mcpServers") &&
strstr(settings, "codebase-memory-mcp") &&
!strstr(settings, "codebase-memory-session") &&
!strstr(settings, "codebase-memory-coverage");
free(settings);
int bare_rc = cli_test_cmd_install(3, default_args);
bool bare_disabled =
bare_rc == 0 && !cbm_file_exists(session_path) && !cbm_file_exists(coverage_path);
char *hooks_args[] = {"--hooks", "--skip-binary", "--clients=augment", "--yes"};
int hooks_rc = cli_test_cmd_install(4, hooks_args);
settings = read_test_file_alloc(settings_path);
bool restored = hooks_rc == 0 && cbm_file_exists(session_path) &&
cbm_file_exists(coverage_path) && settings &&
strstr(settings, "codebase-memory-session") &&
strstr(settings, "codebase-memory-coverage");
free(settings);
/* A script the user edited is no longer the owned document: the opt-out
* keeps it, as uninstall does, and still deletes the untouched one. */
char *owned_session = read_test_file_alloc(session_path);
char user_session[4096];
snprintf(user_session, sizeof(user_session), "%s# local edit\n",
owned_session ? owned_session : "");
free(owned_session);
write_test_file(session_path, user_session);
int user_rc = cli_test_cmd_install(4, no_hooks_args);
char *kept_session = read_test_file_alloc(session_path);
bool user_script_kept = user_rc == 0 && kept_session &&
strcmp(kept_session, user_session) == 0 &&
!cbm_file_exists(coverage_path);
free(kept_session);
/* End on --hooks, as the Codex case does: the opt-out is process state,
* and later tests install without the command line. */
(void)remove(session_path);
int final_rc = cli_test_cmd_install(4, hooks_args);
bool final_restored =
final_rc == 0 && cbm_file_exists(session_path) && cbm_file_exists(coverage_path);
cbm_cli_set_activation_ops_for_test(NULL);
cbm_set_auto_answer_for_test(0);
for (size_t i = 0U; i < sizeof(env_names) / sizeof(env_names[0]); i++) {
restore_test_env(env_names[i], saved_env[i]);
}
test_rmdir_r(tmpdir);
ASSERT_TRUE(default_scripts);
ASSERT_TRUE(mcp_kept);
ASSERT_TRUE(restored);
ASSERT_TRUE(scripts_removed);
ASSERT_TRUE(bare_disabled);
ASSERT_TRUE(user_script_kept);
ASSERT_TRUE(final_restored);
PASS();
}
TEST(cli_install_reset_deletion_waits_for_final_activation_guard) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cli-daemon-install-reset-XXXXXX");
@@ -18590,18 +19124,32 @@ TEST(cli_config_cmd_get_prints_defaults_and_rejects_unknown_keys) {
char *get_limit[] = {"get", "auto_index_limit"};
ASSERT_EQ(cli_config_cmd_capture(2, get_limit, out, sizeof(out)), 0);
ASSERT_STR_EQ(out, "50000\n");
char *get_install_hooks[] = {"get", "install_hooks"};
ASSERT_EQ(cli_config_cmd_capture(2, get_install_hooks, out, sizeof(out)), 0);
ASSERT_STR_EQ(out, "true\n");
char *list[] = {"list"};
ASSERT_EQ(cli_config_cmd_capture(1, list, out, sizeof(out)), 0);
ASSERT_NOT_NULL(strstr(out, "install_hooks"));
/* Stored value round-trips. */
char *set_watch[] = {"set", "auto_watch", "false"};
ASSERT_EQ(cli_config_cmd_capture(3, set_watch, out, sizeof(out)), 0);
ASSERT_EQ(cli_config_cmd_capture(2, get_watch, out, sizeof(out)), 0);
ASSERT_STR_EQ(out, "false\n");
char *set_install_hooks[] = {"set", "install_hooks", "false"};
ASSERT_EQ(cli_config_cmd_capture(3, set_install_hooks, out, sizeof(out)), 0);
ASSERT_EQ(cli_config_cmd_capture(2, get_install_hooks, out, sizeof(out)), 0);
ASSERT_STR_EQ(out, "false\n");
/* Reset returns the key to its default. */
char *reset_watch[] = {"reset", "auto_watch"};
ASSERT_EQ(cli_config_cmd_capture(2, reset_watch, out, sizeof(out)), 0);
ASSERT_EQ(cli_config_cmd_capture(2, get_watch, out, sizeof(out)), 0);
ASSERT_STR_EQ(out, "true\n");
char *reset_install_hooks[] = {"reset", "install_hooks"};
ASSERT_EQ(cli_config_cmd_capture(2, reset_install_hooks, out, sizeof(out)), 0);
ASSERT_EQ(cli_config_cmd_capture(2, get_install_hooks, out, sizeof(out)), 0);
ASSERT_STR_EQ(out, "true\n");
/* Unknown keys are errors on every subcommand, with clean stdout. */
char *get_bogus[] = {"get", "totally_bogus_key"};
@@ -20494,6 +21042,10 @@ SUITE(cli) {
RUN_TEST(cli_install_force_quiesces_active_cohort_before_replacing_binary);
RUN_TEST(cli_install_dir_and_skip_config_stage_first_install_safely);
RUN_TEST(cli_activation_commands_reject_malformed_and_unknown_flags);
RUN_TEST(cli_install_hooks_preference_persists_and_migrates_owned_state);
RUN_TEST(cli_no_hooks_plan_covers_every_hook_installer_family);
RUN_TEST(cli_no_hooks_removes_codex_pretool_hook);
RUN_TEST(cli_no_hooks_removes_augment_owned_scripts);
RUN_TEST(cli_install_reset_deletion_waits_for_final_activation_guard);
RUN_TEST(cli_remove_indexes_deletes_orphan_sqlite_sidecars_issue2054);
RUN_TEST(cli_index_enumeration_skips_internal_cache_dbs);
+447
View File
@@ -0,0 +1,447 @@
/*
* test_java_typed_calls.c — Java CALLS edges follow the receiver's type
* through the whole pipeline (definitions → cross-file LSP → calls pass).
*
* One small project is indexed with cbm_pipeline_run and the CALLS edges of
* InvoiceService are read back from the store. Each project method carries a
* same-named decoy in an unrelated class (Filters), so a name-only guess is
* visible as an edge to Filters. Covered:
* - JDK and library receivers (Stream, List, org.jooq.DSLContext), whose
* calls get no edge instead of a project method of the same name;
* - a project receiver, whose call keeps its edge, also when its type is
* spelled with its package;
* - a receiver whose type is missing from the graph but whose package is
* the project's (a generated class that was not indexed, spelled with
* its package): unknown, not external, so its call keeps the name
* fallback;
* - a project type in the caller's own package that shares its simple
* name with a java.lang type (Process), or such a type declared in the
* caller's own file (Record): the own package wins;
* - the same rule on a project class named Enum (com.acme.protogen):
* `new Enum(..)` there binds that class node, not java.lang.Enum.
* The same fixture runs on the sequential path and, padded past
* MIN_FILES_FOR_PARALLEL, on the parallel one: both must agree on every edge
* found by type. A name guess is left out of that comparison: on Windows the
* parallel path can pick another same-named method for it.
*/
#include "../src/foundation/compat.h"
#include "test_framework.h"
#include "test_helpers.h"
#include "pipeline/pipeline.h"
#include "sqlite3.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* ── Fixture ─────────────────────────────────────────────────────── */
static const char *const JT_CUSTOMER = "package com.acme.model;\n\n"
"public class Customer {\n"
" public String getName() { return \"\"; }\n"
"}\n";
/* Every method here shares a name with a real target elsewhere. */
static const char *const JT_FILTERS = "package com.acme.other;\n\n"
"public class Filters {\n"
" public Filters filter(Object p) { return this; }\n"
" public Filters map(Object f) { return this; }\n"
" public String getName() { return \"\"; }\n"
" public Object select(Object a) { return a; }\n"
" public Object build() { return null; }\n"
" public String getCity() { return \"\"; }\n"
" public int size() { return 0; }\n"
" public int start() { return 0; }\n"
" public Filters number(String n) { return this; }\n"
" public void setPaid(boolean p) {}\n"
" public Object getCustomer() { return null; }\n"
" public String label() { return \"\"; }\n"
"}\n";
/* Process and Record share their simple names with java.lang types: the own
* package and the file's own types win (JLS 6.4.1), so these are the types
* InvoiceService and Process.start mean. */
static const char *const JT_PROCESS = "package com.acme.svc;\n\n"
"public class Process {\n"
" public Process(String name) {}\n"
" public int start() { return new Record().id(); }\n"
"}\n\n"
"class Record {\n"
" int id() { return 0; }\n"
"}\n";
static const char *const JT_SERVICE =
"package com.acme.svc;\n\n"
"import com.acme.model.Customer;\n"
"import java.util.List;\n"
"import java.util.stream.Collectors;\n"
"import org.jooq.DSLContext;\n\n"
"public class InvoiceService {\n"
" private final DSLContext dsl;\n\n"
" public InvoiceService(DSLContext dsl) {\n"
" this.dsl = dsl;\n"
" }\n\n"
" public List<String> numbers(List<String> names) {\n"
" return names.stream().filter(n -> n.isEmpty()).map(n -> n.trim())\n"
" .collect(Collectors.toList());\n"
" }\n\n"
" public int touch(List<String> names) {\n"
" dsl.select(\"a\");\n"
" return names.size();\n"
" }\n\n"
" public String customerName(Customer c) {\n"
" return c.getName();\n"
" }\n\n"
" public int run() {\n"
" Process p = new Process(\"job\");\n"
" return p.start();\n"
" }\n\n"
" public String qualifiedName(com.acme.other.Filters f) {\n"
" return f.label();\n"
" }\n\n"
" public String label() {\n"
" return \"\";\n"
" }\n\n"
" public String branchCity(com.acme.model.Branch b) {\n"
" return b.getCity();\n"
" }\n"
"}\n";
/* JLS 6.4.1: java.lang is an on-demand import, and an on-demand import never
* shadows a type of the compilation unit's own package. So `Enum` in
* Message means com.acme.protogen.Enum (the shape of Netflix conductor's
* protogen.Enum). Read as java.lang.Enum, the call counts as external and
* loses its edge. */
static const char *const JT_PROTO_ENUM = "package com.acme.protogen;\n\n"
"public class Enum {\n"
" public Enum(Class<?> cls, String parent) {}\n"
"}\n";
static const char *const JT_PROTO_MESSAGE = "package com.acme.protogen;\n\n"
"public class Message {\n"
" public void addNestedEnum(Class<?> cls) {\n"
" Enum e = new Enum(cls, \"parent\");\n"
" }\n"
"}\n";
static void jt_write_fixture(const char *tmp, int filler) {
th_write_file(TH_PATH(tmp, "src/main/java/com/acme/model/Customer.java"), JT_CUSTOMER);
th_write_file(TH_PATH(tmp, "src/main/java/com/acme/other/Filters.java"), JT_FILTERS);
th_write_file(TH_PATH(tmp, "src/main/java/com/acme/svc/InvoiceService.java"), JT_SERVICE);
th_write_file(TH_PATH(tmp, "src/main/java/com/acme/svc/Process.java"), JT_PROCESS);
th_write_file(TH_PATH(tmp, "src/main/java/com/acme/protogen/Enum.java"), JT_PROTO_ENUM);
th_write_file(TH_PATH(tmp, "src/main/java/com/acme/protogen/Message.java"), JT_PROTO_MESSAGE);
for (int i = 0; i < filler; i++) {
char rel[128];
char body[256];
snprintf(rel, sizeof(rel), "src/main/java/com/acme/fill/Fill%d.java", i);
snprintf(body, sizeof(body),
"package com.acme.fill;\n\npublic class Fill%d {\n"
" public int value%d() { return %d; }\n}\n",
i, i, i);
th_write_file(TH_PATH(tmp, rel), body);
}
}
/* ── Store reads ─────────────────────────────────────────────────── */
typedef struct {
char tmp[256];
char db_path[512];
char project[512];
sqlite3 *db;
} jt_index_t;
static int jt_index(jt_index_t *ix, int filler) {
memset(ix, 0, sizeof(*ix));
snprintf(ix->tmp, sizeof(ix->tmp), "/tmp/cbm_jtyped_XXXXXX");
if (!cbm_mkdtemp(ix->tmp)) {
return -1;
}
jt_write_fixture(ix->tmp, filler);
snprintf(ix->db_path, sizeof(ix->db_path), "%s/jt.db", ix->tmp);
cbm_pipeline_t *p = cbm_pipeline_new(ix->tmp, ix->db_path, CBM_MODE_FULL);
if (!p) {
return -1;
}
int rc = cbm_pipeline_run(p);
snprintf(ix->project, sizeof(ix->project), "%s", cbm_pipeline_project_name(p));
cbm_pipeline_free(p);
if (rc != 0 || sqlite3_open_v2(ix->db_path, &ix->db, SQLITE_OPEN_READONLY, NULL) != SQLITE_OK) {
return -1;
}
return 0;
}
static void jt_close(jt_index_t *ix) {
if (ix->db) {
sqlite3_close(ix->db);
}
th_cleanup(ix->tmp);
}
/* Targets of the CALLS edges from method `caller` whose textual callee ends
* in `.leaf` or is `leaf`, as "<qn-without-project>|<strategy>" lines. */
static int jt_targets(jt_index_t *ix, const char *caller, const char *leaf, char *out,
size_t out_len) {
out[0] = '\0';
const char *sql = "SELECT t.qualified_name, json_extract(e.properties,'$.strategy'), "
"json_extract(e.properties,'$.callee') FROM edges e "
"JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id "
"WHERE e.type = 'CALLS' AND s.name = ?1";
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(ix->db, sql, -1, &st, NULL) != SQLITE_OK) {
return -1;
}
sqlite3_bind_text(st, 1, caller, -1, SQLITE_STATIC);
int n = 0;
size_t plen = strlen(ix->project);
size_t llen = strlen(leaf);
while (sqlite3_step(st) == SQLITE_ROW) {
const char *qn = (const char *)sqlite3_column_text(st, 0);
const char *strategy = (const char *)sqlite3_column_text(st, 1);
const char *callee = (const char *)sqlite3_column_text(st, 2);
if (!qn || !callee) {
continue;
}
size_t clen = strlen(callee);
bool leaf_match = (clen == llen && strcmp(callee, leaf) == 0) ||
(clen > llen && callee[clen - llen - 1] == '.' &&
strcmp(callee + clen - llen, leaf) == 0);
if (!leaf_match) {
continue;
}
if (strncmp(qn, ix->project, plen) == 0 && qn[plen] == '.') {
qn += plen + 1;
}
size_t used = strlen(out);
snprintf(out + used, out_len - used, "%s|%s\n", qn, strategy ? strategy : "");
n++;
}
sqlite3_finalize(st);
return n;
}
/* Exactly one CALLS edge for (caller, leaf), and its target QN ends in
* `want_suffix`. Prints what the graph has on failure. */
static int jt_expect(jt_index_t *ix, const char *caller, const char *leaf,
const char *want_suffix) {
char got[4096];
int n = jt_targets(ix, caller, leaf, got, sizeof(got));
if (n == 1) {
const char *bar = strchr(got, '|');
size_t qlen = bar ? (size_t)(bar - got) : strlen(got);
size_t wlen = strlen(want_suffix);
if (qlen >= wlen && strncmp(got + qlen - wlen, want_suffix, wlen) == 0) {
return 1;
}
}
printf("\n %s -> %s: want one edge to ...%s, have %d:\n%s", caller, leaf, want_suffix, n, got);
return 0;
}
/* No CALLS edge for (caller, leaf) reaches a node whose QN contains `bad`. */
static int jt_expect_none_to(jt_index_t *ix, const char *caller, const char *leaf,
const char *bad) {
char got[4096];
(void)jt_targets(ix, caller, leaf, got, sizeof(got));
if (!strstr(got, bad)) {
return 1;
}
printf("\n %s -> %s: an edge reaches %s:\n%s", caller, leaf, bad, got);
return 0;
}
/* At least one CALLS edge for (caller, leaf). */
static int jt_expect_some_edge(jt_index_t *ix, const char *caller, const char *leaf) {
char got[4096];
if (jt_targets(ix, caller, leaf, got, sizeof(got)) > 0) {
return 1;
}
printf("\n %s -> %s: want an edge, have none\n", caller, leaf);
return 0;
}
/* No CALLS edge at all for (caller, leaf). */
static int jt_expect_no_edge(jt_index_t *ix, const char *caller, const char *leaf) {
char got[4096];
int n = jt_targets(ix, caller, leaf, got, sizeof(got));
if (n == 0) {
return 1;
}
printf("\n %s -> %s: want no edge, have %d:\n%s", caller, leaf, n, got);
return 0;
}
/* The CALLS edges out of the svc package that the Java resolver found by
* type, one sorted line each, with the project prefix dropped so two indexes
* compare. Edges of the textual registry are left out: its strategy names
* already differ between the two paths (a constructor is import_map on one
* and unique_name on the other), and so can its pick among same-named
* methods. */
static char *jt_edge_set(jt_index_t *ix) {
const char *sql = "SELECT group_concat(x, char(10)) FROM (SELECT s.name || '|' || "
"coalesce(json_extract(e.properties,'$.callee'),'') || '|' || "
"substr(t.qualified_name, length(?1) + 2) || '|' || "
"json_extract(e.properties,'$.strategy') AS x FROM edges e "
"JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id "
"WHERE e.type = 'CALLS' AND s.file_path LIKE '%/svc/%' "
"AND json_extract(e.properties,'$.strategy') LIKE 'lsp%' ORDER BY x)";
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(ix->db, sql, -1, &st, NULL) != SQLITE_OK) {
return NULL;
}
sqlite3_bind_text(st, 1, ix->project, -1, SQLITE_STATIC);
char *out = NULL;
if (sqlite3_step(st) == SQLITE_ROW && sqlite3_column_text(st, 0)) {
out = strdup((const char *)sqlite3_column_text(st, 0));
}
sqlite3_finalize(st);
return out;
}
/* ── Tests ───────────────────────────────────────────────────────── */
static int jt_check_external(jt_index_t *ix, int sequential) {
int ok = 1;
/* JDK and library receivers never bind a project method by name... */
ok &= jt_expect_none_to(ix, "numbers", "filter", "Filters");
ok &= jt_expect_none_to(ix, "numbers", "map", "Filters");
ok &= jt_expect_none_to(ix, "touch", "select", "Filters");
ok &= jt_expect_none_to(ix, "touch", "size", "Filters");
/* ...and get no edge: the graph holds no node for a library method. */
ok &= jt_expect_no_edge(ix, "numbers", "filter");
ok &= jt_expect_no_edge(ix, "touch", "select");
ok &= jt_expect_no_edge(ix, "touch", "size");
/* A project receiver keeps its edge. Its type comes from an import, which
* only the name guess reads here, as on main; on Windows the parallel
* path's guess can pick Filters.getName. */
ok &= sequential ? jt_expect(ix, "customerName", "getName", "com.acme.model.Customer.getName")
: jt_expect_some_edge(ix, "customerName", "getName");
/* A project type spelled with its package and not imported binds by its
* type, where the name guess picks the caller's own label(). */
ok &= jt_expect(ix, "qualifiedName", "label", "com.acme.other.Filters.label");
/* An unknown type in a project package is not proven external. */
ok &= jt_expect_some_edge(ix, "branchCity", "getCity");
/* A project type named like a java.lang one, in the caller's package. */
/* `new` on a project type binds its class node, as for any project type. */
ok &= jt_expect(ix, "run", "Process", "com.acme.svc.Process");
ok &= jt_expect(ix, "run", "start", "com.acme.svc.Process.start");
ok &= jt_expect(ix, "start", "id", "Record.id");
return ok;
}
/* The one CALLS edge for (caller, leaf) reaches a node labelled `label`. */
static int jt_expect_target_label(jt_index_t *ix, const char *caller, const char *leaf,
const char *label) {
const char *sql = "SELECT t.label, json_extract(e.properties,'$.callee') FROM edges e "
"JOIN nodes s ON s.id = e.source_id JOIN nodes t ON t.id = e.target_id "
"WHERE e.type = 'CALLS' AND s.name = ?1";
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(ix->db, sql, -1, &st, NULL) != SQLITE_OK) {
return 0;
}
sqlite3_bind_text(st, 1, caller, -1, SQLITE_STATIC);
int hits = 0;
int others = 0;
size_t llen = strlen(leaf);
while (sqlite3_step(st) == SQLITE_ROW) {
const char *have = (const char *)sqlite3_column_text(st, 0);
const char *callee = (const char *)sqlite3_column_text(st, 1);
if (!have || !callee) {
continue;
}
size_t clen = strlen(callee);
bool leaf_match = (clen == llen && strcmp(callee, leaf) == 0) ||
(clen > llen && callee[clen - llen - 1] == '.' &&
strcmp(callee + clen - llen, leaf) == 0);
if (!leaf_match) {
continue;
}
if (strcmp(have, label) == 0) {
hits++;
} else {
others++;
}
}
sqlite3_finalize(st);
if (hits == 1 && others == 0) {
return 1;
}
printf("\n %s -> %s: want one edge to a %s node, have %d to one and %d to others\n", caller,
leaf, label, hits, others);
return 0;
}
/* `new Enum(..)` in com.acme.protogen binds the project class Enum (its
* class node, as `new` does for every project type), not java.lang.Enum. */
static int jt_check_own_package_enum(jt_index_t *ix) {
int ok = 1;
ok &= jt_expect(ix, "addNestedEnum", "Enum", "com.acme.protogen.Enum");
ok &= jt_expect_target_label(ix, "addNestedEnum", "Enum", "Class");
return ok;
}
static int jt_run_check(int filler, int enum_only) {
jt_index_t ix;
if (jt_index(&ix, filler) != 0) {
printf("\n indexing the fixture failed\n");
jt_close(&ix);
return 0;
}
int ok = enum_only ? jt_check_own_package_enum(&ix) : jt_check_external(&ix, filler == 0);
jt_close(&ix);
return ok;
}
static int jt_run_all(int filler) {
return jt_run_check(filler, 0);
}
TEST(java_typed_calls_sequential) {
ASSERT_TRUE(jt_run_all(0));
PASS();
}
TEST(java_typed_calls_parallel) {
ASSERT_TRUE(jt_run_all(60));
PASS();
}
TEST(java_typed_calls_own_package_enum_sequential) {
ASSERT_TRUE(jt_run_check(0, 1));
PASS();
}
TEST(java_typed_calls_own_package_enum_parallel) {
ASSERT_TRUE(jt_run_check(60, 1));
PASS();
}
/* The sequential and the parallel path find the same svc edges by type. */
TEST(java_typed_calls_paths_agree) {
jt_index_t seq;
jt_index_t par;
int seq_rc = jt_index(&seq, 0);
int par_rc = jt_index(&par, 60);
char *a = seq_rc == 0 ? jt_edge_set(&seq) : NULL;
char *b = par_rc == 0 ? jt_edge_set(&par) : NULL;
int same = a && b && strcmp(a, b) == 0;
if (!same) {
printf("\n sequential:\n%s\n parallel:\n%s\n", a ? a : "(none)", b ? b : "(none)");
}
free(a);
free(b);
jt_close(&seq);
jt_close(&par);
ASSERT_TRUE(same);
PASS();
}
SUITE(java_typed_calls) {
RUN_TEST(java_typed_calls_sequential);
RUN_TEST(java_typed_calls_parallel);
RUN_TEST(java_typed_calls_own_package_enum_sequential);
RUN_TEST(java_typed_calls_own_package_enum_parallel);
RUN_TEST(java_typed_calls_paths_agree);
}
+2
View File
@@ -1816,6 +1816,7 @@ extern void suite_py_lsp_scale(void);
extern void suite_ts_lsp(void);
extern void suite_java_lsp(void);
extern void suite_java_lsp_coverage(void);
extern void suite_java_typed_calls(void);
extern void suite_kotlin_lsp(void);
extern void suite_kotlin_typed_calls(void);
extern void suite_rust_lsp(void);
@@ -2547,6 +2548,7 @@ int main(int argc, char **argv) {
RUN_SELECTED_SUITE(ts_lsp);
RUN_SELECTED_SUITE(java_lsp);
RUN_SELECTED_SUITE(java_lsp_coverage);
RUN_SELECTED_SUITE(java_typed_calls);
/* Architecture + ADR + Louvain */
RUN_SELECTED_SUITE(store_arch);
+138
View File
@@ -14551,6 +14551,141 @@ TEST(search_code_output_limit_fails_closed_and_cleans_scan) {
PASS();
}
/* #2634: the POSIX unscoped scan runs find/sort/grep as argv stages with no
* shell, so file names carrying shell metacharacters are plain operands, not
* command text. Pin that such files are discovered and scanned. */
TEST(search_code_unscoped_argv_scans_metachar_filenames_issue2634) {
#ifdef _WIN32
SKIP_PLATFORM("argv scan stages are POSIX-only");
#else
char tmp[CBM_SZ_512];
snprintf(tmp, sizeof(tmp), "%s/cbm-search-argv-meta-XXXXXX", cbm_tmpdir());
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char hostile[CBM_SZ_1K];
snprintf(hostile, sizeof(hostile), "%s/we'ird ;na`me$.go", tmp);
ASSERT_EQ(th_write_file(hostile,
"package main\n\nfunc HandleHostile2634() error {\n\treturn nil\n}\n"),
0);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
const char *project = "search-argv-metachar";
cbm_mcp_server_set_project(srv, project);
ASSERT_EQ(cbm_store_upsert_project(store, project, tmp), CBM_STORE_OK);
char *resp = cbm_mcp_handle_tool(srv, "search_code",
"{\"pattern\":\"HandleHostile2634\","
"\"project\":\"search-argv-metachar\",\"format\":\"json\"}");
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "we'ird ;na`me$.go"));
ASSERT_NOT_NULL(strstr(inner, "\"total_grep_matches\":1"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
ASSERT_EQ(th_rmtree(tmp), 0);
PASS();
#endif
}
/* #2634: grep's exit 1 (no match in a chunk) is an ordinary empty result, not
* a scan failure — the same contract the retired xargs wrapper encoded. */
TEST(search_code_unscoped_argv_no_match_is_success_issue2634) {
#ifdef _WIN32
SKIP_PLATFORM("argv scan stages are POSIX-only");
#else
char tmp[CBM_SZ_512];
snprintf(tmp, sizeof(tmp), "%s/cbm-search-argv-nomatch-XXXXXX", cbm_tmpdir());
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char src_path[CBM_SZ_1K];
snprintf(src_path, sizeof(src_path), "%s/handler.go", tmp);
ASSERT_EQ(th_write_file(src_path,
"package main\n\nfunc HandlePresent2634() error {\n\treturn nil\n}\n"),
0);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
const char *project = "search-argv-nomatch";
cbm_mcp_server_set_project(srv, project);
ASSERT_EQ(cbm_store_upsert_project(store, project, tmp), CBM_STORE_OK);
char *resp = cbm_mcp_handle_tool(srv, "search_code",
"{\"pattern\":\"NoSuchSymbol2634\","
"\"project\":\"search-argv-nomatch\",\"format\":\"json\"}");
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"total_grep_matches\":0"));
ASSERT_NOT_NULL(strstr(inner, "\"total_relation\":\"eq\""));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
ASSERT_EQ(th_rmtree(tmp), 0);
PASS();
#endif
}
/* #2634: the unscoped file_pattern reaches find as a -name operand, so a
* non-matching extension is excluded from the scan without any shell glob. */
TEST(search_code_unscoped_argv_honours_file_pattern_issue2634) {
#ifdef _WIN32
SKIP_PLATFORM("argv scan stages are POSIX-only");
#else
char tmp[CBM_SZ_512];
snprintf(tmp, sizeof(tmp), "%s/cbm-search-argv-glob-XXXXXX", cbm_tmpdir());
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char py[CBM_SZ_1K];
snprintf(py, sizeof(py), "%s/handler.py", tmp);
ASSERT_EQ(th_write_file(py, "def handle_python2634():\n return 1\n"), 0);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *store = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(store);
const char *project = "search-argv-glob";
cbm_mcp_server_set_project(srv, project);
ASSERT_EQ(cbm_store_upsert_project(store, project, tmp), CBM_STORE_OK);
char *resp = cbm_mcp_handle_tool(srv, "search_code",
"{\"pattern\":\"handle_python2634\","
"\"project\":\"search-argv-glob\","
"\"file_pattern\":\"*.go\",\"format\":\"json\"}");
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_TRUE(strstr(inner, "handler.py") == NULL);
ASSERT_NOT_NULL(strstr(inner, "\"total_grep_matches\":0"));
free(inner);
free(resp);
resp = cbm_mcp_handle_tool(srv, "search_code",
"{\"pattern\":\"handle_python2634\","
"\"project\":\"search-argv-glob\",\"format\":\"json\"}");
ASSERT_NOT_NULL(resp);
ASSERT_TRUE(strstr(resp, "\"isError\":true") == NULL);
inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "handler.py"));
ASSERT_NOT_NULL(strstr(inner, "\"total_grep_matches\":1"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
ASSERT_EQ(th_rmtree(tmp), 0);
PASS();
#endif
}
TEST(search_code_scan_deadline_fails_closed_and_resets) {
mcp_search_cache_t cache;
ASSERT_TRUE(mcp_search_cache_open(&cache, "cbm-search-deadline"));
@@ -28245,6 +28380,9 @@ SUITE(mcp) {
RUN_TEST(search_code_file_pattern_prefilter_boundaries);
RUN_TEST(search_code_windows_scope_prefilter_removes_pipeline_filter);
RUN_TEST(search_code_cancel_cleans_supervised_scan);
RUN_TEST(search_code_unscoped_argv_scans_metachar_filenames_issue2634);
RUN_TEST(search_code_unscoped_argv_no_match_is_success_issue2634);
RUN_TEST(search_code_unscoped_argv_honours_file_pattern_issue2634);
RUN_TEST(server_handle_logs_timeout_without_query);
RUN_TEST(search_code_output_limit_fails_closed_and_cleans_scan);
RUN_TEST(search_code_scan_deadline_fails_closed_and_resets);
+31
View File
@@ -4156,6 +4156,36 @@ TEST(parallel_rust_external_target_contract) {
PASS();
}
/* Contract for cbm_pipeline_java_external_target: only a Java row the
* resolver marked lsp_external, naming a qualified member, is external. The
* end-to-end probes live in test_java_typed_calls.c. */
TEST(parallel_java_external_target_contract) {
CBMResolvedCall rc = {0};
rc.callee_qn = "org.jooq.DSLContext.select";
rc.strategy = "lsp_external";
ASSERT_TRUE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc));
/* Every other strategy keeps the registry fallback. */
rc.strategy = "lsp_type_dispatch";
ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc));
rc.strategy = "lsp_unresolved";
ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc));
/* A bare name is no member of any type. */
rc.strategy = "lsp_external";
rc.callee_qn = "select";
ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc));
/* Per-language: no other language is affected. */
rc.callee_qn = "org.jooq.DSLContext.select";
ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_KOTLIN, &rc));
/* Defensive NULL inputs. */
ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, NULL));
rc.callee_qn = NULL;
ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc));
rc.callee_qn = "org.jooq.DSLContext.select";
rc.strategy = NULL;
ASSERT_FALSE(cbm_pipeline_java_external_target(CBM_LANG_JAVA, &rc));
PASS();
}
/* Gate guard for the JVM-only unique-tail fallbacks (lsp_resolve.h).
*
* The tail fallbacks join LSP overrides across QN drift by unique
@@ -6015,6 +6045,7 @@ SUITE(parallel) {
RUN_TEST(parallel_cross_file_reread_preserves_unretained_edges);
RUN_TEST(parallel_java_kotlin_lsp_override_cross_file_emits_lsp_strategy_edges);
RUN_TEST(parallel_rust_external_target_contract);
RUN_TEST(parallel_java_external_target_contract);
RUN_TEST(parallel_lsp_tail_match_fallbacks_gated_to_jvm);
RUN_TEST(parallel_calls_parity);
RUN_TEST(parallel_defines_parity);
+106
View File
@@ -9710,6 +9710,108 @@ TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory) {
}
#endif /* !_WIN32 */
#ifdef _WIN32
/* Create or delete a real file named nul in dir, as a Git Bash redirect such
* as `cmd > nul` leaves behind (#2646). The plain Win32 path <dir>/nul names
* the NUL device, so the file is reached only through the extended-length
* namespace, which needs an absolute path with backslashes (CI's TEMP uses
* forward slashes). */
static bool pipeline_test_nul_file(const char *dir, bool create) {
enum { NUL_PATH_CAP = 1024, PREFIX_LEN = 4, LEAF_LEN = 4 };
static const wchar_t prefix[] = L"\\\\?\\";
static const wchar_t leaf[] = L"\\nul";
wchar_t path[NUL_PATH_CAP];
const DWORD room = NUL_PATH_CAP - PREFIX_LEN - LEAF_LEN;
wchar_t *wide_dir = cbm_utf8_to_wide(dir);
DWORD length = wide_dir ? GetFullPathNameW(wide_dir, room, path + PREFIX_LEN, NULL) : 0;
free(wide_dir);
if (length == 0 || length >= room) {
return false;
}
wmemcpy(path, prefix, PREFIX_LEN);
wmemcpy(path + PREFIX_LEN + length, leaf, LEAF_LEN + 1);
if (!create) {
return DeleteFileW(path) != 0;
}
HANDLE file =
CreateFileW(path, GENERIC_WRITE, 0, NULL, CREATE_NEW, FILE_ATTRIBUTE_NORMAL, NULL);
if (file == INVALID_HANDLE_VALUE) {
return false;
}
CloseHandle(file);
return true;
}
/* #2646: the control-file walk cannot inspect a file named nul through the
* plain path, and it never needs to: only directories and control files can
* contribute to the manifest. Listing nul in .gitignore does not help, since
* the walk visits every entry. */
TEST(pipeline_semantic_manifest_skips_nul_device_file_issue2646) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_manifest_nul_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
write_temp_file(tmp, "app.py", "def NulNeighbor():\n return 1\n");
write_temp_file(tmp, ".gitignore", "nul\n");
bool created = pipeline_test_nul_file(tmp, true);
cbm_file_hash_t *manifest = NULL;
int manifest_count = -1;
int rc = cbm_pipeline_build_semantic_manifest(NULL, "manifest-skip-nul", tmp, NULL, 0, NULL, 0,
NULL, NULL, &manifest, &manifest_count);
bool found_gitignore = false;
for (int i = 0; i < manifest_count; i++) {
if (manifest[i].rel_path && strcmp(manifest[i].rel_path, ".gitignore") == 0) {
found_gitignore = true;
}
}
cbm_pipeline_free_semantic_manifest(manifest, manifest_count > 0 ? manifest_count : 0);
bool deleted = created && pipeline_test_nul_file(tmp, false);
th_rmtree(tmp);
ASSERT_TRUE(created);
ASSERT_EQ(rc, 0);
ASSERT_TRUE(found_gitignore);
ASSERT_TRUE(deleted);
PASS();
}
/* The user-visible half of #2646: one such file made every index run return
* CBM_PIPELINE_ABORT_PRESERVE_DB (aborted_previous_preserved), so a new
* project never got an index. */
TEST(pipeline_full_index_survives_nul_device_file_issue2646) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_index_nul_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
write_temp_file(tmp, "app.py", "def NulNeighbor():\n return 1\n");
write_temp_file(tmp, ".gitignore", "nul\n");
bool created = pipeline_test_nul_file(tmp, true);
char db_path[512];
snprintf(db_path, sizeof(db_path), "%s/generation.db", tmp);
cbm_pipeline_t *pipeline = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL);
int run_rc = pipeline ? cbm_pipeline_run(pipeline) : -1;
char project[256] = "";
if (pipeline) {
snprintf(project, sizeof(project), "%s", cbm_pipeline_project_name(pipeline));
cbm_pipeline_free(pipeline);
}
int indexed = -1;
cbm_store_t *store = cbm_store_open_path_query(db_path);
if (store) {
indexed = named_node_count(store, project, "NulNeighbor");
cbm_store_close(store);
}
bool deleted = created && pipeline_test_nul_file(tmp, false);
th_rmtree(tmp);
ASSERT_TRUE(created);
ASSERT_EQ(run_rc, 0);
ASSERT_EQ(indexed, 1);
ASSERT_TRUE(deleted);
PASS();
}
#endif /* _WIN32 */
/* A fully validated staged graph must be able to recover from a definitely
* non-SQLite destination without deleting evidence or overwriting an earlier
* quarantine. The replacement happens only after the corrupt bytes are moved. */
@@ -34216,6 +34318,10 @@ SUITE(pipeline_semantic_manifest_repro) {
RUN_TEST(pipeline_semantic_manifest_rejects_non_directory_root);
#ifndef _WIN32
RUN_TEST(pipeline_semantic_manifest_skips_unreadable_subdirectory);
#endif
#ifdef _WIN32
RUN_TEST(pipeline_semantic_manifest_skips_nul_device_file_issue2646);
RUN_TEST(pipeline_full_index_survives_nul_device_file_issue2646);
#endif
RUN_TEST(pipeline_full_reindex_quarantines_corrupt_destination_without_overwrite);
RUN_TEST(pipeline_full_reindex_replaces_legacy_schema_without_quarantine);
+126
View File
@@ -2260,6 +2260,127 @@ TEST(watcher_sustained_failure_logs_once_issue2015) {
PASS();
}
/* ── Success cooldown (#2835) ─────────────────────────────────────── */
TEST(success_delay_fast_index_keeps_interval) {
ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 0), 5000);
ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 1), 5000);
ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 1666), 5000); /* 3x < interval */
ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 20000), 60000);
PASS();
}
TEST(success_delay_slow_index_scales_by_factor) {
ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 1667), 5001);
ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 10000), 30000);
/* 90 s rebuild → next poll 4.5 min after it finishes. */
ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 90000), 270000);
PASS();
}
TEST(success_delay_clamped_and_degenerate) {
/* Capped at 5 min (INDEX_FAIL_CEILING_MS) from 100 s of indexing up. */
ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 100000), 300000);
ASSERT_EQ(cbm_watcher_success_delay_ms(5000, 100001), 300000);
ASSERT_EQ(cbm_watcher_success_delay_ms(60000, 300000), 300000);
ASSERT_EQ(cbm_watcher_success_delay_ms(5000, INT64_MAX), 300000);
ASSERT_EQ(cbm_watcher_success_delay_ms(5000, -1), 5000);
ASSERT_EQ(cbm_watcher_success_delay_ms(5000, INT64_MIN), 5000);
ASSERT_EQ(cbm_watcher_success_delay_ms(-1, 0), 0);
/* Never below the caller's own cadence, even above the ceiling. */
ASSERT_EQ(cbm_watcher_success_delay_ms(INT_MAX, INT64_MAX), INT_MAX);
PASS();
}
TEST(success_delay_is_monotonic) {
int previous = cbm_watcher_success_delay_ms(5000, 0);
for (int64_t e = 0; e <= 2000000; e += 997) {
int delay = cbm_watcher_success_delay_ms(5000, e);
ASSERT_TRUE(delay >= previous);
ASSERT_TRUE(delay >= 5000 && delay <= 300000);
previous = delay;
}
PASS();
}
/* Wire-up: the cooldown must be measured around the real index_fn call and
* counted from when the index finished. The watcher's clock is frozen via a
* test seam and the callback advances it, so an index "takes" a chosen time
* with no sleep and the scheduled poll time can be asserted exactly. */
#define COOL_NS_PER_MS 1000000LL
static int64_t cool_clock_ns = 0;
static int64_t cool_index_ms = 0;
static int cool_index_calls = 0;
static int cool_index_callback(const char *name, const char *path, void *ud) {
(void)name;
(void)path;
(void)ud;
cool_index_calls++;
cool_clock_ns += cool_index_ms * COOL_NS_PER_MS;
cbm_watcher_test_set_clock_ns(cool_clock_ns);
return 0;
}
/* Re-index once (touch + poll until the callback runs; a transient git probe
* failure makes a poll a no-op, see the sustained-failure test) and return
* the scheduled next poll relative to when the index finished, in ms. */
static int64_t cool_reindex_delay_ms(cbm_watcher_t *w, const char *file, const char *line,
int64_t index_ms) {
cool_index_ms = index_ms;
th_append_file(file, line);
int target = cool_index_calls + 1;
while (cool_index_calls < target) {
cbm_watcher_touch(w, "cool-repo");
cbm_watcher_poll_once(w);
}
return (cbm_watcher_test_next_poll_ns(w, "cool-repo") - cool_clock_ns) / COOL_NS_PER_MS;
}
TEST(watcher_success_cooldown_measures_index_issue2835) {
char tmpdir[256];
snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_watcher_cool_XXXXXX");
if (!cbm_mkdtemp(tmpdir))
FAIL("cbm_mkdtemp failed");
if (wt_git(tmpdir, "init -q") != 0) {
th_rmtree(tmpdir);
FAIL("git init failed");
}
char p[300];
th_write_file(wt_path(p, sizeof(p), tmpdir, "file.txt"), "hello\n");
wt_git(tmpdir, "add file.txt");
wt_git(tmpdir, "commit -q -m init");
cool_clock_ns = 1000LL * 1000 * COOL_NS_PER_MS; /* any nonzero start */
cbm_watcher_test_set_clock_ns(cool_clock_ns);
cool_index_calls = 0;
cbm_store_t *store = cbm_store_open_memory();
cbm_watcher_t *w = cbm_watcher_new(store, cool_index_callback, NULL);
cbm_watcher_watch(w, "cool-repo", tmpdir);
cbm_watcher_poll_once(w); /* baseline */
wt_path(p, sizeof(p), tmpdir, "file.txt");
/* Instant index: plain cadence. */
int64_t fast = cool_reindex_delay_ms(w, p, "a\n", 0);
/* 60 s index → 3 x 60 s, counted from the END of the index (anchoring at
* the start would give 120 s here). */
int64_t slow = cool_reindex_delay_ms(w, p, "b\n", 60000);
/* 200 s index → 600 s, capped at 5 min. */
int64_t capped = cool_reindex_delay_ms(w, p, "c\n", 200000);
/* Restore the real clock before any assert can return early. */
cbm_watcher_test_set_clock_ns(0);
ASSERT_EQ(fast, cbm_watcher_poll_interval_ms(1));
ASSERT_EQ(slow, 180000);
ASSERT_EQ(capped, 300000);
cbm_watcher_free(w);
cbm_store_close(store);
th_rmtree(tmpdir);
PASS();
}
TEST(watcher_index_failure_count_unknown_project) {
cbm_store_t *store = cbm_store_open_memory();
cbm_watcher_t *w = cbm_watcher_new(store, index_callback, NULL);
@@ -4043,6 +4164,11 @@ SUITE(watcher) {
RUN_TEST(index_backoff_never_schedules_sooner_than_the_interval);
RUN_TEST(watcher_index_failure_streak_increments_and_resets_issue2015);
RUN_TEST(watcher_index_failure_backoff_gates_repolling_issue2015);
RUN_TEST(success_delay_fast_index_keeps_interval);
RUN_TEST(success_delay_slow_index_scales_by_factor);
RUN_TEST(success_delay_clamped_and_degenerate);
RUN_TEST(success_delay_is_monotonic);
RUN_TEST(watcher_success_cooldown_measures_index_issue2835);
RUN_TEST(watcher_sustained_failure_logs_once_issue2015);
RUN_TEST(watcher_index_failure_count_unknown_project);
RUN_TEST(watcher_project_info_publishes_scan_issue2167);