Skip to content

Commit 6e0f7f7

Browse files
authored
Merge pull request #466 from KerseyFabrications/fix/name-resolution
fix(mcp): prefer definitions and report ambiguity in name resolution
2 parents c3b5b00 + 382dc24 commit 6e0f7f7

2 files changed

Lines changed: 192 additions & 2 deletions

File tree

src/mcp/mcp.c

Lines changed: 93 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2288,6 +2288,66 @@ static yyjson_mut_val *bfs_to_json_array(yyjson_mut_doc *doc, cbm_traverse_resul
22882288
return arr;
22892289
}
22902290

2291+
static char *snippet_suggestions(const char *input, cbm_node_t *nodes, int count);
2292+
2293+
/* Rank a candidate for name resolution. The label tier (callable > class-like >
2294+
* module/file) is the primary key; WITHIN a tier the larger definition by line
2295+
* span wins. In practice the .c-over-.h and C-main-over-shell-main preferences
2296+
* come primarily from span (the real definition has the larger body), since the
2297+
* competing matches usually share a tier — no file extension is hardcoded.
2298+
* Consequence: two same-tier candidates with equal span tie and are reported
2299+
* ambiguous (see pick_resolved_node) rather than guessed. */
2300+
enum {
2301+
RES_RANK_CALLABLE = 2, /* Function / Method */
2302+
RES_RANK_OTHER = 1, /* Class / Struct / etc. */
2303+
RES_RANK_MODULE = 0, /* Module / File */
2304+
RES_LABEL_WEIGHT = 1000000 /* label tier dominates span */
2305+
};
2306+
static long node_resolution_score(const cbm_node_t *n) {
2307+
long label_rank = RES_RANK_MODULE;
2308+
if (n->label) {
2309+
if (strcmp(n->label, "Function") == 0 || strcmp(n->label, "Method") == 0) {
2310+
label_rank = RES_RANK_CALLABLE;
2311+
} else if (strcmp(n->label, "Module") != 0 && strcmp(n->label, "File") != 0) {
2312+
label_rank = RES_RANK_OTHER;
2313+
}
2314+
}
2315+
long span = (long)n->end_line - (long)n->start_line;
2316+
if (span < 0) {
2317+
span = 0;
2318+
}
2319+
return label_rank * (long)RES_LABEL_WEIGHT + span;
2320+
}
2321+
2322+
/* Pick the best-resolving node among name matches. Sets *ambiguous when the top
2323+
* score is shared by more than one candidate (a genuine tie the caller must
2324+
* disambiguate) so resolution never silently traces the wrong same-named node. */
2325+
static int pick_resolved_node(const cbm_node_t *nodes, int count, bool *ambiguous) {
2326+
*ambiguous = false;
2327+
if (count <= 1) {
2328+
return 0;
2329+
}
2330+
int best = 0;
2331+
long best_score = node_resolution_score(&nodes[0]);
2332+
for (int i = 1; i < count; i++) {
2333+
long s = node_resolution_score(&nodes[i]);
2334+
if (s > best_score) {
2335+
best_score = s;
2336+
best = i;
2337+
}
2338+
}
2339+
int top_count = 0;
2340+
for (int i = 0; i < count; i++) {
2341+
if (node_resolution_score(&nodes[i]) == best_score) {
2342+
top_count++;
2343+
}
2344+
}
2345+
if (top_count > 1) {
2346+
*ambiguous = true;
2347+
}
2348+
return best;
2349+
}
2350+
22912351
static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
22922352
char *func_name = cbm_mcp_get_string_arg(args, "function_name");
22932353
char *project = cbm_mcp_get_string_arg(args, "project");
@@ -2372,6 +2432,22 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
23722432
return cbm_mcp_text_result(hint, true);
23732433
}
23742434

