fix(mcp): expose recursion metadata in trace_path

Report stored recursion signals and the static self-call omission policy
alongside graph reachability totals. Preserve the metadata across cursor
pages and output-budget fallback responses, and document unknown legacy
metadata without changing CALLS indexing or traversal counts.

Fixes #2816

Signed-off-by: pcristin <xxxokzxxx@protonmail.com>
This commit is contained in:
pcristin committed 2026-10-29 22:21:48 +00:00
1 parent dca2a44ef0
commit ab8f68a169
3 files changed
+289 -1

No files matched your search

+10
View File
@@ -934,6 +934,16 @@ mode. `index_status` keeps describing the published graph and its freshness.
`manage_adr` query modes (`get` and `sections`) use the server's cached query store so they can proceed while a same-project reindex is running. If another process publishes a replacement store during reindexing, they can return the pre-publication ADR until idle eviction refreshes that cache. Updates remain serialized through the project mutation guard.
`trace_path` exposes the start nodes' stored `self_recursive` (direct recursion)
and `recursive` (direct recursion or a call-graph cycle) signals in both tree and
JSON output. Across multiple start nodes, a flag is `true` if any records true,
`false` if all record false, and `null` otherwise (including missing legacy
metadata). These are static-analysis signals, not counts of resolved self-calls.
Static `CALLS` indexing omits direct self-call edges; traces that include `CALLS` state
`self_call_policy: omitted`. Caller/callee totals still count graph-reachable
nodes, so a recursive function can have zero callees. Recursion metadata remains
visible on continuation pages and when an output budget prevents rows from fitting.
## Graph Data Model
### Node Labels
+71
View File
@@ -10137,6 +10137,72 @@ static void trace_call_coverage(cbm_store_t *store, const char *project,
cbm_ht_free(match.targets);
}
enum { TRACE_RECURSION_FLAG_COUNT = 2 };
typedef enum {
TRACE_RECURSION_UNKNOWN = -1,
TRACE_RECURSION_FALSE,
TRACE_RECURSION_TRUE,
} trace_recursion_flag_t;
static const char *const trace_recursion_keys[TRACE_RECURSION_FLAG_COUNT] = {"self_recursive",
"recursive"};
typedef struct {
/* -1: unknown, 0: all roots record false, 1: at least one records true. */
trace_recursion_flag_t flags[TRACE_RECURSION_FLAG_COUNT];
} trace_recursion_t;
static trace_recursion_t trace_recursion_metadata(const cbm_node_t *nodes, int node_count) {
trace_recursion_t metadata = {0};
/* Use the same roots as bfs_union_same_name, rather than borrowing a
* callee's flag or selecting just one definition/stub. These are stored
* analysis signals, not evidence that a resolved self-call was dropped. */
for (int i = 0; i < node_count; i++) {
const char *properties = nodes[i].properties_json;
yyjson_doc *doc = properties ? yyjson_read(properties, strlen(properties), 0) : NULL;
yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL;
for (int j = 0; j < TRACE_RECURSION_FLAG_COUNT; j++) {
yyjson_val *value = yyjson_obj_get(root, trace_recursion_keys[j]);
if (yyjson_is_bool(value) && yyjson_get_bool(value)) {
metadata.flags[j] = TRACE_RECURSION_TRUE;
} else if (!yyjson_is_bool(value) && metadata.flags[j] != TRACE_RECURSION_TRUE) {
metadata.flags[j] = TRACE_RECURSION_UNKNOWN;
}
}
yyjson_doc_free(doc);
}
return metadata;
}
static void trace_recursion_tree(cbm_sb_t *sb, const trace_recursion_t *metadata,
bool includes_calls) {
for (int i = 0; i < TRACE_RECURSION_FLAG_COUNT; i++) {
if (metadata->flags[i] == TRACE_RECURSION_UNKNOWN) {
cbm_sb_append(sb, trace_recursion_keys[i]);
cbm_sb_append(sb, ": null\n");
} else {
cbm_tree_scalar_bool(sb, trace_recursion_keys[i],
metadata->flags[i] == TRACE_RECURSION_TRUE);
}
}
if (includes_calls) {
cbm_tree_scalar_str(sb, "self_call_policy", "omitted");
}
}
static void trace_recursion_json(yyjson_mut_doc *doc, yyjson_mut_val *root,
const trace_recursion_t *metadata, bool includes_calls) {
for (int i = 0; i < TRACE_RECURSION_FLAG_COUNT; i++) {
if (metadata->flags[i] == TRACE_RECURSION_UNKNOWN) {
yyjson_mut_obj_add_null(doc, root, trace_recursion_keys[i]);
} else {
yyjson_mut_obj_add_bool(doc, root, trace_recursion_keys[i],
metadata->flags[i] == TRACE_RECURSION_TRUE);
}
}
if (includes_calls) {
yyjson_mut_obj_add_str(doc, root, "self_call_policy", "omitted");
}
}
static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
char *func_name = cbm_mcp_get_string_arg(args, "function_name");
char *project = get_project_arg(args);
@@ -10491,6 +10557,7 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
.do_inbound = do_inbound};
trace_call_coverage(store, project, &coverage_scope, trace_includes_calls, &unresolved_out,
&unresolved_in);
trace_recursion_t recursion = trace_recursion_metadata(nodes, node_count);
const char *out_relation = unresolved_out ? "unknown" : (tr_out.truncated ? "gte" : "eq");
const char *in_relation = unresolved_in ? "unknown" : (tr_in.truncated ? "gte" : "eq");
@@ -10574,6 +10641,7 @@ render_trace_output:;
if (mode) {
cbm_tree_scalar_str(&sb, "mode", mode);
}
trace_recursion_tree(&sb, &recursion, trace_includes_calls);
/* Grouped tree is THE default; risk_labels/data_flow keep the flat
* table (extra columns) in the same tree syntax. */
bool render_risk = risk_labels && emit_optional_fields;
@@ -10657,6 +10725,7 @@ render_trace_output:;
if (mode) {
yyjson_mut_obj_add_str(doc, root, "mode", mode);
}
trace_recursion_json(doc, root, &recursion, trace_includes_calls);
if (do_outbound) {
yyjson_mut_obj_add_int(doc, root, "callees_total", out_total);
yyjson_mut_obj_add_str(doc, root, "callees_total_relation", out_relation);
@@ -10724,6 +10793,7 @@ render_trace_output:;
if (!trace_legacy_json) {
cbm_sb_t floor;
cbm_sb_init(&floor);
trace_recursion_tree(&floor, &recursion, trace_includes_calls);
if (do_outbound) {
cbm_tree_scalar_int(&floor, "callees_total", out_total);
cbm_tree_scalar_str(&floor, "callees_total_relation", out_relation);
@@ -10752,6 +10822,7 @@ render_trace_output:;
yyjson_mut_doc *floor_doc = yyjson_mut_doc_new(NULL);
yyjson_mut_val *floor = yyjson_mut_obj(floor_doc);
yyjson_mut_doc_set_root(floor_doc, floor);
trace_recursion_json(floor_doc, floor, &recursion, trace_includes_calls);
if (do_outbound) {
yyjson_mut_obj_add_int(floor_doc, floor, "callees_total", out_total);
yyjson_mut_obj_add_str(floor_doc, floor, "callees_total_relation",
+208 -1
View File
@@ -7403,6 +7403,200 @@ TEST(tool_index_status_includes_git_metadata) {
* TOOL HANDLERS WITH DATA
* ══════════════════════════════════════════════════════════════════ */
/* #2816: stored recursion must remain visible even when the CALLS graph has
* no self-edges. Removing metadata emission must fail these assertions. */
TEST(tool_trace_path_recursion_metadata_issue2816) {
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 = "recursion-proj";
cbm_mcp_server_set_project(srv, proj);
ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/recursion"), CBM_STORE_OK);
const struct {
const char *name;
const char *props;
const char *self;
const char *recursive;
} cases[] = {
{"countdown", "{\"self_recursive\":true,\"recursive\":true}", "true", "true"},
{"visit", "{\"self_recursive\":true,\"recursive\":true}", "true", "true"},
{"mutual", "{\"self_recursive\":false,\"recursive\":true}", "false", "true"},
{"leaf", "{\"self_recursive\":false,\"recursive\":false}", "false", "false"},
{"legacy", "{}", "null", "null"},
{"partial", "{\"self_recursive\":true}", "true", "null"},
{"invalid", "{\"self_recursive\":\"true\",\"recursive\":1}", "null", "null"},
};
for (size_t i = 0; i < sizeof(cases) / sizeof(cases[0]); i++) {
char qn[128];
snprintf(qn, sizeof(qn), "%s.%s", proj, cases[i].name);
cbm_node_t n = {.project = proj,
.label = i == 1 ? "Method" : "Function",
.name = cases[i].name,
.qualified_name = qn,
.file_path = "recursion.ts",
.start_line = 1,
.end_line = 5,
.properties_json = cases[i].props};
ASSERT_GT(cbm_store_upsert_node(st, &n), 0);
for (int json = 0; json < 2; json++) {
for (int inbound = 0; inbound < 2; inbound++) {
char args[512];
snprintf(args, sizeof(args),
"{\"project\":\"%s\",\"function_name\":\"%s\","
"\"direction\":\"%s\",\"format\":\"%s\"}",
proj, cases[i].name, inbound ? "inbound" : "outbound",
json ? "json" : "tree");
char *response = cbm_mcp_handle_tool(srv, "trace_path", args);
char *inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
char expected[128];
snprintf(expected, sizeof(expected),
json ? "\"self_recursive\":%s" : "self_recursive: %s\n", cases[i].self);
ASSERT_NOT_NULL(strstr(inner, expected));
snprintf(expected, sizeof(expected), json ? "\"recursive\":%s" : "recursive: %s\n",
cases[i].recursive);
ASSERT_NOT_NULL(strstr(inner, expected));
ASSERT_NOT_NULL(strstr(inner, json ? "\"self_call_policy\":\"omitted\""
: "self_call_policy: omitted\n"));
snprintf(expected, sizeof(expected), json ? "\"%s_total\":0" : "%s_total: 0\n",
inbound ? "callers" : "callees");
ASSERT_NOT_NULL(strstr(inner, expected));
free(inner);
free(response);
}
}
}
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_trace_path_recursion_union_and_edges_issue2816) {
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 = "recursion-union";
cbm_mcp_server_set_project(srv, proj);
ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/recursion-union"), CBM_STORE_OK);
cbm_node_t def = {.project = proj,
.label = "Function",
.name = "countdown",
.qualified_name = "recursion-union.impl.countdown",
.file_path = "impl.ts",
.start_line = 1,
.end_line = 10,
.properties_json = "{\"self_recursive\":false,\"recursive\":false}"};
int64_t def_id = cbm_store_upsert_node(st, &def);
ASSERT_GT(def_id, 0);
cbm_node_t stub = def;
stub.qualified_name = "recursion-union.stub.countdown";
stub.file_path = "stub.d.ts";
stub.end_line = 1;
stub.properties_json = "{\"self_recursive\":true,\"recursive\":true}";
ASSERT_GT(cbm_store_upsert_node(st, &stub), 0);
cbm_node_t leaf = def;
leaf.name = "leaf";
leaf.qualified_name = "recursion-union.impl.leaf";
leaf.properties_json = "{\"self_recursive\":true,\"recursive\":true}";
int64_t leaf_id = cbm_store_upsert_node(st, &leaf);
ASSERT_GT(leaf_id, 0);
cbm_edge_t edge = {.project = proj, .source_id = def_id, .target_id = leaf_id, .type = "CALLS"};
ASSERT_GT(cbm_store_insert_edge(st, &edge), 0);
const char *expected_flags[] = {"true", "null", "false", "true"};
for (int state = 0; state < 4; state++) {
def.properties_json = state == 3 ? "{\"self_recursive\":true,\"recursive\":true}"
: "{\"self_recursive\":false,\"recursive\":false}";
stub.properties_json = state == 0 ? "{\"self_recursive\":true,\"recursive\":true}"
: state == 2 ? "{\"self_recursive\":false,\"recursive\":false}"
: "{}";
ASSERT_GT(cbm_store_upsert_node(st, &def), 0);
ASSERT_GT(cbm_store_upsert_node(st, &stub), 0);
for (int json = 0; json < 2; json++) {
char args[512];
snprintf(args, sizeof(args),
"{\"project\":\"%s\",\"function_name\":\"countdown\","
"\"direction\":\"outbound\",\"format\":\"%s\"}",
proj, json ? "json" : "tree");
char *response = cbm_mcp_handle_tool(srv, "trace_path", args);
char *inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, json ? "\"callees_total\":1" : "callees_total: 1\n"));
ASSERT_NOT_NULL(strstr(inner, json ? "\"leaf\"" : ".leaf"));
char expected[128];
snprintf(expected, sizeof(expected),
json ? "\"self_recursive\":%s" : "self_recursive: %s\n",
expected_flags[state]);
ASSERT_NOT_NULL(strstr(inner, expected));
snprintf(expected, sizeof(expected), json ? "\"recursive\":%s" : "recursive: %s\n",
expected_flags[state]);
ASSERT_NOT_NULL(strstr(inner, expected));
free(inner);
free(response);
}
}
/* Recursion is root metadata, but the omission policy concerns CALLS only. */
char *response =
cbm_mcp_handle_tool(srv, "trace_path",
"{\"project\":\"recursion-union\",\"function_name\":\"countdown\","
"\"edge_types\":[\"IMPORTS\"],\"format\":\"json\"}");
char *inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true"));
ASSERT_NULL(strstr(inner, "self_call_policy"));
free(inner);
free(response);
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_trace_path_recursion_survives_budget_floor_issue2816) {
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 = "recursion-budget";
cbm_mcp_server_set_project(srv, proj);
ASSERT_EQ(cbm_store_upsert_project(st, proj, "/tmp/recursion-budget"), CBM_STORE_OK);
char huge[4096];
memset(huge, 'x', sizeof(huge) - 1);
huge[sizeof(huge) - 1] = '\0';
cbm_node_t n = {.project = proj,
.label = "Function",
.name = "countdown",
.qualified_name = "recursion-budget.countdown",
.file_path = "recursion.ts",
.start_line = 1,
.end_line = 10,
.properties_json = "{\"self_recursive\":true,\"recursive\":true}"};
int64_t root_id = cbm_store_upsert_node(st, &n);
ASSERT_GT(root_id, 0);
n.name = huge;
n.qualified_name = huge;
int64_t callee_id = cbm_store_upsert_node(st, &n);
ASSERT_GT(callee_id, 0);
cbm_edge_t edge = {
.project = proj, .source_id = root_id, .target_id = callee_id, .type = "CALLS"};
ASSERT_GT(cbm_store_insert_edge(st, &edge), 0);
for (int json = 0; json < 2; json++) {
char args[512];
snprintf(args, sizeof(args),
"{\"project\":\"%s\",\"function_name\":\"countdown\","
"\"direction\":\"outbound\",\"format\":\"%s\",\"max_output_tokens\":128}",
proj, json ? "json" : "tree");
char *response = cbm_mcp_handle_tool(srv, "trace_path", args);
char *inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "output_budget"));
ASSERT_NULL(strstr(inner, huge));
ASSERT_NOT_NULL(strstr(inner, json ? "\"self_recursive\":true" : "self_recursive: true\n"));
ASSERT_NOT_NULL(strstr(inner, json ? "\"recursive\":true" : "recursive: true\n"));
ASSERT_NOT_NULL(strstr(inner, json ? "\"self_call_policy\":\"omitted\""
: "self_call_policy: omitted\n"));
free(inner);
free(response);
}
cbm_mcp_server_free(srv);
PASS();
}
TEST(tool_trace_call_path_not_found) {
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
@@ -8100,7 +8294,8 @@ TEST(tool_trace_cursor_survives_output_budget_increase) {
.qualified_name = "trace-budget-cursor.hub",
.file_path = "src/hub.c",
.start_line = 1,
.end_line = 2};
.end_line = 2,
.properties_json = "{\"self_recursive\":true,\"recursive\":true}"};
cbm_node_t short_node = {.project = project,
.label = "Function",
.name = "short_row",
@@ -8140,6 +8335,9 @@ TEST(tool_trace_cursor_survives_output_budget_increase) {
"\"format\":\"json\"}");
char *inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true"));
ASSERT_NOT_NULL(strstr(inner, "\"recursive\":true"));
ASSERT_NOT_NULL(strstr(inner, "\"self_call_policy\":\"omitted\""));
ASSERT_NOT_NULL(strstr(inner, "short_row"));
yyjson_doc *doc = yyjson_read(inner, strlen(inner), 0);
ASSERT_NOT_NULL(doc);
@@ -8160,6 +8358,9 @@ TEST(tool_trace_cursor_survives_output_budget_increase) {
response = cbm_mcp_handle_tool(srv, "trace_path", args);
inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true"));
ASSERT_NOT_NULL(strstr(inner, "\"recursive\":true"));
ASSERT_NOT_NULL(strstr(inner, "\"self_call_policy\":\"omitted\""));
ASSERT_NULL(strstr(inner, "cursor_params_mismatch"));
ASSERT_NULL(strstr(inner, long_qn));
doc = yyjson_read(inner, strlen(inner), 0);
@@ -8179,6 +8380,9 @@ TEST(tool_trace_cursor_survives_output_budget_increase) {
response = cbm_mcp_handle_tool(srv, "trace_path", args);
inner = extract_text_content(response);
ASSERT_NOT_NULL(inner);
ASSERT_NOT_NULL(strstr(inner, "\"self_recursive\":true"));
ASSERT_NOT_NULL(strstr(inner, "\"recursive\":true"));
ASSERT_NOT_NULL(strstr(inner, "\"self_call_policy\":\"omitted\""));
ASSERT_NULL(strstr(inner, "cursor_params_mismatch"));
ASSERT_NULL(strstr(inner, "short_row"));
doc = yyjson_read(inner, strlen(inner), 0);
@@ -25961,6 +26165,9 @@ SUITE(mcp) {
RUN_TEST(tool_index_status_includes_git_metadata);
/* Tool handlers with validation */
RUN_TEST(tool_trace_path_recursion_metadata_issue2816);
RUN_TEST(tool_trace_path_recursion_union_and_edges_issue2816);
RUN_TEST(tool_trace_path_recursion_survives_budget_floor_issue2816);
RUN_TEST(tool_trace_call_path_not_found);
RUN_TEST(tool_call_invalid_project_name_leaves_no_corrupt_litter_issue1425);
RUN_TEST(tool_trace_missing_function_name);