Merge remote-tracking branch 'origin/main' into feat/shared-coordination-daemon

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>

# Conflicts:
#	src/mcp/mcp.c
#	tests/test_cli.c
This commit is contained in:
Martin Vogel committed 2026-07-18 13:48:06 +02:00
commit 5d8a9421fb
16 files changed
+3545 -715

No files matched your search

+8 -7
View File
@@ -237,8 +237,9 @@ echo "OK: search_graph found $TOTAL result(s) for 'compute'"
if ! TRACE=$(cli trace_path --project "$PROJECT" --function-name compute --direction inbound --depth 1); then
echo "FAIL: trace_path (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
# trace_path default output is TOON: the callers[N]{...} header carries the count
CALLERS=$(echo "$TRACE" | sed -n 's/^callers\[\([0-9]*\)\].*/\1/p' | head -1)
# trace_path default output is the tree format: callers_total carries the
# exact reachable count (test files excluded by default)
CALLERS=$(echo "$TRACE" | sed -n 's/^callers_total: \([0-9]*\).*/\1/p' | head -1)
CALLERS=${CALLERS:-0}
if [ "$CALLERS" -lt 1 ]; then
echo "FAIL: trace_path found 0 callers for 'compute'"
@@ -320,7 +321,7 @@ cyp_first_cell() {
# argv token, so string-literal args (e.g. replace(f.name,"a","A")) and Cypher
# metacharacters {}|=~<>" need no JSON escaping.
cli query_graph --project "$PROJECT" --query "$1" |
sed -n '/^rows\[/{n;p;}' | sed 's/^ //' | sed 's/^"//;s/"$//;s/\\"/"/g'
sed -n '/^rows: /{n;p;}' | sed 's/^ //' | sed 's/^"//;s/"$//;s/\\"/"/g'
}
# labels(n) → JSON list like ["Function"]
@@ -408,7 +409,7 @@ LEFTV=$(cyp_first_cell 'MATCH (f:Function) RETURN left(f.name, 3) AS l LIMIT 1')
# NOT EXISTS dead-code query (functions with no caller)
CYPHER_NX=$(cli query_graph --project "$PROJECT" --query "MATCH (f:Function) WHERE NOT EXISTS { (f)<-[:CALLS]-() } RETURN f.name")
NX_OK=$(echo "$CYPHER_NX" | grep -qE '^rows\[[0-9]+\]\{' && echo "True" || echo "False")
NX_OK=$(echo "$CYPHER_NX" | grep -qE '^rows: [0-9]+' && echo "True" || echo "False")
[ "$NX_OK" = "True" ] && echo "OK: query_graph NOT EXISTS dead-code query executed" || { echo "FAIL: NOT EXISTS query"; echo "$CYPHER_NX" | head -c 300; exit 1; }
# CASE expression in RETURN
@@ -432,7 +433,7 @@ if ! ARCH=$(cli get_architecture --project "$PROJECT" --aspects clusters); then
echo "FAIL: get_architecture (flag form) exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
# get_architecture default output is TOON: clusters[N]{...} header carries the count
NCLUST=$(echo "$ARCH" | sed -n 's/^clusters\[\([0-9]*\)\].*/\1/p' | head -1)
NCLUST=$(echo "$ARCH" | sed -n 's/^clusters: \([0-9]*\).*/\1/p' | head -1)
NCLUST=${NCLUST:-0}
if [ "$NCLUST" -lt 1 ]; then
echo "FAIL: get_architecture returned 0 community clusters"; echo "$ARCH" | head -c 400; exit 1
@@ -473,7 +474,7 @@ echo "=== Phase 3h: CLI input-mode guards (flags / stdin / --args-file / --help
assert_json_obj() { python3 -c "import json,sys; d=json.loads(sys.stdin.read()); sys.exit(0 if isinstance(d,dict) else 1)" 2>/dev/null; }
# search_graph emits TOON by default: a results/semantic table header proves
# the tool parsed its typed flags and produced a well-formed response.
assert_toon_table() { grep -qE '^(results|semantic)\[[0-9]+\]\{'; }
assert_toon_table() { grep -qE '^(results|semantic): [0-9]+'; }
# B1: INTEGER flag — --limit is schema-typed integer; must parse and answer.
if ! IM_INT=$(cli search_graph --project "$PROJECT" --name-pattern compute --limit 5); then
@@ -500,7 +501,7 @@ fi
if ! IM_ARR=$(cli search_graph --project "$PROJECT" --semantic-query send --semantic-query publish); then
echo "FAIL B3: search_graph repeated --semantic-query exited non-zero"; cat "$CLI_STDERR"; exit 1
fi
if echo "$IM_ARR" | grep -qE '^semantic\[[0-9]+\]\{'; then
if echo "$IM_ARR" | grep -qE '^semantic: [0-9]+'; then
echo "OK B3: ARRAY flag (repeated --semantic-query) → semantic TOON table"
else
echo "FAIL B3: repeated --semantic-query did not produce a semantic table"; echo "$IM_ARR" | head -c 300; exit 1
+2
View File
@@ -203,6 +203,8 @@ int cbm_resolve_claude_hook_command_for_testing(const char *script_name, bool wi
char *command, size_t command_size);
bool cbm_optional_hook_supported_for_testing(const char *agent_name, bool windows);
void cbm_hook_sanitize_metadata_for_testing(const char *input, char *output, size_t output_size);
char *cbm_hook_augment_format_context_for_testing(const char *envelope, const char *token,
bool *is_error);
int cbm_upsert_qwen_lifecycle_hooks_for_testing(const char *settings_path, const char *binary_path,
bool windows);
int cbm_upsert_qoder_context_hooks_for_testing(const char *settings_path, const char *binary_path);
+55 -21
View File
@@ -368,9 +368,23 @@ static char *ha_format_context(const char *envelope, const char *token, bool *is
yyjson_doc_free(edoc);
return NULL;
}
/* json-tree shape: {total, cols, groups:[{qn_prefix, file,
* rows:[[name,label,lines,in,out],...]}]}. The full qualified name is
* qn_prefix + "." + row[0]; label is row[1]; file lives on the group. */
yyjson_val *iroot = yyjson_doc_get_root(idoc);
yyjson_val *results = yyjson_obj_get(iroot, "results");
size_t nres = (results && yyjson_is_arr(results)) ? yyjson_arr_size(results) : 0;
yyjson_val *groups = yyjson_obj_get(iroot, "groups");
size_t nres = 0;
size_t gidx;
size_t gmax;
yyjson_val *g;
if (groups && yyjson_is_arr(groups)) {
yyjson_arr_foreach(groups, gidx, gmax, g) {
yyjson_val *rows = yyjson_obj_get(g, "rows");
if (rows && yyjson_is_arr(rows)) {
nres += yyjson_arr_size(rows);
}
}
}
if (nres == 0) {
yyjson_doc_free(idoc);
yyjson_doc_free(edoc);
@@ -389,26 +403,37 @@ static char *ha_format_context(const char *envelope, const char *token, bool *is
"(structured context; your search results below are "
"unaffected):",
nres, token);
size_t idx;
size_t maxn;
yyjson_val *r;
yyjson_arr_foreach(results, idx, maxn, r) {
if (off < 0 || off >= 3900) {
break;
yyjson_arr_foreach(groups, gidx, gmax, g) {
const char *prefix = ha_obj_str(g, "qn_prefix");
const char *fp = ha_obj_str(g, "file");
yyjson_val *rows = yyjson_obj_get(g, "rows");
if (!rows || !yyjson_is_arr(rows)) {
continue;
}
size_t ridx;
size_t rmax;
yyjson_val *row;
yyjson_arr_foreach(rows, ridx, rmax, row) {
if (off < 0 || off >= 3900) {
break;
}
const char *nm = yyjson_get_str(yyjson_arr_get(row, 0));
const char *lb = yyjson_get_str(yyjson_arr_get(row, 1));
char disp[HA_METADATA_CAP];
if (prefix && prefix[0] && nm && nm[0]) {
snprintf(disp, sizeof(disp), "%s.%s", prefix, nm);
} else {
snprintf(disp, sizeof(disp), "%s", nm ? nm : "");
}
char safe_disp[HA_METADATA_CAP];
char safe_path[HA_METADATA_CAP];
char safe_label[HA_METADATA_CAP];
ha_sanitize_metadata(disp, safe_disp, sizeof(safe_disp));
ha_sanitize_metadata(fp, safe_path, sizeof(safe_path));
ha_sanitize_metadata(lb, safe_label, sizeof(safe_label));
off += snprintf(text + off, (size_t)(4096 - off), "\n- %s %s%s%s", safe_disp,
safe_path, safe_label[0] ? " " : "", safe_label);
}
const char *qn = ha_obj_str(r, "qualified_name");
const char *nm = ha_obj_str(r, "name");
const char *fp = ha_obj_str(r, "file_path");
const char *lb = ha_obj_str(r, "label");
const char *disp = (qn && qn[0]) ? qn : (nm ? nm : "");
char safe_disp[HA_METADATA_CAP];
char safe_path[HA_METADATA_CAP];
char safe_label[HA_METADATA_CAP];
ha_sanitize_metadata(disp, safe_disp, sizeof(safe_disp));
ha_sanitize_metadata(fp, safe_path, sizeof(safe_path));
ha_sanitize_metadata(lb, safe_label, sizeof(safe_label));
off += snprintf(text + off, (size_t)(4096 - off), "\n- %s %s%s%s", safe_disp, safe_path,
safe_label[0] ? " " : "", safe_label);
}
yyjson_doc_free(idoc);
@@ -1232,6 +1257,15 @@ char *cbm_hook_augment_lifecycle_json_for_dialect(const char *input, const char
return json;
}
/* Test seam: run the real envelope->additionalContext formatter. Couples the
* test suite to the ACTUAL search_graph response shape — a format change that
* breaks this parser breaks a test locally, not just the Windows CI guard. */
char *cbm_hook_augment_format_context_for_testing(const char *envelope, const char *token,
bool *is_error) {
bool dummy = false;
return ha_format_context(envelope, token, is_error ? is_error : &dummy);
}
char *cbm_hook_augment_tool_json_for_testing(const char *input, const char *dialect_name,
const char *context, char *path, size_t path_size) {
if (!input || !context || !path || path_size == 0U || strlen(input) > HA_STDIN_CAP) {
+5
View File
@@ -2164,8 +2164,13 @@ static const char *node_string_field(const cbm_node_t *n, const char *prop) {
} fields[] = {
{"name", offsetof(cbm_node_t, name)},
{"qualified_name", offsetof(cbm_node_t, qualified_name)},
/* Aliases: field-eval agents reach for the short names, and a miss
* used to return a silent empty column costing a round-trip. */
{"qn", offsetof(cbm_node_t, qualified_name)},
{"label", offsetof(cbm_node_t, label)},
{"file_path", offsetof(cbm_node_t, file_path)},
{"file", offsetof(cbm_node_t, file_path)},
{"path", offsetof(cbm_node_t, file_path)},
};
for (size_t i = 0; i < sizeof(fields) / sizeof(fields[0]); i++) {
if (strcmp(prop, fields[i].key) == 0) {
+31 -27
View File
@@ -1,4 +1,4 @@
/* compact_out.c — TOON emission helpers. See compact_out.h for the contract. */
/* compact_out.c — tree-format emission helpers. See compact_out.h for the contract. */
#include "mcp/compact_out.h"
#include <ctype.h>
@@ -72,7 +72,7 @@ void cbm_sb_free(cbm_sb_t *sb) {
cbm_sb_init(sb);
}
/* ── TOON quoting ───────────────────────────────────────────────── */
/* ── Tree quoting ────────────────────────────────────────────────── */
/* True when `s` parses as an integer or real literal (sign, digits, one dot). */
static bool looks_numeric(const char *s) {
@@ -99,17 +99,17 @@ static bool looks_numeric(const char *s) {
static bool needs_quotes(const char *s) {
if (!s || !*s) {
return true; /* empty cell must be visible as "" */
}
if (isspace((unsigned char)s[0]) || isspace((unsigned char)s[strlen(s) - 1])) {
return true;
return false; /* empty cells emit as the "-" placeholder, not quotes */
}
for (const char *p = s; *p; p++) {
if (*p == ',' || *p == '"' || *p == '\n' || *p == '\r') {
/* Space-delimited rows: any internal whitespace or quote forces
* quoting so column positions stay parseable. */
if (isspace((unsigned char)*p) || *p == '"' || *p == '\r') {
return true;
}
}
if (strcmp(s, "true") == 0 || strcmp(s, "false") == 0 || strcmp(s, "null") == 0) {
if (strcmp(s, "true") == 0 || strcmp(s, "false") == 0 || strcmp(s, "null") == 0 ||
strcmp(s, "-") == 0) {
return true;
}
return looks_numeric(s);
@@ -140,6 +140,10 @@ static void append_quoted(cbm_sb_t *sb, const char *s) {
}
static void append_value(cbm_sb_t *sb, const char *s) {
if (!s || !*s) {
cbm_sb_append_n(sb, "-", 1); /* stable column positions for empties */
return;
}
if (needs_quotes(s)) {
append_quoted(sb, s);
} else {
@@ -149,14 +153,14 @@ static void append_value(cbm_sb_t *sb, const char *s) {
/* ── Scalars ────────────────────────────────────────────────────── */
void cbm_toon_scalar_str(cbm_sb_t *sb, const char *key, const char *val) {
void cbm_tree_scalar_str(cbm_sb_t *sb, const char *key, const char *val) {
cbm_sb_append(sb, key);
cbm_sb_append_n(sb, ": ", 2);
append_value(sb, val);
cbm_sb_append_n(sb, "\n", 1);
}
void cbm_toon_scalar_int(cbm_sb_t *sb, const char *key, long long v) {
void cbm_tree_scalar_int(cbm_sb_t *sb, const char *key, long long v) {
char num[32];
snprintf(num, sizeof(num), "%lld", v);
cbm_sb_append(sb, key);
@@ -165,7 +169,7 @@ void cbm_toon_scalar_int(cbm_sb_t *sb, const char *key, long long v) {
cbm_sb_append_n(sb, "\n", 1);
}
void cbm_toon_scalar_bool(cbm_sb_t *sb, const char *key, bool v) {
void cbm_tree_scalar_bool(cbm_sb_t *sb, const char *key, bool v) {
cbm_sb_append(sb, key);
cbm_sb_append_n(sb, ": ", 2);
cbm_sb_append(sb, v ? "true" : "false");
@@ -174,57 +178,57 @@ void cbm_toon_scalar_bool(cbm_sb_t *sb, const char *key, bool v) {
/* ── Tables ─────────────────────────────────────────────────────── */
void cbm_toon_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols,
/* Tree-syntax table header: `key: N (cols: a b c)` — count first (agents
* read scale before rows), column names once, rows indented beneath. */
void cbm_tree_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols,
int ncols) {
char num[32];
snprintf(num, sizeof(num), "[%d]{", n);
snprintf(num, sizeof(num), ": %d (cols:", n);
cbm_sb_append(sb, key);
cbm_sb_append(sb, num);
for (int i = 0; i < ncols; i++) {
if (i > 0) {
cbm_sb_append_n(sb, ",", 1);
}
cbm_sb_append_n(sb, " ", 1);
cbm_sb_append(sb, cols[i]);
}
cbm_sb_append_n(sb, "}:\n", 3);
cbm_sb_append_n(sb, ")\n", 2);
}
void cbm_toon_row_begin(cbm_sb_t *sb) {
void cbm_tree_row_begin(cbm_sb_t *sb) {
cbm_sb_append_n(sb, " ", 2);
}
void cbm_toon_cell_str(cbm_sb_t *sb, const char *val, bool first) {
void cbm_tree_cell_str(cbm_sb_t *sb, const char *val, bool first) {
if (!first) {
cbm_sb_append_n(sb, ",", 1);
cbm_sb_append_n(sb, " ", 1);
}
append_value(sb, val ? val : "");
}
void cbm_toon_cell_int(cbm_sb_t *sb, long long v, bool first) {
void cbm_tree_cell_int(cbm_sb_t *sb, long long v, bool first) {
char num[32];
snprintf(num, sizeof(num), "%lld", v);
if (!first) {
cbm_sb_append_n(sb, ",", 1);
cbm_sb_append_n(sb, " ", 1);
}
cbm_sb_append(sb, num);
}
void cbm_toon_cell_real(cbm_sb_t *sb, double v, bool first) {
void cbm_tree_cell_real(cbm_sb_t *sb, double v, bool first) {
char num[48];
snprintf(num, sizeof(num), "%.4g", v);
if (!first) {
cbm_sb_append_n(sb, ",", 1);
cbm_sb_append_n(sb, " ", 1);
}
cbm_sb_append(sb, num);
}
void cbm_toon_cell_bool(cbm_sb_t *sb, bool v, bool first) {
void cbm_tree_cell_bool(cbm_sb_t *sb, bool v, bool first) {
if (!first) {
cbm_sb_append_n(sb, ",", 1);
cbm_sb_append_n(sb, " ", 1);
}
cbm_sb_append(sb, v ? "true" : "false");
}
void cbm_toon_row_end(cbm_sb_t *sb) {
void cbm_tree_row_end(cbm_sb_t *sb) {
cbm_sb_append_n(sb, "\n", 1);
}
+10 -10
View File
@@ -34,21 +34,21 @@ char *cbm_sb_finish(cbm_sb_t *sb);
void cbm_sb_free(cbm_sb_t *sb);
/* `key: value` scalar lines (top-level, no indent). */
void cbm_toon_scalar_str(cbm_sb_t *sb, const char *key, const char *val);
void cbm_toon_scalar_int(cbm_sb_t *sb, const char *key, long long v);
void cbm_toon_scalar_bool(cbm_sb_t *sb, const char *key, bool v);
void cbm_tree_scalar_str(cbm_sb_t *sb, const char *key, const char *val);
void cbm_tree_scalar_int(cbm_sb_t *sb, const char *key, long long v);
void cbm_tree_scalar_bool(cbm_sb_t *sb, const char *key, bool v);
/* `key[n]{col1,col2,...}:` table header; rows follow at 2-space indent. */
void cbm_toon_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols,
void cbm_tree_table_header(cbm_sb_t *sb, const char *key, int n, const char *const *cols,
int ncols);
/* Row cells: call row_begin, then cell_* per column (first=true for the
* first cell), then row_end. Empty/NULL strings emit as empty cells. */
void cbm_toon_row_begin(cbm_sb_t *sb);
void cbm_toon_cell_str(cbm_sb_t *sb, const char *val, bool first);
void cbm_toon_cell_int(cbm_sb_t *sb, long long v, bool first);
void cbm_toon_cell_real(cbm_sb_t *sb, double v, bool first);
void cbm_toon_cell_bool(cbm_sb_t *sb, bool v, bool first);
void cbm_toon_row_end(cbm_sb_t *sb);
void cbm_tree_row_begin(cbm_sb_t *sb);
void cbm_tree_cell_str(cbm_sb_t *sb, const char *val, bool first);
void cbm_tree_cell_int(cbm_sb_t *sb, long long v, bool first);
void cbm_tree_cell_real(cbm_sb_t *sb, double v, bool first);
void cbm_tree_cell_bool(cbm_sb_t *sb, bool v, bool first);
void cbm_tree_row_end(cbm_sb_t *sb);
#endif /* CBM_MCP_COMPACT_OUT_H */
+2092 -603
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -6,9 +6,12 @@
/* White-box fault injection for deterministic cross-platform quarantine
* safety tests. This header is internal and is not part of the MCP API. */
typedef bool (*cbm_mcp_quarantine_test_hook_fn)(void *context, const char *step);
typedef bool (*cbm_mcp_command_test_hook_fn)(void *context, const char *command);
void cbm_mcp_server_set_quarantine_test_hook(cbm_mcp_server_t *srv,
cbm_mcp_quarantine_test_hook_fn hook, void *context);
void cbm_mcp_server_set_command_test_hook(cbm_mcp_server_t *srv, cbm_mcp_command_test_hook_fn hook,
void *context);
/* Release only the constructor-created pristine in-memory store. Public
* cbm_mcp_server_new(NULL) semantics remain unchanged; daemon sessions use
+277 -15
View File
@@ -1327,6 +1327,51 @@ int cbm_store_upsert_project(cbm_store_t *s, const char *name, const char *root_
store_set_error_sqlite(s, "upsert_project");
return CBM_STORE_ERR;
}
/* Store generation for cursor staleness (pagination): db_uid is a random
* identity minted once per DB FILE (a full reindex publishes a fresh file
* via the writer, which carries no store_meta — the first upsert_project
* on the opened store seeds a NEW uid, so cursors minted against the old
* file can never validate against the rebuilt one, whose node ids all
* differ). mutation_gen increments on every project upsert — the choke
* point every index run (full, incremental, watcher) passes through.
* Created here rather than in the byte-level writer: adding a table to
* its hand-built sqlite_master is rootpage surgery for zero benefit. */
(void)sqlite3_exec(s->db,
"CREATE TABLE IF NOT EXISTS store_meta (k TEXT PRIMARY KEY, v TEXT);"
"INSERT OR IGNORE INTO store_meta VALUES"
"('db_uid', lower(hex(randomblob(8))));"
"INSERT OR IGNORE INTO store_meta VALUES('mutation_gen','0');"
"UPDATE store_meta SET v = CAST(CAST(v AS INTEGER)+1 AS TEXT) "
"WHERE k='mutation_gen';",
NULL, NULL, NULL);
return CBM_STORE_OK;
}
/* Opaque store generation for pagination cursors: "u<db_uid>g<mutation_gen>",
* or "legacy" when the DB predates store_meta (read-only opens never create
* it). A cursor whose embedded generation mismatches the store's current one
* is stale — the graph may have changed under it. */
int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz) {
if (!s || !s->db || !buf || bufsz == 0) {
return CBM_STORE_ERR;
}
snprintf(buf, bufsz, "legacy");
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT (SELECT v FROM store_meta WHERE k='db_uid'),"
" (SELECT v FROM store_meta WHERE k='mutation_gen');",
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
return CBM_STORE_OK; /* pre-migration DB: stable "legacy" generation */
}
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *uid = (const char *)sqlite3_column_text(stmt, 0);
const char *gen = (const char *)sqlite3_column_text(stmt, SKIP_ONE);
if (uid && gen) {
snprintf(buf, bufsz, "u%sg%s", uid, gen);
}
}
sqlite3_finalize(stmt);
return CBM_STORE_OK;
}
@@ -1902,6 +1947,76 @@ static void bind_proj_and_type(sqlite3_stmt *stmt, const void *data) {
bind_text(stmt, ST_COL_2, b->type);
}
int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges,
int64_t **out_src, int64_t **out_tgt, int *count, bool *truncated) {
if (out_src) {
*out_src = NULL;
}
if (out_tgt) {
*out_tgt = NULL;
}
if (count) {
*count = 0;
}
if (truncated) {
*truncated = false;
}
if (!s || !s->db || !project || !out_src || !out_tgt || !count) {
return CBM_STORE_ERR;
}
/* CALLS edges whose BOTH endpoints are callable defs — the call graph the
* SCC pass condenses. Ordered for determinism. LIMIT max_edges + 1 so a
* full result is distinguishable from a truncated one. */
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT e.source_id, e.target_id FROM edges e "
"JOIN nodes ns ON ns.id = e.source_id "
"JOIN nodes nt ON nt.id = e.target_id "
"WHERE e.project = ?1 AND e.type = 'CALLS' "
" AND ns.label IN ('Function','Method') "
" AND nt.label IN ('Function','Method') "
"ORDER BY e.source_id, e.target_id LIMIT ?2;",
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "fetch_call_edges prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
sqlite3_bind_int(stmt, 2, max_edges > 0 ? max_edges + SKIP_ONE : CBM_NOT_FOUND);
int cap = ST_INIT_CAP_16;
int n = 0;
int64_t *src = malloc((size_t)cap * sizeof(int64_t));
int64_t *tgt = malloc((size_t)cap * sizeof(int64_t));
int scan_rc;
while ((scan_rc = sqlite3_step(stmt)) == SQLITE_ROW) {
if (max_edges > 0 && n >= max_edges) {
if (truncated) {
*truncated = true; /* the sentinel row proves there are more */
}
break;
}
if (n >= cap) {
cap *= ST_GROWTH;
src = safe_realloc(src, (size_t)cap * sizeof(int64_t));
tgt = safe_realloc(tgt, (size_t)cap * sizeof(int64_t));
}
src[n] = sqlite3_column_int64(stmt, 0);
tgt[n] = sqlite3_column_int64(stmt, 1);
n++;
}
sqlite3_finalize(stmt);
if (scan_rc != SQLITE_DONE && scan_rc != SQLITE_ROW) {
free(src);
free(tgt);
store_set_error_sqlite(s, "fetch_call_edges scan");
return CBM_STORE_ERR;
}
*out_src = src;
*out_tgt = tgt;
*count = n;
return CBM_STORE_OK;
}
int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out,
int *count) {
bind_id_t b = {source_id};
@@ -3575,9 +3690,12 @@ int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_sear
int limit = params->limit > 0 ? params->limit : CBM_DEFAULT_SEARCH_LIMIT;
int offset = params->offset;
const char *name_col = has_degree_filter ? "name" : "n.name";
const char *id_col = has_degree_filter ? "id" : "n.id";
char order_limit[CBM_SZ_128];
snprintf(order_limit, sizeof(order_limit), " ORDER BY %s LIMIT %d OFFSET %d", name_col, limit,
offset);
/* (name, id) is a unique total order — names are non-unique, and without
* the tie-break offset pages are not contractually stable across calls. */
snprintf(order_limit, sizeof(order_limit), " ORDER BY %s, %s LIMIT %d OFFSET %d", name_col,
id_col, limit, offset);
strncat(sql, order_limit, sizeof(sql) - strlen(sql) - 1);
/* Execute count query */
@@ -3666,19 +3784,34 @@ static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_ho
int visited_count, const char *types_clause, const char **edge_types,
int edge_type_count, cbm_edge_info_t **out_edges,
int *out_edge_count) {
/* Build ID set: root + all visited */
char id_set[CBM_SZ_4K];
int ilen = snprintf(id_set, sizeof(id_set), "%lld", (long long)start_id);
if (ilen >= (int)sizeof(id_set)) {
ilen = (int)sizeof(id_set) - SKIP_ONE;
*out_edges = NULL;
*out_edge_count = 0;
/* Visited-ID set via a per-connection TEMP table. The previous approach
* interpolated the ids into a fixed 4KB SQL string: past ~1000 visited
* nodes the list was cut MID-NUMBER, the SQL failed to prepare, and every
* trace edge silently vanished (and a luckier cut could match an
* unrelated node). TEMP tables live in the connection's temp db, so this
* works on read-only query connections too. */
if (sqlite3_exec(s->db,
"CREATE TEMP TABLE IF NOT EXISTS bfs_ids (id INTEGER PRIMARY KEY);"
"DELETE FROM bfs_ids;",
NULL, NULL, NULL) != SQLITE_OK) {
return CBM_STORE_OK; /* best-effort: nodes without edges beat an error */
}
sqlite3_stmt *ins = NULL;
if (sqlite3_prepare_v2(s->db, "INSERT OR IGNORE INTO bfs_ids(id) VALUES (?1)", CBM_NOT_FOUND,
&ins, NULL) != SQLITE_OK) {
return CBM_STORE_OK;
}
sqlite3_bind_int64(ins, SKIP_ONE, start_id);
(void)sqlite3_step(ins);
for (int i = 0; i < visited_count; i++) {
ilen += snprintf(id_set + ilen, sizeof(id_set) - (size_t)ilen, ",%lld",
(long long)visited[i].node.id);
if (ilen >= (int)sizeof(id_set)) {
ilen = (int)sizeof(id_set) - SKIP_ONE;
}
sqlite3_reset(ins);
sqlite3_bind_int64(ins, SKIP_ONE, visited[i].node.id);
(void)sqlite3_step(ins);
}
sqlite3_finalize(ins);
char edge_sql[ST_SQL_BUF];
snprintf(edge_sql, sizeof(edge_sql),
@@ -3686,9 +3819,10 @@ static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_ho
"FROM edges e "
"JOIN nodes n1 ON n1.id = e.source_id "
"JOIN nodes n2 ON n2.id = e.target_id "
"WHERE e.source_id IN (%s) AND e.target_id IN (%s) "
"WHERE e.source_id IN (SELECT id FROM bfs_ids) "
"AND e.target_id IN (SELECT id FROM bfs_ids) "
"AND e.type IN (%s)",
id_set, id_set, types_clause);
types_clause);
sqlite3_stmt *estmt = NULL;
int rc = sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &estmt, NULL);
@@ -3809,7 +3943,9 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
"JOIN nodes n ON n.id = bfs.node_id "
"WHERE bfs.hop > 0 " /* exclude root at hop 0 (self via a loop still appears) */
"GROUP BY n.id "
"ORDER BY hop "
/* (hop, id) is a unique total order — deterministic pagination
* watermarks and reproducible trace output depend on it. */
"ORDER BY hop, n.id "
"LIMIT %d;",
(long long)start_id, next_id, join_cond, types_clause, max_depth, max_results);
@@ -3868,6 +4004,132 @@ int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const
return CBM_STORE_OK;
}
/* Multi-source BFS: one recursive CTE anchored on ALL seeds (via a temp
* table — never a per-seed loop, which re-walks overlapping hub subgraphs
* seed_count times). Semantics for impact analysis:
* - shortest-path MIN(hop) across the whole seed set;
* - SEEDS ARE EXCLUDED from the result: a seed reached from another seed
* (changed files call each other constantly) is not "impact";
* - uncapped counting up to max_results as a memory-safety ceiling only —
* *truncated reports when it was hit (never a silent cap);
* - canonical (hop, id) order.
* No root node and no edge collection — impact wants the reached set. */
int cbm_store_bfs_multi(cbm_store_t *s, const int64_t *seed_ids, int seed_count,
const char *direction, const char **edge_types, int edge_type_count,
int max_depth, int max_results, cbm_traverse_result_t *out,
bool *truncated) {
memset(out, 0, sizeof(*out));
if (truncated) {
*truncated = false;
}
if (!s || !s->db || !seed_ids || seed_count <= 0) {
return CBM_STORE_ERR;
}
if (sqlite3_exec(s->db,
"CREATE TEMP TABLE IF NOT EXISTS bfs_seeds (id INTEGER PRIMARY KEY);"
"DELETE FROM bfs_seeds;",
NULL, NULL, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "bfs_multi seeds");
return CBM_STORE_ERR;
}
sqlite3_stmt *ins = NULL;
if (sqlite3_prepare_v2(s->db, "INSERT OR IGNORE INTO bfs_seeds(id) VALUES (?1)", CBM_NOT_FOUND,
&ins, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "bfs_multi seed insert");
return CBM_STORE_ERR;
}
for (int i = 0; i < seed_count; i++) {
sqlite3_reset(ins);
sqlite3_bind_int64(ins, SKIP_ONE, seed_ids[i]);
(void)sqlite3_step(ins);
}
sqlite3_finalize(ins);
char types_clause[CBM_SZ_512];
bfs_build_types_clause(edge_type_count, types_clause, (int)sizeof(types_clause));
const char *join_cond;
const char *next_id;
bool is_inbound = (direction != NULL) && (strcmp(direction, "inbound") == 0);
if (is_inbound) {
join_cond = "e.target_id = bfs.node_id";
next_id = "e.source_id";
} else {
join_cond = "e.source_id = bfs.node_id";
next_id = "e.target_id";
}
char sql[CBM_SZ_4K];
snprintf(sql, sizeof(sql),
"WITH RECURSIVE bfs(node_id, hop) AS ("
" SELECT id, 0 FROM bfs_seeds"
" UNION"
" SELECT %s, bfs.hop + 1"
" FROM bfs"
" JOIN edges e ON %s"
" WHERE e.type IN (%s) AND bfs.hop < %d"
")"
"SELECT n.id, n.project, n.label, n.name, n.qualified_name, "
"n.file_path, n.start_line, n.end_line, n.properties, MIN(bfs.hop) AS hop "
"FROM bfs "
"JOIN nodes n ON n.id = bfs.node_id "
/* hop > 0 excludes the anchors themselves; the NOT IN excludes a
* seed REACHED from another seed at hop > 0 (leak-back). */
"WHERE bfs.hop > 0 AND bfs.node_id NOT IN (SELECT id FROM bfs_seeds) "
"GROUP BY n.id "
"ORDER BY hop, n.id "
"LIMIT %d;",
next_id, join_cond, types_clause, max_depth, max_results + SKIP_ONE);
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "bfs_multi prepare");
return CBM_STORE_ERR;
}
if (edge_type_count > 0) {
for (int i = 0; i < edge_type_count; i++) {
bind_text(stmt, i + SKIP_ONE, edge_types[i]);
}
} else {
bind_text(stmt, SKIP_ONE, "CALLS");
}
int cap = ST_INIT_CAP_16;
int n = 0;
cbm_node_hop_t *visited = malloc(cap * sizeof(cbm_node_hop_t));
int scan_rc16;
while ((scan_rc16 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n > max_results) {
break; /* ceiling + 1 row fetched: report truncation below */
}
if (n >= cap) {
cap *= ST_GROWTH;
visited = safe_realloc(visited, cap * sizeof(cbm_node_hop_t));
}
scan_node(stmt, &visited[n].node);
visited[n].hop = sqlite3_column_int(stmt, ST_COL_9);
n++;
}
bool aborted = (scan_rc16 != SQLITE_DONE && scan_rc16 != SQLITE_ROW);
if (n > max_results) {
/* Free the sentinel row and flag the ceiling. */
n = max_results;
cbm_node_free_fields(&visited[n].node);
if (truncated) {
*truncated = true;
}
}
sqlite3_finalize(stmt);
out->visited = visited;
out->visited_count = n;
if (aborted) {
store_set_error_sqlite(s, "bfs_multi row scan aborted");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
void cbm_store_traverse_free(cbm_traverse_result_t *out) {
if (!out) {
return;
+21
View File
@@ -283,6 +283,11 @@ int cbm_store_checkpoint(cbm_store_t *s);
* connection, in bytes; -1 = unlimited (SQLite default / pre-fix). */
int64_t cbm_store_journal_size_limit(cbm_store_t *s);
/* Opaque store generation for pagination-cursor staleness detection:
* "u<db_uid>g<mutation_gen>" — db_uid is minted per DB file, mutation_gen
* bumps on every index run. "legacy" for DBs predating store_meta. */
int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz);
/* Resolve the mmap_size pragma value applied to on-disk stores from the
* CBM_SQLITE_MMAP_SIZE environment variable. Defaults to 67108864 (64 MB)
* when the variable is unset, malformed, or partially numeric. Negative
@@ -380,6 +385,13 @@ int64_t cbm_store_insert_edge(cbm_store_t *s, const cbm_edge_t *e);
/* Insert edges in batch. */
int cbm_store_insert_edge_batch(cbm_store_t *s, const cbm_edge_t *edges, int count);
/* Fetch all CALLS edges among Function/Method nodes for a project as parallel
* (source_id, target_id) arrays (caller frees both). For SCC / cycle analysis.
* Stops at max_edges and sets *truncated — never a silent cap. Returns
* CBM_STORE_OK (or _ERR); *count is the number returned. */
int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges,
int64_t **out_src, int64_t **out_tgt, int *count, bool *truncated);
/* Find edges by source node. */
int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out, int *count);
@@ -512,6 +524,15 @@ void cbm_store_search_free(cbm_search_output_t *out);
int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types,
int edge_type_count, int max_depth, int max_results, cbm_traverse_result_t *out);
/* Multi-source BFS from ALL seed ids at once (one CTE, temp-table anchored).
* Seeds are EXCLUDED from the result (impact semantics); MIN(hop) across the
* seed set; canonical (hop,id) order; *truncated set when the max_results
* memory-safety ceiling was hit (counting is otherwise uncapped). */
int cbm_store_bfs_multi(cbm_store_t *s, const int64_t *seed_ids, int seed_count,
const char *direction, const char **edge_types, int edge_type_count,
int max_depth, int max_results, cbm_traverse_result_t *out,
bool *truncated);
/* Free a traverse result's allocated memory. */
void cbm_store_traverse_free(cbm_traverse_result_t *out);
+44
View File
@@ -19,6 +19,7 @@
#include <daemon/runtime.h>
#include <daemon/version_cohort.h>
#include <foundation/platform.h>
#include <mcp/mcp.h>
#include <foundation/yaml.h>
#include <store/store.h>
#include <yyjson/yyjson.h>
@@ -8550,6 +8551,48 @@ TEST(cli_codex_migrates_to_single_hook_representation) {
PASS();
}
/* The PreToolUse augmenter parses search_graph's format:"json" payload to
* build additionalContext. This test feeds it the REAL envelope from a live
* in-memory server, so any drift between the response shape and the parser
* fails HERE — not only in the Windows CI guard (which caught exactly that:
* the json-tree reshape left the parser reading a key that no longer exists,
* and the hook silently emitted nothing). */
TEST(cli_hook_augment_context_tracks_search_json_shape) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "hookproj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/hookproj");
cbm_node_t n = {.project = proj,
.label = "Function",
.name = "someIndexedSymbol",
.qualified_name = "hookproj.mod.someIndexedSymbol",
.file_path = "mod.py",
.start_line = 1,
.end_line = 4};
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
/* The exact request ha_build_args produces: format:"json". */
char *envelope =
cbm_mcp_handle_tool(srv, "search_graph",
"{\"project\":\"hookproj\",\"name_pattern\":\".*someIndexedSymbol.*\","
"\"limit\":5,\"format\":\"json\"}");
ASSERT_NOT_NULL(envelope);
bool is_error = true;
char *ctx =
cbm_hook_augment_format_context_for_testing(envelope, "someIndexedSymbol", &is_error);
ASSERT_FALSE(is_error);
ASSERT_NOT_NULL(ctx); /* one hit MUST produce context — empty = broken hook */
ASSERT_NOT_NULL(strstr(ctx, "someIndexedSymbol"));
ASSERT_NOT_NULL(strstr(ctx, "mod.py"));
free(ctx);
free(envelope);
cbm_mcp_server_free(srv);
PASS();
}
TEST(cli_hook_augment_lifecycle_output_contract) {
static const struct {
const char *event;
@@ -11678,6 +11721,7 @@ SUITE(cli) {
RUN_TEST(cli_claude_hook_scripts_shell_quote_binary_path);
RUN_TEST(cli_claude_hook_commands_shell_quote_custom_config_dir);
RUN_TEST(cli_codex_migrates_to_single_hook_representation);
RUN_TEST(cli_hook_augment_context_tracks_search_json_shape);
RUN_TEST(cli_hook_augment_lifecycle_output_contract);
RUN_TEST(cli_hook_augment_subagent_tier_router_contract);
RUN_TEST(cli_hook_augment_subagent_no_project_guidance_is_read_only);
+30 -5
View File
@@ -1860,11 +1860,35 @@ TEST(tool_detect_changes_default) {
double ms;
char *r = call_tool_timed("detect_changes", &ms, "{\"project\":\"%s\"}", g_project);
TOOL_OK(r, ms);
/* Must have changed_files array and changed_count */
ASSERT(resp_has_key(r, "changed_files"));
ASSERT(resp_has_key(r, "changed_count"));
ASSERT(resp_has_key(r, "impacted_symbols"));
ASSERT(resp_has_key(r, "depth"));
/* New tree contract: base + direction scalars, a changed_files section,
* and the seed/impact accounting. (The old changed_count/impacted_symbols/
* depth keys are gone — impact is now a real traversal, not bare names.) */
ASSERT(strstr(r, "changed_files:") != NULL);
ASSERT(strstr(r, "direction:") != NULL);
ASSERT(strstr(r, "seed_symbols:") != NULL);
free(r);
PASS();
}
/* The blast radius is a REAL traversal now: default inbound gives transitive
* callers of the changed symbols with an exact impacted_total and a module
* rollup. Fixture diff may be empty (shallow clone at HEAD) — the sections and
* their accounting must still be present and internally consistent. */
TEST(tool_detect_changes_impact_shape) {
double ms;
char *r = call_tool_timed("detect_changes", &ms,
"{\"project\":\"%s\",\"base_branch\":\"HEAD\"}", g_project);
TOOL_OK(r, ms);
ASSERT(strstr(r, "direction: inbound") != NULL); /* default = blast radius */
ASSERT(strstr(r, "seed_symbols:") != NULL);
/* format:"json" returns the same model as structured JSON. */
free(r);
r = call_tool_timed("detect_changes", &ms,
"{\"project\":\"%s\",\"base_branch\":\"HEAD\",\"format\":\"json\"}",
g_project);
TOOL_OK(r, ms);
ASSERT(resp_has_key(r, "impacted_total"));
ASSERT(resp_has_key(r, "direction"));
free(r);
PASS();
}
@@ -3100,6 +3124,7 @@ SUITE(incremental) {
/* Phase 15: detect_changes */
RUN_TEST(tool_detect_changes_default);
RUN_TEST(tool_detect_changes_impact_shape);
RUN_TEST(tool_detect_changes_custom_branch);
RUN_TEST(tool_detect_changes_since);
RUN_TEST(tool_detect_changes_since_precedence);
+6 -2
View File
@@ -359,7 +359,9 @@ TEST(index_parse_partial_reported) {
ASSERT_TRUE(found_split);
const char *note = yyjson_get_str(yyjson_obj_get(pp, "note"));
ASSERT_NOT_NULL(note);
ASSERT_NOT_NULL(strstr(note, "guarantee"));
/* Lean-note contract: best-effort framing + pointer to the full record. */
ASSERT_NOT_NULL(strstr(note, "best-effort"));
ASSERT_NOT_NULL(strstr(note, "logfile"));
/* Logfile lists it under the distinct phase. */
const char *logfile = yyjson_get_str(yyjson_obj_get(sc, "logfile"));
@@ -591,7 +593,9 @@ TEST(index_not_indexed_by_design_reported) {
ASSERT_TRUE(found_secret);
const char *note = yyjson_get_str(yyjson_obj_get(ni, "note"));
ASSERT_NOT_NULL(note);
ASSERT_NOT_NULL(strstr(note, "BY DESIGN"));
/* Lean-note contract: deliberate-exclusion framing + pointer to the record. */
ASSERT_NOT_NULL(strstr(note, "by design"));
ASSERT_NOT_NULL(strstr(note, "logfile"));
/* The gitignored DIR is in the excluded subtrees list. */
yyjson_val *excluded = yyjson_obj_get(sc, "excluded");
+175
View File
@@ -579,6 +579,178 @@ TEST(integ_store_bfs_traversal) {
PASS();
}
/* bfs_collect_edges built its visited-ID set into a fixed 4KB string: past
* ~340-1100 visited nodes (id-width dependent) the id list was SILENTLY cut,
* so trace edges (and data_flow args) vanished — and a partially-written id
* could even match an UNRELATED node, admitting wrong edges. GUARD: a star of
* 1200 callers (id string ≈ 4.6KB) must surface every edge. RED on the fixed
* buffer, GREEN with the temp-table join. */
TEST(store_bfs_edges_survive_large_visited_set) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "star", "/tmp/star"), CBM_STORE_OK);
cbm_node_t hub = {0};
hub.project = "star";
hub.label = "Function";
hub.name = "hub";
hub.qualified_name = "star.hub";
hub.file_path = "hub.c";
int64_t hub_id = cbm_store_upsert_node(s, &hub);
ASSERT_GT(hub_id, 0);
enum { SPOKES = 1200 };
for (int i = 0; i < SPOKES; i++) {
char nm[32];
char qn[64];
snprintf(nm, sizeof(nm), "caller_%04d", i);
snprintf(qn, sizeof(qn), "star.caller_%04d", i);
cbm_node_t sp = {0};
sp.project = "star";
sp.label = "Function";
sp.name = nm;
sp.qualified_name = qn;
sp.file_path = "spokes.c";
int64_t sid = cbm_store_upsert_node(s, &sp);
ASSERT_GT(sid, 0);
cbm_edge_t e = {0};
e.project = "star";
e.source_id = sid;
e.target_id = hub_id;
e.type = "CALLS";
ASSERT_GT(cbm_store_insert_edge(s, &e), 0); /* returns the edge id */
}
cbm_traverse_result_t tr = {0};
ASSERT_EQ(cbm_store_bfs(s, hub_id, "inbound", NULL, 0, 1, SPOKES + 10, &tr), CBM_STORE_OK);
ASSERT_EQ(tr.visited_count, SPOKES);
/* Every caller->hub edge must be collected — none silently dropped. */
ASSERT_EQ(tr.edge_count, SPOKES);
cbm_store_traverse_free(&tr);
cbm_store_close(s);
PASS();
}
/* Multi-source BFS is the substrate for detect_changes impact analysis. Its
* contract (challenger's flagged traps): (1) ONE traversal over ALL seeds,
* not seed_count separate walks; (2) seeds EXCLUDED from the result even when
* reachable from another seed (changed files call each other — that is not
* "downstream impact"); (3) MIN(hop) across the whole seed set; (4) uncapped
* counting up to the memory ceiling, which sets *truncated when hit. Fixture:
* two seeds A, B; A->mid->leaf, B->leaf (leaf is hop 1 from B, hop 2 from A),
* and A->B directly (B reachable from A). Impact set must be {mid, leaf} with
* leaf at hop 1, and must NOT contain A or B. */
TEST(store_bfs_multi_excludes_seeds_and_takes_min_hop) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "impact", "/tmp/impact"), CBM_STORE_OK);
int64_t ids[4];
const char *names[4] = {"A", "B", "mid", "leaf"};
for (int i = 0; i < 4; i++) {
char qn[32];
snprintf(qn, sizeof(qn), "impact.%s", names[i]);
cbm_node_t n = {.project = "impact",
.label = "Function",
.name = names[i],
.qualified_name = qn,
.file_path = "m.c",
.start_line = 1,
.end_line = 5};
ids[i] = cbm_store_upsert_node(s, &n);
ASSERT_GT(ids[i], 0);
}
int64_t A = ids[0];
int64_t B = ids[1];
int64_t mid = ids[2];
int64_t leaf = ids[3];
struct {
int64_t from;
int64_t to;
} edges[] = {{A, mid}, {mid, leaf}, {B, leaf}, {A, B}};
for (size_t i = 0; i < sizeof(edges) / sizeof(edges[0]); i++) {
cbm_edge_t e = {.project = "impact",
.source_id = edges[i].from,
.target_id = edges[i].to,
.type = "CALLS"};
ASSERT_GT(cbm_store_insert_edge(s, &e), 0);
}
int64_t seeds[2] = {A, B};
cbm_traverse_result_t tr = {0};
bool truncated = true;
ASSERT_EQ(cbm_store_bfs_multi(s, seeds, 2, "outbound", NULL, 0, 5, 100, &tr, &truncated),
CBM_STORE_OK);
ASSERT_FALSE(truncated);
/* Impact set = {mid, leaf}; A and B (seeds) excluded even though B is
* reachable from A. */
ASSERT_EQ(tr.visited_count, 2);
int seen_mid = 0;
int seen_leaf = 0;
int leaf_hop = -1;
for (int i = 0; i < tr.visited_count; i++) {
int64_t id = tr.visited[i].node.id;
ASSERT_TRUE(id != A && id != B); /* seeds never in the result */
if (id == mid) {
seen_mid = 1;
}
if (id == leaf) {
seen_leaf = 1;
leaf_hop = tr.visited[i].hop;
}
}
ASSERT_TRUE(seen_mid && seen_leaf);
ASSERT_EQ(leaf_hop, 1); /* MIN(hop): 1 from B, not 2 from A */
cbm_store_traverse_free(&tr);
cbm_store_close(s);
PASS();
}
/* The memory-safety ceiling reports truncation instead of silently capping.
* A star of N callees from one seed, ceiling = N/2, must return exactly N/2
* rows with *truncated = true. */
TEST(store_bfs_multi_reports_truncation_at_ceiling) {
cbm_store_t *s = cbm_store_open_memory();
ASSERT_NOT_NULL(s);
ASSERT_EQ(cbm_store_upsert_project(s, "cap", "/tmp/cap"), CBM_STORE_OK);
cbm_node_t hub = {.project = "cap",
.label = "Function",
.name = "hub",
.qualified_name = "cap.hub",
.file_path = "h.c",
.start_line = 1,
.end_line = 2};
int64_t hub_id = cbm_store_upsert_node(s, &hub);
ASSERT_GT(hub_id, 0);
enum { N = 40, CEIL = 20 };
for (int i = 0; i < N; i++) {
char qn[32];
snprintf(qn, sizeof(qn), "cap.c%02d", i);
cbm_node_t n = {.project = "cap",
.label = "Function",
.name = qn + 4,
.qualified_name = qn,
.file_path = "c.c",
.start_line = 1,
.end_line = 2};
int64_t nid = cbm_store_upsert_node(s, &n);
ASSERT_GT(nid, 0);
cbm_edge_t e = {.project = "cap", .source_id = hub_id, .target_id = nid, .type = "CALLS"};
ASSERT_GT(cbm_store_insert_edge(s, &e), 0);
}
cbm_traverse_result_t tr = {0};
bool truncated = false;
ASSERT_EQ(cbm_store_bfs_multi(s, &hub_id, 1, "outbound", NULL, 0, 5, CEIL, &tr, &truncated),
CBM_STORE_OK);
ASSERT_EQ(tr.visited_count, CEIL);
ASSERT_TRUE(truncated);
cbm_store_traverse_free(&tr);
cbm_store_close(s);
PASS();
}
/* #411: index_repository silently drops entire subtrees with no record.
* Moderate/fast mode applies FAST_SKIP_DIRS (tools/scripts/bin/docs/...) and ALL
* modes apply ALWAYS_SKIP_DIRS (node_modules/...), so files are excluded from the
@@ -666,6 +838,9 @@ SUITE(integration) {
RUN_TEST(integ_store_search_by_degree);
RUN_TEST(integ_store_find_by_file);
RUN_TEST(integ_store_bfs_traversal);
RUN_TEST(store_bfs_edges_survive_large_visited_set);
RUN_TEST(store_bfs_multi_excludes_seeds_and_takes_min_hop);
RUN_TEST(store_bfs_multi_reports_truncation_at_ceiling);
/* Pipeline API tests (no db needed) */
RUN_TEST(integ_pipeline_fqn_compute);
+753 -21
View File
@@ -151,6 +151,12 @@ typedef struct {
char steps[4][64];
} mcp_quarantine_hook_probe_t;
typedef struct {
bool reject_merge_base;
int diff_calls;
int merge_base_calls;
} mcp_command_hook_probe_t;
static bool mcp_quarantine_hook_probe(void *context, const char *step) {
mcp_quarantine_hook_probe_t *probe = context;
if (!probe || !step) {
@@ -163,6 +169,19 @@ static bool mcp_quarantine_hook_probe(void *context, const char *step) {
return !probe->deny_step || strcmp(probe->deny_step, step) != 0;
}
static bool mcp_command_hook_probe(void *context, const char *command) {
mcp_command_hook_probe_t *probe = context;
if (!probe || !command) {
return false;
}
if (strstr(command, "merge-base")) {
probe->merge_base_calls++;
return !probe->reject_merge_base;
}
probe->diff_calls++;
return true;
}
static bool mcp_mutation_guard_probe_begin(void *context, const char *project) {
mcp_mutation_guard_probe_t *probe = context;
if (!probe) {
@@ -371,6 +390,23 @@ static int mcp_count_corrupt_artifacts(const char *cache, const char *project) {
return count;
}
static int mcp_count_directory_entries_with_prefix(const char *directory, const char *prefix) {
cbm_dir_t *dir = cbm_opendir(directory);
if (!dir) {
return -1;
}
size_t prefix_length = strlen(prefix);
int count = 0;
cbm_dirent_t *entry;
while ((entry = cbm_readdir(dir)) != NULL) {
if (strncmp(entry->name, prefix, prefix_length) == 0) {
count++;
}
}
cbm_closedir(dir);
return count;
}
static void mcp_cleanup_corrupt_backups(const char *cache, const char *project) {
char prefix[CBM_DIRENT_NAME_MAX];
snprintf(prefix, sizeof(prefix), "%s.db.corrupt", project);
@@ -834,7 +870,7 @@ TEST(mcp_get_architecture_aspects_schema_enum_pr560) {
static const char *expected[] = {"all", "overview", "structure", "dependencies",
"routes", "languages", "packages", "entry_points",
"hotspots", "boundaries", "layers", "file_tree",
"clusters"};
"clusters", "cycles"};
size_t expected_count = sizeof(expected) / sizeof(expected[0]);
ASSERT_EQ(yyjson_arr_size(enum_arr), expected_count);
for (size_t i = 0; i < expected_count; i++) {
@@ -1392,6 +1428,203 @@ static cbm_mcp_server_t *setup_snippet_server(char *tmp_dir, size_t tmp_sz);
static void cleanup_snippet_dir(const char *tmp_dir);
static char *extract_text_content(const char *mcp_result);
/* callers_total/callees_total must count what the caller can enumerate: with
* include_tests=false (default) test-file rows are hidden from the table, so
* the totals must apply the same filter — a raw visited_count overstated the
* set (field-eval agent read callers_total=175 against 2 visible rows and
* distrusted the tool). */
TEST(tool_trace_totals_respect_test_filter) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "totproj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/tot");
cbm_node_t tgt = {.project = proj,
.label = "Function",
.name = "tgt",
.qualified_name = "totproj.a.tgt",
.file_path = "a.c",
.start_line = 1,
.end_line = 5};
int64_t tid = cbm_store_upsert_node(st, &tgt);
ASSERT_GT(tid, 0);
cbm_node_t prod = {.project = proj,
.label = "Function",
.name = "prod_caller",
.qualified_name = "totproj.a.prod_caller",
.file_path = "a.c",
.start_line = 10,
.end_line = 15};
int64_t pid = cbm_store_upsert_node(st, &prod);
ASSERT_GT(pid, 0);
cbm_node_t tst = {.project = proj,
.label = "Function",
.name = "test_caller",
.qualified_name = "totproj.t.test_caller",
.file_path = "tests/test_x.c",
.start_line = 1,
.end_line = 5};
int64_t xid = cbm_store_upsert_node(st, &tst);
ASSERT_GT(xid, 0);
cbm_edge_t e1 = {.project = proj, .source_id = pid, .target_id = tid, .type = "CALLS"};
ASSERT_GT(cbm_store_insert_edge(st, &e1), 0);
cbm_edge_t e2 = {.project = proj, .source_id = xid, .target_id = tid, .type = "CALLS"};
ASSERT_GT(cbm_store_insert_edge(st, &e2), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":90,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"totproj\","
"\"function_name\":\"tgt\",\"direction\":\"inbound\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
free(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "callers_total: 1")); /* test row filtered */
free(inner);
resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"totproj\","
"\"function_name\":\"tgt\",\"direction\":\"inbound\",\"include_tests\":true}}}");
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
free(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "callers_total: 2")); /* both visible now */
free(inner);
cbm_mcp_server_free(srv);
PASS();
}
/* SCC condensation (get_architecture aspect "cycles"): a 3-function CALLS
* cycle A->B->C->A must be reported as one circular dependency of size 3 with
* all three members; a separate acyclic chain (D->E) must NOT appear. The
* aspect is opt-in — a default get_architecture call must NOT compute it. */
TEST(tool_get_architecture_cycles_detects_scc) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "cycproj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/cyc");
const char *names[5] = {"A", "B", "C", "D", "E"};
int64_t id[5];
for (int i = 0; i < 5; i++) {
char qn[32];
snprintf(qn, sizeof(qn), "cycproj.m.%s", names[i]);
cbm_node_t n = {.project = proj,
.label = "Function",
.name = names[i],
.qualified_name = qn,
.file_path = "m.c",
.start_line = i + 1,
.end_line = i + 2};
id[i] = cbm_store_upsert_node(st, &n);
ASSERT_GT(id[i], 0);
}
/* cycle A->B->C->A, plus acyclic D->E */
struct {
int f;
int t;
} e[] = {{0, 1}, {1, 2}, {2, 0}, {3, 4}};
for (size_t i = 0; i < sizeof(e) / sizeof(e[0]); i++) {
cbm_edge_t ed = {
.project = proj, .source_id = id[e[i].f], .target_id = id[e[i].t], .type = "CALLS"};
ASSERT_GT(cbm_store_insert_edge(st, &ed), 0);
}
/* opt-in cycles aspect */
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"get_architecture\",\"arguments\":{\"project\":\"cycproj\","
"\"aspects\":[\"cycles\"]}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "cycles: 1")); /* exactly one SCC of size>1 */
ASSERT_NOT_NULL(strstr(inner, "cycproj.m.A"));
ASSERT_NOT_NULL(strstr(inner, "cycproj.m.B"));
ASSERT_NOT_NULL(strstr(inner, "cycproj.m.C"));
ASSERT_NULL(strstr(inner, "cycproj.m.D")); /* acyclic node not in any cycle */
free(inner);
free(resp);
/* default call (no aspects) must NOT run the scan. */
resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"get_architecture\",\"arguments\":{\"project\":\"cycproj\"}}}");
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NULL(strstr(inner, "cycles:"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Context-bomb guard: get_code_snippet on a whole-file node (a Module/File
* span) used to read the ENTIRE file into one response — a field-eval agent
* that fell back to a Module snippet pulled ~400KB in a single call. The read
* must clip at MCP_SNIPPET_MAX_LINES and flag source_clipped, while the exact
* start/end range stays in the response for a targeted re-read. */
TEST(tool_get_code_snippet_clips_whole_file_node) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_snipcap_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char proj_dir[512];
snprintf(proj_dir, sizeof(proj_dir), "%s/project", tmp);
cbm_mkdir(proj_dir);
char src_path[600];
snprintf(src_path, sizeof(src_path), "%s/big.py", proj_dir);
FILE *fp = fopen(src_path, "w");
ASSERT_NOT_NULL(fp);
enum { BIG_LINES = 2000 };
for (int i = 0; i < BIG_LINES; i++) {
fprintf(fp, "line_%04d = %d # padding to blow up an unclipped read\n", i, i);
}
fclose(fp);
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "test-project";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, proj_dir);
cbm_node_t mod = {0};
mod.project = proj;
mod.label = "Module";
mod.name = "big";
mod.qualified_name = "test-project.big";
mod.file_path = "big.py";
mod.start_line = 1;
mod.end_line = BIG_LINES;
ASSERT_GT(cbm_store_upsert_node(st, &mod), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"get_code_snippet\",\"arguments\":{\"project\":\"test-project\","
"\"qualified_name\":\"test-project.big\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"source_clipped\":true"));
/* The whole 2000-line file (~100KB) must NOT be in the response. */
ASSERT_TRUE(strlen(inner) < 60000);
/* The last line must be absent (clipped), the first present. */
ASSERT_NOT_NULL(strstr(inner, "line_0000"));
ASSERT_NULL(strstr(inner, "line_1999"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
th_rmtree(tmp);
PASS();
}
TEST(tool_search_graph_includes_node_properties) {
/* Node properties are OPT-IN columns in the default TOON output: the
* default row is qn/label/file/lines/degrees only, `fields` adds the
@@ -1412,8 +1645,8 @@ TEST(tool_search_graph_includes_node_properties) {
ASSERT_NOT_NULL(strstr(resp, "\"structuredContent\":{\"text\":"));
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "results[")); /* TOON table header */
ASSERT_NOT_NULL(strstr(inner, "{qn,label,file,lines,in,out}"));
ASSERT_NOT_NULL(strstr(inner, "results:")); /* TOON table header */
ASSERT_NOT_NULL(strstr(inner, "(rows: name label lines in out;"));
ASSERT_NOT_NULL(strstr(inner, "HandleRequest"));
ASSERT_NULL(strstr(inner, "func HandleRequest")); /* signature not spilled */
ASSERT_NULL(strstr(inner, "is_exported"));
@@ -1429,23 +1662,31 @@ TEST(tool_search_graph_includes_node_properties) {
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "{qn,label,file,lines,in,out,signature}"));
ASSERT_NOT_NULL(strstr(inner, "(rows: name label lines in out signature;"));
/* values with spaces are QUOTED so column positions survive */
ASSERT_NOT_NULL(strstr(inner, "\"func HandleRequest() error\""));
ASSERT_NOT_NULL(strstr(inner, "func HandleRequest"));
free(inner);
free(resp);
/* format:"json" keeps the legacy verbose objects intact. */
/* format:"json" = json-stringified tree: same grouped model, column-
* ordered row arrays — never per-row key envelopes or property blobs.
* fields adds columns there too. */
resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":44,\"method\":\"tools/call\","
"\"params\":{\"name\":\"search_graph\","
"\"arguments\":{\"project\":\"test-project\",\"label\":\"Function\","
"\"name_pattern\":\"HandleRequest\",\"format\":\"json\",\"limit\":5}}}");
"\"name_pattern\":\"HandleRequest\",\"format\":\"json\","
"\"fields\":[\"signature\"],\"limit\":5}}}");
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"signature\""));
ASSERT_NOT_NULL(strstr(inner, "func HandleRequest"));
ASSERT_NOT_NULL(strstr(inner, "is_exported"));
ASSERT_NOT_NULL(strstr(inner, "\"qn_prefix\"")); /* grouped tree model */
ASSERT_NOT_NULL(strstr(inner, "\"cols\""));
ASSERT_NOT_NULL(strstr(inner, "\"rows\""));
ASSERT_NOT_NULL(strstr(inner, "\"signature\"")); /* requested column */
ASSERT_NOT_NULL(strstr(inner, "func HandleRequest")); /* its value */
ASSERT_NULL(strstr(inner, "is_exported")); /* blob never spills */
free(inner);
free(resp);
@@ -1487,7 +1728,7 @@ TEST(tool_output_byte_budgets) {
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "callees["));
ASSERT_NOT_NULL(strstr(inner, "callees:"));
ASSERT_LT((int)strlen(inner), 800);
free(inner);
free(resp);
@@ -1544,6 +1785,187 @@ TEST(tool_search_graph_toon_never_leaks_internal_fields) {
PASS();
}
TEST(tool_lean_defaults_schema_and_status) {
/* GUARDS for the lean-default contract (TOON round 2):
* 1. get_graph_schema must not advertise the blocked internal fields
* (fp/sp/bt) — the server refuses to emit them, so listing them in the
* schema invited agents to request fields they can never get.
* 2. index_status omits the git context block unless verbose:true — the
* worktree/shadow path variants only matter when debugging where an
* index lives. */
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
cbm_store_t *st = cbm_mcp_server_store(srv);
ASSERT_NOT_NULL(st);
cbm_node_t n = {0};
n.project = "test-project";
n.label = "Function";
n.name = "schemaCarrier";
n.qualified_name = "test-project.src.schemaCarrier";
n.file_path = "src/sc.go";
n.start_line = 1;
n.end_line = 2;
n.properties_json = "{\"fp\":\"x\",\"sp\":\"y\",\"bt\":\"z\",\"complexity\":3}";
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":48,\"method\":\"tools/call\","
"\"params\":{\"name\":\"get_graph_schema\","
"\"arguments\":{\"project\":\"test-project\"}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "Function")); /* non-vacuous: label present */
ASSERT_NOT_NULL(strstr(inner, "complexity")); /* obtainable property listed */
ASSERT_NULL(strstr(inner, "\"fp\"")); /* blocked fields not advertised */
ASSERT_NULL(strstr(inner, "\"sp\""));
ASSERT_NULL(strstr(inner, "\"bt\""));
free(inner);
free(resp);
/* index_status: no git block by default... */
resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":49,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_status\","
"\"arguments\":{\"project\":\"test-project\"}}}");
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"status\""));
ASSERT_NULL(strstr(inner, "\"git\""));
free(inner);
free(resp);
/* ...and present with verbose:true. */
resp = cbm_mcp_server_handle(srv,
"{\"jsonrpc\":\"2.0\",\"id\":50,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_status\","
"\"arguments\":{\"project\":\"test-project\",\"verbose\":true}}}");
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"git\""));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
/* ── Tool-output regression suite (gating) ──────────────────────────
* Context-explosion detector: flags the measured smells that re-introduce
* token bloat into default outputs, independent of any specific tool:
* 1. blocked internal fields (fp/sp/bt) appearing anywhere;
* 2. repeated-key JSON envelopes — the same key emitted per row instead of
* a header-once table (the un-TOONed enumeration smell; detect_changes
* shipped 4,787x3 of these = 416KB);
* 3. embedded prose notes/hints beyond one line (~220 chars) — long prose
* belongs in tool descriptions or docs, not repeated per response.
* Returns NULL when clean, else a static description of the violation. */
static const char *output_explosion_smell(const char *inner) {
static const char *row_keys[] = {
"\"name\":", "\"label\":", "\"file\":", "\"path\":", "\"qualified_name\":", "\"qn\":"};
if (strstr(inner, "\"fp\":") || strstr(inner, "\"sp\":") || strstr(inner, "\"bt\":")) {
return "blocked internal field (fp/sp/bt) leaked into output";
}
for (size_t k = 0; k < sizeof(row_keys) / sizeof(row_keys[0]); k++) {
int n = 0;
for (const char *p = strstr(inner, row_keys[k]); p && n <= 32;
p = strstr(p + 1, row_keys[k])) {
n++;
}
if (n > 32) {
return "repeated-key envelope (>32x same JSON key) — emit a header-once table";
}
}
for (const char *p = strstr(inner, "\"note\":\""); p; p = strstr(p + 1, "\"note\":\"")) {
const char *end = strchr(p + 9, '"');
while (end && end[-1] == '\\') {
end = strchr(end + 1, '"');
}
if (end && end - (p + 9) > 220) {
return "embedded note exceeds one line (~220 chars)";
}
}
return NULL;
}
/* Run one tool call on the fixture server, apply the explosion detector and
* an absolute byte ceiling, and require a semantic-floor marker so trimming
* can never hollow the response out either. */
static const char *check_tool_output(cbm_mcp_server_t *srv, const char *req, int ceiling,
const char *floor_marker) {
char *resp = cbm_mcp_server_handle(srv, req);
if (!resp) {
return "no response";
}
char *inner = extract_text_content(resp);
free(resp);
if (!inner) {
return "no text content";
}
static char why[256];
const char *smell = output_explosion_smell(inner);
if (smell) {
snprintf(why, sizeof(why), "%s", smell);
free(inner);
return why;
}
if ((int)strlen(inner) >= ceiling) {
snprintf(why, sizeof(why), "output %d B >= ceiling %d B", (int)strlen(inner), ceiling);
free(inner);
return why;
}
if (floor_marker && !strstr(inner, floor_marker)) {
snprintf(why, sizeof(why), "semantic floor missing: %s", floor_marker);
free(inner);
return why;
}
free(inner);
return NULL;
}
TEST(tool_output_regression_gate) {
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
struct {
const char *req;
int ceiling;
const char *floor;
} cases[] = {
{"{\"jsonrpc\":\"2.0\",\"id\":70,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"search_graph\",\"arguments\":{\"project\":\"test-project\","
"\"name_pattern\":\".*\",\"limit\":50}}}",
6000, "results:"},
{"{\"jsonrpc\":\"2.0\",\"id\":71,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"get_graph_schema\",\"arguments\":{\"project\":\"test-project\"}}}",
6000, "node_labels"},
{"{\"jsonrpc\":\"2.0\",\"id\":72,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"index_status\",\"arguments\":{\"project\":\"test-project\"}}}",
7000, "\"status\""},
{"{\"jsonrpc\":\"2.0\",\"id\":73,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"test-project\","
"\"function_name\":\"HandleRequest\",\"direction\":\"both\"}}}",
1500, "callees:"},
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
const char *why = check_tool_output(srv, cases[i].req, cases[i].ceiling, cases[i].floor);
if (why) {
char msg[320];
snprintf(msg, sizeof(msg), "case %d: %s", (int)i, why);
FAIL(msg);
}
}
cbm_mcp_server_free(srv);
cleanup_snippet_dir(tmp);
PASS();
}
TEST(tool_search_graph_query_honors_file_pattern_issue552) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
ASSERT_NOT_NULL(srv);
@@ -1860,14 +2282,17 @@ TEST(tool_check_index_coverage_surfaces_lookup_errors) {
}
TEST(tool_index_status_includes_git_metadata) {
/* The git context block moved behind verbose:true (lean-default contract,
* TOON round 2) — this test pins the verbose path's content; the default-
* omission guard lives in tool_lean_defaults_schema_and_status. */
char tmp[256];
cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp));
ASSERT_NOT_NULL(srv);
char *resp =
cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":16,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_status\","
"\"arguments\":{\"project\":\"test-project\"}}}");
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":16,\"method\":\"tools/call\","
"\"params\":{\"name\":\"index_status\","
"\"arguments\":{\"project\":\"test-project\",\"verbose\":true}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
@@ -1960,6 +2385,223 @@ TEST(tool_trace_call_path_ambiguous) {
PASS();
}
/* Multi-seed union hop semantics: bfs_union_same_name deduped visited nodes
* keep-FIRST-seen, so a node reached at hop 2 from the first seed kept hop 2
* even when the second seed reaches it at hop 1. hop feeds risk_labels and
* (soon) pagination watermarks — it must be the MINIMUM across seeds, matching
* the single-BFS MIN(hop) semantics (#797). */
TEST(tool_trace_union_records_min_hop_across_seeds) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "dualproj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/dual");
/* One real definition + one body-less stub (start==end) — the #546/#650
* shape pick_resolved_node resolves WITHOUT ambiguity while
* bfs_union_same_name still traverses both. Seed A (real def, lower id,
* traversed first) reaches tgt only via mid (hop 2); the stub seed B
* reaches tgt directly (hop 1). */
cbm_node_t sa = {.project = proj,
.label = "Function",
.name = "dual",
.qualified_name = "dualproj.a.dual",
.file_path = "a.c",
.start_line = 1,
.end_line = 50};
cbm_node_t sb = {.project = proj,
.label = "Function",
.name = "dual",
.qualified_name = "dualproj.b.dual",
.file_path = "b.d.ts",
.start_line = 1,
.end_line = 1};
cbm_node_t mid = {.project = proj,
.label = "Function",
.name = "mid",
.qualified_name = "dualproj.c.mid",
.file_path = "c.c",
.start_line = 1,
.end_line = 5};
cbm_node_t tgt = {.project = proj,
.label = "Function",
.name = "tgt",
.qualified_name = "dualproj.c.tgt",
.file_path = "c.c",
.start_line = 10,
.end_line = 15};
int64_t ida = cbm_store_upsert_node(st, &sa);
int64_t idb = cbm_store_upsert_node(st, &sb);
int64_t idm = cbm_store_upsert_node(st, &mid);
int64_t idt = cbm_store_upsert_node(st, &tgt);
ASSERT_GT(ida, 0);
ASSERT_GT(idb, 0);
ASSERT_GT(idm, 0);
ASSERT_GT(idt, 0);
cbm_edge_t e1 = {.project = proj, .source_id = ida, .target_id = idm, .type = "CALLS"};
cbm_edge_t e2 = {.project = proj, .source_id = idm, .target_id = idt, .type = "CALLS"};
cbm_edge_t e3 = {.project = proj, .source_id = idb, .target_id = idt, .type = "CALLS"};
ASSERT_GT(cbm_store_insert_edge(st, &e1), 0);
ASSERT_GT(cbm_store_insert_edge(st, &e2), 0);
ASSERT_GT(cbm_store_insert_edge(st, &e3), 0);
char *resp = cbm_mcp_server_handle(
srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\","
"\"params\":{\"name\":\"trace_call_path\","
"\"arguments\":{\"function_name\":\"dual\",\"project\":\"dualproj\","
"\"direction\":\"outbound\",\"depth\":3}}}");
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
/* tgt is one hop from seed B — the union must record hop 1, not seed A's 2. */
ASSERT_NOT_NULL(strstr(inner, " tgt 1"));
ASSERT_NULL(strstr(inner, " tgt 2"));
free(inner);
free(resp);
cbm_mcp_server_free(srv);
PASS();
}
/* Exactly-once trace pagination: 12 callees paged at limit=5 must yield
* 5+5+2 rows with every callee appearing on exactly one page, exact totals
* on every page, and a final page without a cursor. Stale and mismatched
* cursors must fail with teaching errors, never silently restart. */
TEST(tool_trace_pagination_exactly_once) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
cbm_store_t *st = cbm_mcp_server_store(srv);
const char *proj = "pageproj";
cbm_mcp_server_set_project(srv, proj);
cbm_store_upsert_project(st, proj, "/tmp/page");
cbm_node_t hub = {.project = proj,
.label = "Function",
.name = "hub",
.qualified_name = "pageproj.h.hub",
.file_path = "h.c",
.start_line = 1,
.end_line = 9};
int64_t hid = cbm_store_upsert_node(st, &hub);
ASSERT_GT(hid, 0);
enum { CALLEES = 12 };
for (int i = 0; i < CALLEES; i++) {
char nm[16];
char qn[48];
snprintf(nm, sizeof(nm), "c%02d", i);
snprintf(qn, sizeof(qn), "pageproj.m.c%02d", i);
cbm_node_t n = {.project = proj,
.label = "Function",
.name = nm,
.qualified_name = qn,
.file_path = "m.c",
.start_line = 1,
.end_line = 3};
int64_t nid = cbm_store_upsert_node(st, &n);
ASSERT_GT(nid, 0);
cbm_edge_t e = {.project = proj, .source_id = hid, .target_id = nid, .type = "CALLS"};
ASSERT_GT(cbm_store_insert_edge(st, &e), 0);
}
char pages[3][4096];
char tok[192] = "";
int npages = 0;
for (; npages < 3; npages++) {
char req[640];
if (tok[0]) {
snprintf(req, sizeof(req),
"{\"jsonrpc\":\"2.0\",\"id\":80,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\","
"\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5,"
"\"cursor\":\"%s\"}}}",
tok);
} else {
snprintf(req, sizeof(req),
"{\"jsonrpc\":\"2.0\",\"id\":80,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\","
"\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5}}}");
}
char *resp = cbm_mcp_server_handle(srv, req);
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
free(resp);
ASSERT_NOT_NULL(inner);
snprintf(pages[npages], sizeof(pages[npages]), "%s", inner);
ASSERT_NOT_NULL(strstr(inner, "callees_total: 12")); /* exact total, every page */
const char *nx = strstr(inner, "next: ");
if (nx) {
const char *e = strchr(nx + 6, '\n');
size_t tl = e ? (size_t)(e - (nx + 6)) : strlen(nx + 6);
ASSERT_TRUE(tl < sizeof(tok));
memcpy(tok, nx + 6, tl);
tok[tl] = '\0';
} else {
tok[0] = '\0';
}
free(inner);
if (!tok[0]) {
npages++;
break;
}
}
ASSERT_EQ(npages, 3); /* 5 + 5 + 2 */
/* Exactly-once: every callee appears on exactly ONE page. */
for (int i = 0; i < CALLEES; i++) {
char qn[48];
snprintf(qn, sizeof(qn), " c%02d 1\n", i);
int seen = 0;
for (int p = 0; p < 3; p++) {
if (strstr(pages[p], qn)) {
seen++;
}
}
ASSERT_EQ(seen, 1);
}
/* Final page carries no cursor. */
ASSERT_NULL(strstr(pages[2], "next: "));
/* Params mismatch: replay a page-2-era cursor with a different depth. */
const char *nx1 = strstr(pages[0], "next: ");
ASSERT_NOT_NULL(nx1);
char tok1[192];
const char *e1 = strchr(nx1 + 6, '\n');
size_t tl1 = e1 ? (size_t)(e1 - (nx1 + 6)) : strlen(nx1 + 6);
memcpy(tok1, nx1 + 6, tl1);
tok1[tl1] = '\0';
char req2[640];
snprintf(req2, sizeof(req2),
"{\"jsonrpc\":\"2.0\",\"id\":81,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\","
"\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5,\"depth\":2,"
"\"cursor\":\"%s\"}}}",
tok1);
char *resp = cbm_mcp_server_handle(srv, req2);
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
free(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "cursor_params_mismatch"));
free(inner);
/* Stale: an index run (upsert_project bumps the generation) invalidates
* outstanding cursors with a loud, actionable error. */
cbm_store_upsert_project(st, proj, "/tmp/page");
snprintf(req2, sizeof(req2),
"{\"jsonrpc\":\"2.0\",\"id\":82,\"method\":\"tools/call\",\"params\":{"
"\"name\":\"trace_call_path\",\"arguments\":{\"project\":\"pageproj\","
"\"function_name\":\"hub\",\"direction\":\"outbound\",\"limit\":5,"
"\"cursor\":\"%s\"}}}",
tok1);
resp = cbm_mcp_server_handle(srv, req2);
ASSERT_NOT_NULL(resp);
inner = extract_text_content(resp);
free(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "stale_cursor"));
free(inner);
cbm_mcp_server_free(srv);
PASS();
}
/* Regression: when same-named nodes differ in rank, trace must pick the real
* definition (callable, larger body) — NOT nodes[0]. The Module is inserted
* first; if trace took nodes[0] the outbound trace would be empty. */
@@ -2375,7 +3017,7 @@ TEST(tool_get_architecture_emits_populated_sections) {
* those existed before #281. The "entry_points" array only appears
* when cbm_store_get_architecture is actually called and its result
* is serialized — which is exactly what #281 wires up. */
ASSERT_NOT_NULL(strstr(inner, "entry_points["));
ASSERT_NOT_NULL(strstr(inner, "entry_points:"));
ASSERT_NOT_NULL(strstr(inner, "main"));
free(inner);
@@ -2428,8 +3070,8 @@ TEST(tool_get_architecture_overview_compact_subset_pr560) {
ASSERT_NOT_NULL(resp_all);
char *inner_all = extract_text_content(resp_all);
ASSERT_NOT_NULL(inner_all);
ASSERT_NOT_NULL(strstr(inner_all, "entry_points["));
ASSERT_NOT_NULL(strstr(inner_all, "file_tree["));
ASSERT_NOT_NULL(strstr(inner_all, "entry_points:"));
ASSERT_NOT_NULL(strstr(inner_all, "file_tree:"));
free(inner_all);
free(resp_all);
@@ -2442,9 +3084,9 @@ TEST(tool_get_architecture_overview_compact_subset_pr560) {
ASSERT_NOT_NULL(resp);
char *inner = extract_text_content(resp);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "entry_points["));
ASSERT_NOT_NULL(strstr(inner, "node_labels["));
ASSERT_NULL(strstr(inner, "file_tree["));
ASSERT_NOT_NULL(strstr(inner, "entry_points:"));
ASSERT_NOT_NULL(strstr(inner, "node_labels:"));
ASSERT_NULL(strstr(inner, "file_tree:"));
free(inner);
free(resp);
@@ -2525,7 +3167,7 @@ TEST(tool_get_architecture_accepts_project_name_alias_issue640) {
/* RED before the alias: inner is the "project not found" error.
* GREEN after: the alias resolves and architecture sections surface. */
ASSERT_NULL(strstr(inner, "project not found"));
ASSERT_NOT_NULL(strstr(inner, "entry_points["));
ASSERT_NOT_NULL(strstr(inner, "entry_points:"));
free(inner);
free(resp);
@@ -5004,6 +5646,88 @@ TEST(tool_detect_changes_not_found_rich_error) {
PASS();
}
/* detect_changes owns shell output through regular temporary files. An error
* after opening that file must use fclose + unlink. The command hook then
* rejects merge-base only when it reaches the contained subprocess helper, so
* a raw popen regression bypasses the hook and fails this test. */
TEST(tool_detect_changes_contained_commands_clean_up_error_and_success) {
char cache[512];
(void)snprintf(cache, sizeof(cache), "%s/cbm-detect-contained-XXXXXX", cbm_tmpdir());
bool cache_created = cbm_mkdtemp(cache) != NULL;
const char *saved_cache = getenv("CBM_CACHE_DIR");
char *saved_cache_copy = saved_cache ? strdup(saved_cache) : NULL;
bool environment_ready = cache_created && cbm_setenv("CBM_CACHE_DIR", cache, 1) == 0;
char root[CBM_SZ_4K] = {0};
bool root_ready = cbm_getcwd(root, sizeof(root)) != NULL;
const char *project = "detect-contained-project";
cbm_mcp_server_t *srv = environment_ready && root_ready ? cbm_mcp_server_new(NULL) : NULL;
bool server_ready = srv != NULL;
cbm_store_t *store = srv ? cbm_mcp_server_store(srv) : NULL;
bool project_ready = store && cbm_store_upsert_project(store, project, root) == CBM_STORE_OK;
mcp_command_hook_probe_t command_probe = {.reject_merge_base = true};
if (project_ready) {
cbm_mcp_server_set_project(srv, project);
cbm_mcp_server_set_command_test_hook(srv, mcp_command_hook_probe, &command_probe);
}
char *invalid_response =
project_ready ? cbm_mcp_handle_tool(srv, "detect_changes",
"{\"project\":\"detect-contained-project\","
"\"base_branch\":\"HEAD\",\"scope\":\"files\","
"\"direction\":\"sideways\"}")
: NULL;
bool invalid_rejected = invalid_response && strstr(invalid_response, "invalid direction");
char logs[640];
(void)snprintf(logs, sizeof(logs), "%s/logs", cache);
int artifacts_after_error =
invalid_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1;
char *rejected_response =
project_ready ? cbm_mcp_handle_tool(srv, "detect_changes",
"{\"project\":\"detect-contained-project\","
"\"base_branch\":\"HEAD\",\"scope\":\"files\"}")
: NULL;
bool containment_rejected =
rejected_response && strstr(rejected_response, "contained command could not complete");
int artifacts_after_rejection =
rejected_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1;
command_probe.reject_merge_base = false;
char *success_response =
project_ready ? cbm_mcp_handle_tool(srv, "detect_changes",
"{\"project\":\"detect-contained-project\","
"\"base_branch\":\"HEAD\",\"scope\":\"files\"}")
: NULL;
bool merge_base_reported = success_response && strstr(success_response, "merge_base");
int artifacts_after_success =
success_response ? mcp_count_directory_entries_with_prefix(logs, ".mcp-command-") : -1;
free(invalid_response);
free(rejected_response);
free(success_response);
cbm_mcp_server_free(srv);
restore_cache_dir(saved_cache_copy);
free(saved_cache_copy);
bool cleaned = !cache_created || th_rmtree(cache) == 0;
ASSERT_TRUE(cache_created);
ASSERT_TRUE(environment_ready);
ASSERT_TRUE(root_ready);
ASSERT_TRUE(server_ready);
ASSERT_TRUE(project_ready);
ASSERT_TRUE(invalid_rejected);
ASSERT_EQ(artifacts_after_error, 0);
ASSERT_TRUE(containment_rejected);
ASSERT_EQ(artifacts_after_rejection, 0);
ASSERT_TRUE(merge_base_reported);
ASSERT_EQ(artifacts_after_success, 0);
ASSERT_EQ(command_probe.diff_calls, 3);
ASSERT_EQ(command_probe.merge_base_calls, 2);
ASSERT_TRUE(cleaned);
PASS();
}
TEST(tool_ingest_traces_basic) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
@@ -8564,8 +9288,13 @@ SUITE(mcp) {
RUN_TEST(tool_get_graph_schema_empty);
RUN_TEST(tool_unknown_tool);
RUN_TEST(tool_search_graph_basic);
RUN_TEST(tool_trace_totals_respect_test_filter);
RUN_TEST(tool_get_architecture_cycles_detects_scc);
RUN_TEST(tool_get_code_snippet_clips_whole_file_node);
RUN_TEST(tool_search_graph_includes_node_properties);
RUN_TEST(tool_search_graph_toon_never_leaks_internal_fields);
RUN_TEST(tool_lean_defaults_schema_and_status);
RUN_TEST(tool_output_regression_gate);
RUN_TEST(tool_output_byte_budgets);
RUN_TEST(tool_search_graph_query_honors_file_pattern_issue552);
RUN_TEST(mcp_resource_discovery_methods_return_empty_lists);
@@ -8582,6 +9311,8 @@ SUITE(mcp) {
RUN_TEST(tool_trace_call_path_not_found);
RUN_TEST(tool_trace_missing_function_name);
RUN_TEST(tool_trace_call_path_ambiguous);
RUN_TEST(tool_trace_union_records_min_hop_across_seeds);
RUN_TEST(tool_trace_pagination_exactly_once);
RUN_TEST(tool_trace_call_path_prefers_definition);
RUN_TEST(tool_trace_call_path_depth_clamped);
RUN_TEST(tool_trace_call_path_distinct_defs_not_over_unioned);
@@ -8633,6 +9364,7 @@ SUITE(mcp) {
RUN_TEST(tool_manage_adr_get_accepts_abs_path);
RUN_TEST(tool_manage_adr_get_accepts_symlink_path);
RUN_TEST(tool_detect_changes_not_found_rich_error);
RUN_TEST(tool_detect_changes_contained_commands_clean_up_error_and_success);
RUN_TEST(tool_ingest_traces_basic);
RUN_TEST(tool_ingest_traces_empty);
+33 -4
View File
@@ -113,6 +113,35 @@ TEST(journal_size_limit_bounds_wal_issue1083) {
PASS();
}
/* Pagination-cursor generation: minted per DB file, bumped per index run.
* Same store + reads only -> stable; upsert_project (every index run's choke
* point) -> changes; two distinct DB files can never share a generation even
* at the same counter value (random db_uid). */
TEST(store_generation_tracks_mutations) {
char g1[128];
char g2[128];
char g3[128];
cbm_store_t *a = cbm_store_open_memory();
ASSERT(a != NULL);
ASSERT_EQ(cbm_store_upsert_project(a, "p", "/tmp/p"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_generation(a, g1, sizeof(g1)), CBM_STORE_OK);
ASSERT(strncmp(g1, "u", 1) == 0); /* seeded, not legacy */
ASSERT_EQ(cbm_store_generation(a, g2, sizeof(g2)), CBM_STORE_OK);
ASSERT(strcmp(g1, g2) == 0); /* reads are stable */
ASSERT_EQ(cbm_store_upsert_project(a, "p", "/tmp/p"), CBM_STORE_OK);
ASSERT_EQ(cbm_store_generation(a, g3, sizeof(g3)), CBM_STORE_OK);
ASSERT(strcmp(g1, g3) != 0); /* index run bumps */
cbm_store_t *b = cbm_store_open_memory();
ASSERT(b != NULL);
ASSERT_EQ(cbm_store_upsert_project(b, "p", "/tmp/p"), CBM_STORE_OK);
char gb[128];
ASSERT_EQ(cbm_store_generation(b, gb, sizeof(gb)), CBM_STORE_OK);
ASSERT(strcmp(g1, gb) != 0); /* distinct DBs never alias (random uid) */
cbm_store_close(a);
cbm_store_close(b);
PASS();
}
/* #896: a row-scan that dies mid-stream (SQLITE_CORRUPT) must surface a
* loud store error, not masquerade as a clean end of results. Counts are
@@ -134,8 +163,7 @@ TEST(corrupt_page_scan_returns_error_not_truncation) {
char qn[256];
snprintf(name, sizeof(name), "corrupt_probe_fn_%04d", i);
snprintf(qn, sizeof(qn),
"corr.some.rather.long.module.path.to.fill.table.pages.%s_padding_padding",
name);
"corr.some.rather.long.module.path.to.fill.table.pages.%s_padding_padding", name);
cbm_node_t n = {.project = "corr",
.label = "Function",
.name = name,
@@ -174,8 +202,8 @@ TEST(corrupt_page_scan_returns_error_not_truncation) {
cbm_store_t *s2 = cbm_store_open_path(db_path);
ASSERT_NOT_NULL(s2);
/* The scan must CROSS the corrupt band: request every row. */
cbm_search_params_t all_params = {.project = "corr", .label = "Function",
.limit = CORRUPT_NODES};
cbm_search_params_t all_params = {
.project = "corr", .label = "Function", .limit = CORRUPT_NODES};
cbm_search_output_t out2 = {0};
int rc_search = cbm_store_search(s2, &all_params, &out2);
if (rc_search == CBM_STORE_OK && out2.count == CORRUPT_NODES) {
@@ -207,6 +235,7 @@ TEST(corrupt_page_scan_returns_error_not_truncation) {
SUITE(store_pragmas) {
RUN_TEST(journal_size_limit_bounds_wal_issue1083);
RUN_TEST(store_generation_tracks_mutations);
RUN_TEST(corrupt_page_scan_returns_error_not_truncation);
RUN_TEST(mmap_size_default_when_unset);
RUN_TEST(mmap_size_zero_disables_mmap);