2435+
/* Disambiguate same-named matches: prefer the real definition, and report
2436+
* ambiguity (rather than silently tracing nodes[0]) on a genuine tie — e.g.
2437+
* a C main() vs a same-named shell-script main(). */
2438+
bool trace_ambiguous = false;
2439+
int sel = pick_resolved_node(nodes, node_count, &trace_ambiguous);
2440+
if (trace_ambiguous) {
2441+
char *result = snippet_suggestions(func_name, nodes, node_count);
2442+
free(func_name);
2443+
free(project);
2444+
free(direction);
2445+
free(mode);
2446+
free(param_name);
2447+
cbm_store_free_nodes(nodes, node_count);
2448+
return result;
2449+
}
2450+
23752451
yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL);
23762452
yyjson_mut_val *root = yyjson_mut_obj(doc);
23772453
yyjson_mut_doc_set_root(doc, root);
@@ -2397,14 +2473,14 @@ static char *handle_trace_call_path(cbm_mcp_server_t *srv, const char *args) {
23972473
cbm_traverse_result_t tr_in = {0};
23982474

23992475
if (do_outbound) {
2400-
cbm_store_bfs(store, nodes[0].id, "outbound", edge_types, edge_type_count, depth,
2476+
cbm_store_bfs(store, nodes[sel].id, "outbound", edge_types, edge_type_count, depth,
24012477
MCP_BFS_LIMIT, &tr_out);
24022478
yyjson_mut_obj_add_val(doc, root, "callees",
24032479
bfs_to_json_array(doc, &tr_out, risk_labels, include_tests));
24042480
}
24052481

24062482
if (do_inbound) {
2407-
cbm_store_bfs(store, nodes[0].id, "inbound", edge_types, edge_type_count, depth,
2483+
cbm_store_bfs(store, nodes[sel].id, "inbound", edge_types, edge_type_count, depth,
24082484
MCP_BFS_LIMIT, &tr_in);
24092485
yyjson_mut_obj_add_val(doc, root, "callers",
24102486
bfs_to_json_array(doc, &tr_in, risk_labels, include_tests));
@@ -3046,6 +3122,21 @@ static char *handle_get_code_snippet(cbm_mcp_server_t *srv, const char *args) {
30463122
}
30473123

30483124
if (suffix_count > SKIP_ONE) {
3125+
/* Prefer the real definition (a .c body over a .h declaration, a Function
3126+
* over a Module) so an unambiguous-by-preference match resolves directly
3127+
* instead of forcing a disambiguation round trip; only a genuine tie still
3128+
* returns suggestions. */
3129+
bool snip_ambiguous = false;
3130+
int ssel = pick_resolved_node(suffix_nodes, suffix_count, &snip_ambiguous);
3131+
if (!snip_ambiguous) {
3132+
copy_node(&suffix_nodes[ssel], &node);
3133+
cbm_store_free_nodes(suffix_nodes, suffix_count);
3134+
char *result = build_snippet_response(srv, &node, "suffix", include_neighbors, NULL, 0);
3135+
free_node_contents(&node);
3136+
free(qn);
3137+
free(project);
3138+
return result;
3139+
}
30493140
char *result = snippet_suggestions(qn, suffix_nodes, suffix_count);
30503141
cbm_store_free_nodes(suffix_nodes, suffix_count);
30513142
free(qn);

tests/test_mcp.c

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -612,6 +612,103 @@ TEST(tool_trace_missing_function_name) {
612612
PASS();
613613
}
614614

615+
/* Regression: two same-named definitions with equal rank must be reported
616+
* ambiguous, not silently traced (trace_path previously took nodes[0]). */
617+
TEST(tool_trace_call_path_ambiguous) {
618+
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
619+
cbm_store_t *st = cbm_mcp_server_store(srv);
620+
const char *proj = "amb-proj";
621+
cbm_mcp_server_set_project(srv, proj);
622+
cbm_store_upsert_project(st, proj, "/tmp/amb");
623+
cbm_node_t a = {.project = proj,
624+
.label = "Function",
625+
.name = "amb",
626+
.qualified_name = "amb-proj.a.amb",
627+
.file_path = "a.c",
628+
.start_line = 10,
629+
.end_line = 20};
630+
cbm_node_t b = {.project = proj,
631+
.label = "Function",
632+
.name = "amb",
633+
.qualified_name = "amb-proj.b.amb",
634+
.file_path = "b.c",
635+
.start_line = 10,
636+
.end_line = 20}; /* equal span -> genuine tie */
637+
ASSERT_GT(cbm_store_upsert_node(st, &a), 0);
638+
ASSERT_GT(cbm_store_upsert_node(st, &b), 0);
639+
640+
char *resp = cbm_mcp_server_handle(
641+
srv, "{\"jsonrpc\":\"2.0\",\"id\":61,\"method\":\"tools/call\","
642+
"\"params\":{\"name\":\"trace_call_path\","
643+
"\"arguments\":{\"function_name\":\"amb\",\"project\":\"amb-proj\"}}}");
644+
ASSERT_NOT_NULL(resp);
645+
char *inner = extract_text_content(resp);
646+
ASSERT_NOT_NULL(inner);
647+
ASSERT_NOT_NULL(strstr(inner, "ambiguous"));
648+
ASSERT_NOT_NULL(strstr(inner, "suggestions"));
649+
ASSERT_NULL(strstr(inner, "\"callees\""));
650+
free(inner);
651+
free(resp);
652+
cbm_mcp_server_free(srv);
653+
PASS();
654+
}
655+
656+
/* Regression: when same-named nodes differ in rank, trace must pick the real
657+
* definition (callable, larger body) — NOT nodes[0]. The Module is inserted
658+
* first; if trace took nodes[0] the outbound trace would be empty. */
659+
TEST(tool_trace_call_path_prefers_definition) {
660+
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
661+
cbm_store_t *st = cbm_mcp_server_store(srv);
662+
const char *proj = "pref-proj";
663+
cbm_mcp_server_set_project(srv, proj);
664+
cbm_store_upsert_project(st, proj, "/tmp/pref");
665+
/* nodes[0]: the WRONG match (a Module, tiny span), inserted first. */
666+
cbm_node_t wrong = {.project = proj,
667+
.label = "Module",
668+
.name = "dup",
669+
.qualified_name = "pref-proj.dup",
670+
.file_path = "dup.x",
671+
.start_line = 1,
672+
.end_line = 1};
673+
/* the real definition: a Function with a body. */
674+
cbm_node_t def = {.project = proj,
675+
.label = "Function",
676+
.name = "dup",
677+
.qualified_name = "pref-proj.src.dup",
678+
.file_path = "src/dup.c",
679+
.start_line = 10,
680+
.end_line = 50};
681+
cbm_node_t callee = {.project = proj,
682+
.label = "Function",
683+
.name = "callee",
684+
.qualified_name = "pref-proj.src.callee",
685+
.file_path = "src/dup.c",
686+
.start_line = 60,
687+
.end_line = 70};
688+
ASSERT_GT(cbm_store_upsert_node(st, &wrong), 0);
689+
int64_t id_def = cbm_store_upsert_node(st, &def);
690+
int64_t id_callee = cbm_store_upsert_node(st, &callee);
691+
ASSERT_GT(id_def, 0);
692+
ASSERT_GT(id_callee, 0);
693+
cbm_edge_t e = {.project = proj, .source_id = id_def, .target_id = id_callee, .type = "CALLS"};
694+
cbm_store_insert_edge(st, &e);
695+
696+
char *resp = cbm_mcp_server_handle(
697+
srv, "{\"jsonrpc\":\"2.0\",\"id\":62,\"method\":\"tools/call\","
698+
"\"params\":{\"name\":\"trace_call_path\",\"arguments\":{\"function_name\":\"dup\","
699+
"\"project\":\"pref-proj\",\"direction\":\"outbound\"}}}");
700+
ASSERT_NOT_NULL(resp);
701+
char *inner = extract_text_content(resp);
702+
ASSERT_NOT_NULL(inner);
703+
ASSERT_NULL(strstr(inner, "ambiguous"));
704+
/* picked the Function definition -> its outbound CALLS edge to "callee" shows */
705+
ASSERT_NOT_NULL(strstr(inner, "callee"));
706+
free(inner);
707+
free(resp);
708+
cbm_mcp_server_free(srv);
709+
PASS();
710+
}
711+
615712
TEST(tool_delete_project_not_found) {
616713
cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL);
617714

@@ -2126,6 +2223,8 @@ SUITE(mcp) {
21262223
/* Tool handlers with validation */
21272224
RUN_TEST(tool_trace_call_path_not_found);
21282225
RUN_TEST(tool_trace_missing_function_name);
2226+
RUN_TEST(tool_trace_call_path_ambiguous);
2227+
RUN_TEST(tool_trace_call_path_prefers_definition);
21292228
RUN_TEST(tool_delete_project_not_found);
21302229
RUN_TEST(tool_get_architecture_empty);
21312230
RUN_TEST(tool_get_architecture_emits_populated_sections);

0 commit comments

Comments
 (0)