diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 75ff8e396c..3b3f2806ca 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -9921,10 +9921,16 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path free(lease_keys); yyjson_doc_free(jdoc); - if (result.failed) { + if (result.no_targets || result.failed) { free(project); return cbm_mcp_text_result( - "cross-repo source or target project is missing, invalid, or not indexed", true); + result.no_targets + ? "cross-repo-intelligence resolved zero target projects: no indexed project " + "other than the source matched target_projects. Index the other service first " + "(run list_projects to see what is indexed); existing cross-repo edges were " + "left unchanged." + : "cross-repo source or target project is missing, invalid, or not indexed", + true); } int total = result.http_edges + result.async_edges + result.channel_edges + result.grpc_edges + diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 9ce0e50fbc..f65b64f8c3 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -1239,17 +1239,33 @@ cbm_cross_repo_result_t cbm_cross_repo_match_cancellable(const char *project, result.failed = !result.cancelled; return result; } + int other_targets = 0; for (int i = 0; i < resolved_count; i++) { if (cr_cancel_requested(&run)) { result.cancelled = true; free_project_list(resolved, resolved_count); return result; } - if (strcmp(resolved[i], project) != 0 && !cr_project_exists(resolved[i])) { + if (strcmp(resolved[i], project) == 0) { + continue; + } + if (!cr_project_exists(resolved[i])) { result.failed = true; free_project_list(resolved, resolved_count); return result; } + other_targets++; + } + /* Nothing but the source itself to match against (a self-only list, or + * ["*"] in a store holding only the source). Reporting that as success + * with projects_scanned:0 is indistinguishable from "these services share + * no routes", and the source's existing CROSS_* edges would be wiped below + * with nothing to rebuild them. Fail before any write. (#1133) */ + if (other_targets == 0) { + result.failed = true; + result.no_targets = true; + free_project_list(resolved, resolved_count); + return result; } /* Every input is known to exist before destructive source cleanup. The diff --git a/src/pipeline/pass_cross_repo.h b/src/pipeline/pass_cross_repo.h index 10ee0199ab..898283c3e3 100644 --- a/src/pipeline/pass_cross_repo.h +++ b/src/pipeline/pass_cross_repo.h @@ -20,6 +20,7 @@ typedef struct { int projects_scanned; double elapsed_ms; bool failed; /* source/target validation, open, or allocation failed */ + bool no_targets; /* failed: nothing but the source project resolved (#1133) */ bool cancelled; /* stopped at a bounded cancellation checkpoint */ bool partial_results; /* committed writes before cancellation were retained */ } cbm_cross_repo_result_t; diff --git a/tests/test_cli.c b/tests/test_cli.c index 01fb79ec8d..d18f442795 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -15228,6 +15228,28 @@ TEST(cli_build_args_json_array_flag_accepts_json_literal) { PASS(); } +/* #1133: `index_repository --target-projects '["*"]'` (the form the help + * documents) must reach the cross-repo matcher as a one-element array, not as + * a single string holding the literal text -- that shape resolved to zero + * targets and returned a silent "success" with projects_scanned:0. */ +TEST(cli_build_args_json_target_projects_literal_issue1133) { + char *err = NULL; + char *argv[] = {"--repo-path", "/r", "--mode", "cross-repo-intelligence", + "--target-projects", "[\"*\"]"}; + char *json = cbm_cli_build_args_json("index_repository", 6, argv, &err); + ASSERT_NOT_NULL(json); + ASSERT_NULL(err); + ASSERT(strstr(json, "\"target_projects\":[\"*\"]") != NULL); + free(json); + + char *argv2[] = {"--repo-path", "/r", "--target-projects", "[\"svc-a\",\"svc-b\"]"}; + json = cbm_cli_build_args_json("index_repository", 4, argv2, &err); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"target_projects\":[\"svc-a\",\"svc-b\"]") != NULL); + free(json); + PASS(); +} + /* An unknown flag for a KNOWN tool must be rejected loudly, not silently * typed as a string and dropped server-side (#997). GF1 eval: `trace_path * --max-depth 1` was accepted, the real --depth stayed at default 3, and @@ -16385,6 +16407,7 @@ SUITE(cli) { RUN_TEST(cli_build_args_json_integer_flag_issue680); RUN_TEST(cli_build_args_json_bare_boolean_issue680); RUN_TEST(cli_build_args_json_array_flag_accepts_json_literal); + RUN_TEST(cli_build_args_json_target_projects_literal_issue1133); RUN_TEST(cli_build_args_json_unknown_flag_rejected); RUN_TEST(cli_build_args_json_repeated_array_issue680); RUN_TEST(cli_build_args_json_kebab_to_snake_issue680); diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 7b8af9bbd2..365154a62e 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -469,6 +469,58 @@ TEST(cross_repo_pre_cancel_preserves_existing_cross_edges) { PASS(); } +/* #1133: a run whose targets resolve to nothing but the source project must + * fail explicitly. It used to report "success" with projects_scanned:0 -- + * indistinguishable from "these services share no routes" -- and, worse, it + * had already wiped the source's existing CROSS_* edges before noticing there + * was nothing to match against. */ +TEST(cross_repo_self_only_target_fails_and_keeps_edges_issue1133) { + cross_repo_fixture_t fixture; + bool setup = + cross_repo_fixture_begin(&fixture) && + cross_repo_seed_http_pair(&fixture, "self-source", "self-target", "/self-only", "self"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed self-only fixture"); + } + + const char *target = "self-target"; + cbm_cross_repo_result_t initial = cbm_cross_repo_match("self-source", &target, 1); + int before = cross_repo_count_edges(&fixture, "self-source", "CROSS_HTTP_CALLS"); + const char *self = "self-source"; + cbm_cross_repo_result_t result = cbm_cross_repo_match("self-source", &self, 1); + int after = cross_repo_count_edges(&fixture, "self-source", "CROSS_HTTP_CALLS"); + cross_repo_fixture_end(&fixture); + + ASSERT_FALSE(initial.failed); + ASSERT_TRUE(before > 0); + ASSERT_TRUE(result.failed); + ASSERT_TRUE(result.no_targets); + ASSERT_EQ(result.projects_scanned, 0); + ASSERT_EQ(after, before); + PASS(); +} + +/* #1133: ["*"] in a store that holds only the source project resolves to zero + * targets -- same contract as an explicit self-only list. */ +TEST(cross_repo_wildcard_with_no_other_project_fails_issue1133) { + cross_repo_fixture_t fixture; + if (!cross_repo_fixture_begin(&fixture) || + !cross_repo_create_project(&fixture, "lonely-source")) { + cross_repo_fixture_end(&fixture); + FAIL("failed to create isolated source project"); + } + + const char *targets[] = {"*"}; + cbm_cross_repo_result_t result = cbm_cross_repo_match("lonely-source", targets, 1); + cross_repo_fixture_end(&fixture); + + ASSERT_TRUE(result.failed); + ASSERT_TRUE(result.no_targets); + ASSERT_EQ(result.projects_scanned, 0); + PASS(); +} + /* Add the internal "::missed" miss-graph row that indexing writes into * the SAME db whenever a file parses partially. */ static bool cross_repo_add_missed_shadow(const cross_repo_fixture_t *fixture, const char *project) { @@ -528,4 +580,6 @@ SUITE(cross_repo) { RUN_TEST(cross_repo_failed_bidirectional_insert_is_not_counted); RUN_TEST(cross_repo_cancel_mid_run_keeps_completed_target_and_stops_before_later_target); RUN_TEST(cross_repo_pre_cancel_preserves_existing_cross_edges); + RUN_TEST(cross_repo_self_only_target_fails_and_keeps_edges_issue1133); + RUN_TEST(cross_repo_wildcard_with_no_other_project_fails_issue1133); }