From 6d508350c7713e10e0e935625ad74d8401fe89e2 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 25 Sep 2026 18:37:16 +0200 Subject: [PATCH 1/2] fix(cross-repo): fail explicitly when zero target projects resolve (#1133) A cross-repo-intelligence run whose targets resolved to nothing but the source project -- a self-only target list, or ["*"] in a store that holds only the source -- returned status "success" with projects_scanned:0. That output is indistinguishable from "these services share no routes", which is exactly the silent-zero result #1133 reports. Worse, the run had already passed the pre-write validation and went on to delete the source's existing CROSS_* edges, with no target to rebuild them from. Root cause: cbm_cross_repo_match_cancellable validated that every resolved target exists but never checked that at least one resolved target differs from the source; the self entry was only skipped later, inside the match loop, after delete_cross_edges had run. Count the non-self targets during validation and fail before any write when there are none, with a new result flag (no_targets) so the MCP/CLI frontend reports a specific error ("resolved zero target projects ... existing cross-repo edges were left unchanged") and exits non-zero. The other halves of #1133 are already resolved on main: the --target-projects '["*"]' flag form has parsed as a JSON array since 1a342553 (guarded here by an index_repository-specific CLI test), and cross-repo mode has run in-process ahead of the index supervisor since 0e00ef57, so a cross-repo request can no longer surface the contained worker-crash envelope with a 0-byte log (worker-log survival: 87717b0a). Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 8 +++++ src/pipeline/pass_cross_repo.c | 18 +++++++++++- src/pipeline/pass_cross_repo.h | 1 + tests/test_cli.c | 23 +++++++++++++++ tests/test_cross_repo.c | 54 ++++++++++++++++++++++++++++++++++ 5 files changed, 103 insertions(+), 1 deletion(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 75ff8e396c..7acc401508 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -9921,6 +9921,14 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path free(lease_keys); yyjson_doc_free(jdoc); + if (result.no_targets) { + free(project); + return cbm_mcp_text_result( + "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.", + true); + } if (result.failed) { free(project); return cbm_mcp_text_result( 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); } From b7bcb15c219761f27ff146063a50481303527563 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 25 Sep 2026 22:38:28 +0200 Subject: [PATCH 2/2] chore(lint): share one free() between the cross-repo failure returns The no_targets early return added a second raw free(project) next to the existing result.failed one, growing src/mcp/mcp.c past its memory-core ratchet (784 -> 785). Fold both into one branch that picks the message; no_targets still takes precedence and both texts are unchanged. Signed-off-by: Martin Vogel --- src/mcp/mcp.c | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 7acc401508..3b3f2806ca 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -9921,19 +9921,17 @@ static char *handle_cross_repo_mode(cbm_mcp_server_t *srv, const char *repo_path free(lease_keys); yyjson_doc_free(jdoc); - if (result.no_targets) { + if (result.no_targets || result.failed) { free(project); return cbm_mcp_text_result( - "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.", + 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); } - if (result.failed) { - free(project); - return cbm_mcp_text_result( - "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 + result.graphql_edges + result.trpc_edges;