mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 10:48:20 +00:00
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:
commit
5d8a9421fb
16 files changed
+3545
-715
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user