diff --git a/src/cli/cli.c b/src/cli/cli.c index d9eb9ae02..1263298a0 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -9572,7 +9572,11 @@ static const char *cli_schema_type(yyjson_val *props, const char *key) { } /* Append a typed value to the output object under `key`. For array-typed - * properties, repeated flags accumulate into a single JSON array. */ + * properties, repeated flags accumulate into a single JSON array, and a value + * that is itself JSON array text ('["*"]', '["a","b"]') contributes its + * string elements instead of one literal element — otherwise the raw text + * would flow downstream as a bogus single value (e.g. a project literally + * named ["*"]). */ static void cli_add_typed(yyjson_mut_doc *out, yyjson_mut_val *obj, const char *key, const char *type, const char *value, bool have_value) { if (type && strcmp(type, "array") == 0) { @@ -9581,6 +9585,30 @@ static void cli_add_typed(yyjson_mut_doc *out, yyjson_mut_val *obj, const char * arr = yyjson_mut_arr(out); yyjson_mut_obj_add(obj, yyjson_mut_strcpy(out, key), arr); } + if (have_value && value[0] == '[') { + yyjson_doc *vdoc = yyjson_read(value, strlen(value), 0); + yyjson_val *vroot = vdoc ? yyjson_doc_get_root(vdoc) : NULL; + bool all_strings = vroot && yyjson_is_arr(vroot); + size_t idx; + size_t max; + yyjson_val *el; + if (all_strings) { + yyjson_arr_foreach(vroot, idx, max, el) { + if (!yyjson_is_str(el)) { + all_strings = false; + break; + } + } + } + if (all_strings) { + yyjson_arr_foreach(vroot, idx, max, el) { + yyjson_mut_arr_add_strcpy(out, arr, yyjson_get_str(el)); + } + yyjson_doc_free(vdoc); + return; + } + yyjson_doc_free(vdoc); + } yyjson_mut_arr_add_strcpy(out, arr, have_value ? value : ""); return; } diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index a681a7911..86ec14b19 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -10891,9 +10891,28 @@ static char *get_project_root(cbm_mcp_server_t *srv, const char *project) { /* ── index_repository ─────────────────────────────────────────── */ -/* Handle mode="cross-repo-intelligence" — extract to reduce complexity. */ -static char *handle_cross_repo_mode(const char *repo_path, const char *args) { - char *project = heap_strdup(cbm_project_name_from_path(repo_path)); +/* Handle mode="cross-repo-intelligence" — extract to reduce complexity. + * name_override (may be NULL) selects the same project a prior + * index_repository call with that name wrote; otherwise the project name is + * derived from repo_path exactly like a name-less indexing call. */ +static char *handle_cross_repo_mode(const char *repo_path, const char *name_override, + const char *args) { + char *project = NULL; + if (name_override && name_override[0]) { + project = cbm_project_name_from_path(name_override); + if (!project || !cbm_validate_project_name(project)) { + free(project); + return cbm_mcp_text_result( + "{\"error\":\"invalid name for cross-repo-intelligence mode\"," + "\"hint\":\"Pass the same name used when indexing, or omit name to use " + "the project derived from repo_path.\"}", + true); + } + } else { + /* cbm_project_name_from_path returns owned memory; the previous + * heap_strdup wrapper leaked the inner allocation. */ + project = cbm_project_name_from_path(repo_path); + } if (!project) { return cbm_mcp_text_result("cannot derive project name", true); } @@ -10925,6 +10944,18 @@ static char *handle_cross_repo_mode(const char *repo_path, const char *args) { free(targets); yyjson_doc_free(jdoc); + if (result.source_missing) { + char msg[CBM_SZ_512]; + snprintf(msg, sizeof(msg), + "{\"error\":\"project '%s' is not indexed; cross-repo-intelligence matches " + "existing indexes only\",\"hint\":\"Run index_repository on this repo_path " + "(with the same name, if any) first, then rerun cross-repo-intelligence. " + "Run list_projects to see indexed projects.\"}", + project); + free(project); + return cbm_mcp_text_result(msg, true); + } + int total = result.http_edges + result.async_edges + result.channel_edges + result.grpc_edges + result.graphql_edges + result.trpc_edges; yyjson_mut_doc *doc = yyjson_mut_doc_new(NULL); @@ -10934,6 +10965,9 @@ static char *handle_cross_repo_mode(const char *repo_path, const char *args) { yyjson_mut_obj_add_str(doc, root, "mode", "cross-repo-intelligence"); yyjson_mut_obj_add_strcpy(doc, root, "project", project); yyjson_mut_obj_add_int(doc, root, "projects_scanned", result.projects_scanned); + if (result.targets_missing > 0) { + yyjson_mut_obj_add_int(doc, root, "targets_missing", result.targets_missing); + } yyjson_mut_obj_add_int(doc, root, "cross_http_calls", result.http_edges); yyjson_mut_obj_add_int(doc, root, "cross_async_calls", result.async_edges); yyjson_mut_obj_add_int(doc, root, "cross_channel", result.channel_edges); @@ -11729,8 +11763,8 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) { if (mode_str && strcmp(mode_str, "cross-repo-intelligence") == 0) { free(mode_str); + char *result = handle_cross_repo_mode(repo_path, name_override, args); free(name_override); - char *result = handle_cross_repo_mode(repo_path, args); free(repo_path); CBM_PROF_END("index_repository", "cross_repo_mode", prof_index_args); CBM_PROF_END("index_repository", "TOTAL", prof_index_total); diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 13c39feec..099b92a42 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -134,24 +134,6 @@ static void insert_cross_edge(cbm_store_t *store, const char *project, int64_t f cbm_store_insert_edge(store, &edge); } -/* Strip "scheme://host[:port]" from a stored HTTP_CALLS url, returning the - * path. url_path property values are stored raw from the call's first string - * argument, so they can be full URLs ("scheme://host:port/v2/x") — and - * cbm_route_canon_path only canonicalizes placeholder syntax, never strips - * authorities. Returns "/" for a URL with no path after the host (a request - * against the bare base URL targets the root route). (#523) */ -static const char *cr_url_path(const char *url) { - if (!url) { - return url; - } - const char *scheme_end = strstr(url, "://"); - if (!scheme_end) { - return url; /* already a bare path */ - } - const char *path_start = strchr(scheme_end + CR_SCHEME_SKIP, '/'); - return path_start ? path_start : "/"; -} - /* Look up a node's name and file_path by id. */ static void lookup_node_info(struct sqlite3 *db, int64_t node_id, char *name_out, size_t name_sz, char *file_out, size_t file_sz) { @@ -419,7 +401,7 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project, continue; } - const char *route_path = cr_url_path(url_path); + const char *route_path = cbm_pipeline_route_url_path(url_path); char canonical_path[CBM_SZ_256]; cbm_route_canon_path(route_path, canonical_path, sizeof(canonical_path)); char route_qn[CBM_ROUTE_QN_SIZE]; @@ -772,9 +754,16 @@ cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **t struct timespec t0; clock_gettime(CLOCK_MONOTONIC, &t0); - /* Open source project store (read-write) */ + /* Open source project store (read-write). Opening creates a missing + * database, so check existence first: a never-indexed name (for example a + * typo or a mis-quoted target list) must be reported, not satisfied by a + * silently created empty store. */ char src_path[CR_PATH_BUF]; cr_db_path(project, src_path, sizeof(src_path)); + if (!cbm_file_exists(src_path)) { + result.source_missing = true; + return result; + } cbm_store_t *src_store = cbm_store_open_path(src_path); if (!src_store) { return result; @@ -806,6 +795,13 @@ cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **t char tgt_path[CR_PATH_BUF]; cr_db_path(tgt, tgt_path, sizeof(tgt_path)); + /* Skip never-indexed targets instead of creating an empty database + * for them; report the miss so callers can surface it. */ + if (!cbm_file_exists(tgt_path)) { + result.targets_missing++; + continue; + } + /* Open target store read-write (for bidirectional edge writes) */ cbm_store_t *tgt_store = cbm_store_open_path(tgt_path); if (!tgt_store) { diff --git a/src/pipeline/pass_cross_repo.h b/src/pipeline/pass_cross_repo.h index 5d2d4cfee..a5cb866bb 100644 --- a/src/pipeline/pass_cross_repo.h +++ b/src/pipeline/pass_cross_repo.h @@ -7,6 +7,8 @@ #include "store/store.h" +#include + /* Result of a cross-repo matching run. */ typedef struct { int http_edges; /* CROSS_HTTP_CALLS edges created */ @@ -16,6 +18,8 @@ typedef struct { int graphql_edges; /* CROSS_GRAPHQL_CALLS edges created */ int trpc_edges; /* CROSS_TRPC_CALLS edges created */ int projects_scanned; + int targets_missing; /* named targets with no indexed .db (skipped) */ + bool source_missing; /* source project has no indexed .db (nothing ran) */ double elapsed_ms; } cbm_cross_repo_result_t; @@ -24,8 +28,9 @@ typedef struct { * indexed projects. Writes CROSS_* edges bidirectionally into both the * source and target project DBs. * - * `project` must already be indexed (its .db must exist). - * Returns result with edge counts. */ + * `project` must already be indexed (its .db must exist): a missing source + * sets source_missing and runs nothing; a missing named target is skipped and + * counted in targets_missing. Neither creates a database. */ cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **target_projects, int target_count); diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 55ade3689..dc44cc3a2 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -42,8 +42,16 @@ enum { static const char *const RN_PROPS_INFRA_MATCH = "{\"source\":\"infra_match\"}"; static const char *const RN_PROPS_PREFIX_BRIDGE = "{\"source\":\"prefix_decorator_bridge\"}"; +static const char *const RN_PROPS_METHOD_VARIANT = "{\"source\":\"method_variant\"}"; static const char *const RN_SOURCE_INFRA_MATCH = "\"source\":\"infra_match\""; static const char *const RN_SOURCE_PREFIX_BRIDGE = "\"source\":\"prefix_decorator_bridge\""; +static const char *const RN_SOURCE_METHOD_VARIANT = "\"source\":\"method_variant\""; + +/* Concrete HTTP verbs, shared by method-variant rendezvous and the SvelteKit + * verb-export mapping ("ANY" is the wildcard method, not a verb). */ +static const char *const RN_HTTP_VERBS[] = {"GET", "POST", "PUT", "PATCH", + "DELETE", "OPTIONS", "HEAD"}; +enum { RN_HTTP_VERB_COUNT = sizeof(RN_HTTP_VERBS) / sizeof(RN_HTTP_VERBS[0]) }; /* True for characters that may appear in a ":name" route parameter. */ static inline bool is_route_ident_char(char c) { @@ -148,7 +156,12 @@ bool cbm_pipeline_build_service_route_identity(const char *path, cbm_svc_kind_t const char *qpath = path; if (svc == CBM_SVC_HTTP) { prefix = method ? method : CBM_ROUTE_DEFAULT_METHOD; - qpath = cbm_route_canon_path(path, cpath, sizeof(cpath)); + /* Strip any "scheme://host[:port]" first so a client-side full URL + * ("http://users-svc/api/x") builds the same canonical identity a + * server-side registration builds ("/api/x") — otherwise the caller's + * Route and the handler's Route never merge. Bare paths pass through + * unchanged. Async topics keep scheme-like text ("kafka://orders"). */ + qpath = cbm_route_canon_path(cbm_pipeline_route_url_path(path), cpath, sizeof(cpath)); } else if (svc == CBM_SVC_ASYNC) { prefix = broker ? broker : CBM_ROUTE_DEFAULT_ASYNC_BROKER; } else { @@ -188,6 +201,11 @@ bool cbm_pipeline_build_service_route_identity(const char *path, cbm_svc_kind_t int64_t cbm_pipeline_upsert_service_route(cbm_gbuf_t *gb, const char *path, cbm_svc_kind_t svc, const char *method, const char *broker, const char *source, const char *file_path) { + /* Display name follows the identity: HTTP routes are named by their bare + * path, never by a caller's full URL. */ + if (svc == CBM_SVC_HTTP) { + path = cbm_pipeline_route_url_path(path); + } char route_qn[CBM_ROUTE_QN_SIZE]; char route_props[CBM_SZ_256]; if (!cbm_pipeline_build_service_route_identity(path, svc, method, broker, source, route_qn, @@ -396,7 +414,7 @@ static void route_edge_visitor(const cbm_gbuf_edge_t *edge, void *userdata) { } /* Extract URL path from full URL: "https://host/path/" → "/path/" */ -static const char *url_path(const char *url) { +const char *cbm_pipeline_route_url_path(const char *url) { if (!url) { return NULL; } @@ -619,7 +637,7 @@ static void match_infra_routes(cbm_gbuf_t *gb) { continue; } - const char *infra_path = url_path(infra->name); + const char *infra_path = cbm_pipeline_route_url_path(infra->name); char svc_buf[CBM_SZ_128]; const char *svc_name = extract_service_name(infra->name, svc_buf, sizeof(svc_buf)); if (!infra_path || !svc_name) { @@ -648,6 +666,43 @@ static void match_infra_routes(cbm_gbuf_t *gb) { * During incremental indexing, only changed files get Route nodes from extraction. * This pass scans ALL Function/Method nodes and creates missing Route+HANDLES. */ +/* Rendezvous with call-minted method variants of the same path. A call site + * indexed before this registration mints "__route__GET__/p" (or the ANY + * variant when its method is unknown) with no handler, so caller→Route and + * handler→Route would split on method. Attach this handler to those variants, + * bridging only across ANY: an ANY registration covers every verb variant and + * a verb registration covers the ANY variant, but distinct concrete verbs + * stay separate (a POST call must not appear handled by a GET-only handler). + * Bounded exact-QN lookups; cbm_gbuf_insert_edge deduplicates HANDLES. */ +static void attach_one_method_variant(cbm_gbuf_t *gb, const cbm_gbuf_node_t *func, + const char *path, const char *variant_method) { + char variant_qn[CBM_ROUTE_QN_SIZE]; + char variant_props[CBM_SZ_256]; + if (!cbm_pipeline_build_service_route_identity(path, CBM_SVC_HTTP, variant_method, NULL, NULL, + variant_qn, sizeof(variant_qn), variant_props, + sizeof(variant_props))) { + return; + } + const cbm_gbuf_node_t *variant = cbm_gbuf_find_by_qn(gb, variant_qn); + if (variant) { + cbm_gbuf_insert_edge(gb, func->id, variant->id, "HANDLES", RN_PROPS_METHOD_VARIANT); + } +} + +static void attach_handler_to_method_variants(cbm_gbuf_t *gb, const cbm_gbuf_node_t *func, + const char *path, const char *method) { + if (strcmp(method, CBM_ROUTE_DEFAULT_METHOD) == 0) { + /* ANY registration covers every concrete verb variant. */ + for (int vi = 0; vi < RN_HTTP_VERB_COUNT; vi++) { + attach_one_method_variant(gb, func, path, RN_HTTP_VERBS[vi]); + } + } else { + /* A verb registration covers the ANY variant only; distinct concrete + * verbs stay separate. */ + attach_one_method_variant(gb, func, path, CBM_ROUTE_DEFAULT_METHOD); + } +} + /* Process a single Function/Method node: create Route+HANDLES if it has route_path. * Returns 1 if a new HANDLES edge was created, 0 otherwise. */ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *func) { @@ -699,6 +754,7 @@ static int ensure_one_decorator_route(cbm_gbuf_t *gb, const cbm_gbuf_node_t *fun snprintf(hprops, sizeof(hprops), "{\"handler\":\"%s\"}", func->qualified_name ? func->qualified_name : ""); cbm_gbuf_insert_edge(gb, func->id, route_id, "HANDLES", hprops); + attach_handler_to_method_variants(gb, func, path, method); return SKIP_ONE; } @@ -1372,12 +1428,9 @@ static const char *sveltekit_server_method(const char *name) { if (!name) { return NULL; } - static const char *const verbs[] = { - "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", - }; - for (size_t i = 0; i < sizeof(verbs) / sizeof(verbs[0]); i++) { - if (strcmp(name, verbs[i]) == 0) { - return verbs[i]; + for (int i = 0; i < RN_HTTP_VERB_COUNT; i++) { + if (strcmp(name, RN_HTTP_VERBS[i]) == 0) { + return RN_HTTP_VERBS[i]; } } /* `fallback` catches any verb not explicitly exported. */ @@ -1498,6 +1551,7 @@ void cbm_pipeline_clear_route_derived_edges(cbm_gbuf_t *gb) { cbm_gbuf_delete_edges_by_type(gb, "DATA_FLOWS"); cbm_gbuf_delete_edges_by_type_matching_props(gb, "HANDLES", RN_SOURCE_PREFIX_BRIDGE); cbm_gbuf_delete_edges_by_type_matching_props(gb, "HANDLES", RN_SOURCE_INFRA_MATCH); + cbm_gbuf_delete_edges_by_type_matching_props(gb, "HANDLES", RN_SOURCE_METHOD_VARIANT); } void cbm_pipeline_create_route_nodes(cbm_gbuf_t *gb) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index cccf59300..9c6a273c6 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -61,6 +61,12 @@ bool cbm_pipeline_build_service_route_identity(const char *path, cbm_svc_kind_t size_t route_qn_sz, char *route_props, size_t route_props_sz); +/* Strip "scheme://host[:port]" from a URL, returning the path ("/" when the + * URL has no path after the host); bare paths pass through unchanged. Shared + * by route minting and cross-repo matching so client-side full URLs and + * server-side registrations converge on the same route path. (#523) */ +const char *cbm_pipeline_route_url_path(const char *url); + int64_t cbm_pipeline_upsert_service_route(cbm_gbuf_t *gb, const char *path, cbm_svc_kind_t svc, const char *method, const char *broker, const char *source, const char *file_path); diff --git a/tests/repro/repro_issue523.c b/tests/repro/repro_issue523.c index 82d45b38d..cd486b758 100644 --- a/tests/repro/repro_issue523.c +++ b/tests/repro/repro_issue523.c @@ -57,6 +57,7 @@ #include #include +#include /* ── Fixture files ───────────────────────────────────────────────────────── */ @@ -475,6 +476,75 @@ TEST(repro_issue523_idempotent_cross_edges) { PASS(); } +/* + * TEST: the matcher must not CREATE databases for unknown project names. + * WHY RED on unfixed code: cbm_store_open_path creates a fresh empty store, + * so a never-indexed source or target name silently materializes an empty + * .db and the run reports success with zero matches. A CLI quoting + * mistake (passing the literal text ["*"] as a target name) previously + * created an empty database named ["*"].db this way. The header contract + * already says the project .db must exist. + */ +TEST(repro_issue523_missing_projects_not_created) { + RProj client, server; + bool ok = cr536_setup(&client, + "import requests\n" + "\n" + "\n" + "def fetch_ping():\n" + " \"\"\"Poll the ping endpoint.\"\"\"\n" + " return requests.get(\"/v2/ping\")\n", + &server, + "from flask import Flask, jsonify\n" + "\n" + "app = Flask(__name__)\n" + "\n" + "\n" + "@app.get(\"/v2/ping\")\n" + "def get_ping():\n" + " \"\"\"Return pong.\"\"\"\n" + " return jsonify({\"ping\": \"pong\"})\n"); + if (!ok) { + rh_cleanup(&client, NULL); + rh_cleanup(&server, NULL); + FAIL("fixture precondition failed (client HTTP_CALLS / server Route missing)"); + } + + const char *cache_dir = cbm_resolve_cache_dir(); + ASSERT_NOT_NULL(cache_dir); + char ghost_tgt_db[512]; + char ghost_src_db[512]; + snprintf(ghost_tgt_db, sizeof(ghost_tgt_db), "%s/repro523-ghost-target.db", cache_dir); + snprintf(ghost_src_db, sizeof(ghost_src_db), "%s/repro523-ghost-source.db", cache_dir); + + /* Unknown TARGET: skipped and reported, no database created, and the run + * still matches the real indexed target listed alongside it. */ + const char *targets[] = {"repro523-ghost-target", server.project}; + cbm_cross_repo_result_t r = cbm_cross_repo_match(client.project, targets, 2); + struct stat st; + fprintf(stderr, + " [523] ghost target: http=%d scanned=%d missing=%d ghost_db_exists=%d\n", + r.http_edges, r.projects_scanned, r.targets_missing, + stat(ghost_tgt_db, &st) == 0); + ASSERT_NEQ(stat(ghost_tgt_db, &st), 0); + ASSERT_EQ(r.targets_missing, 1); + ASSERT_EQ(r.projects_scanned, 1); + ASSERT_GTE(r.http_edges, 1); + + /* Unknown SOURCE: refused and reported, no database created. */ + const char *tgt = server.project; + cbm_cross_repo_result_t r2 = cbm_cross_repo_match("repro523-ghost-source", &tgt, 1); + fprintf(stderr, " [523] ghost source: http=%d source_missing=%d ghost_db_exists=%d\n", + r2.http_edges, r2.source_missing, stat(ghost_src_db, &st) == 0); + ASSERT_NEQ(stat(ghost_src_db, &st), 0); + ASSERT_TRUE(r2.source_missing); + ASSERT_EQ(r2.http_edges, 0); + + rh_cleanup(&client, NULL); + rh_cleanup(&server, NULL); + PASS(); +} + /* ── Suite ───────────────────────────────────────────────────────────────── */ SUITE(repro_issue523) { RUN_TEST(repro_issue523_crossrepo_http_calls_edge); @@ -482,4 +552,5 @@ SUITE(repro_issue523) { RUN_TEST(repro_issue523_template_fuzzy_match); RUN_TEST(repro_issue523_reverse_direction_match); RUN_TEST(repro_issue523_idempotent_cross_edges); + RUN_TEST(repro_issue523_missing_projects_not_created); } diff --git a/tests/test_cli.c b/tests/test_cli.c index ad1b0deb0..d13d0df1e 100644 --- a/tests/test_cli.c +++ b/tests/test_cli.c @@ -7022,6 +7022,29 @@ TEST(cli_build_args_json_repeated_array_issue680) { PASS(); } +/* An array-typed flag whose value is itself JSON array text must be parsed + * into its string elements, not wrapped as one literal element. Previously + * `--target-projects '["*"]'` produced ["[\"*\"]"]; the cross-repo matcher + * then treated that as a literal project name, silently created an empty + * database named ["*"].db, and reported success with zero matches. */ +TEST(cli_build_args_json_json_array_value) { + char *err = NULL; + char *argv[] = {"--target-projects", "[\"*\"]"}; + char *json = cbm_cli_build_args_json("index_repository", 2, argv, &err); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"target_projects\":[\"*\"]") != NULL); + ASSERT(strstr(json, "[\\\"*\\\"]") == NULL); + free(json); + + /* JSON-array values and plain values accumulate into one array. */ + char *argv2[] = {"--target-projects", "[\"a\",\"b\"]", "--target-projects", "c"}; + json = cbm_cli_build_args_json("index_repository", 4, argv2, &err); + ASSERT_NOT_NULL(json); + ASSERT(strstr(json, "\"target_projects\":[\"a\",\"b\",\"c\"]") != NULL); + free(json); + PASS(); +} + /* kebab-case flag names map to snake_case JSON keys. */ TEST(cli_build_args_json_kebab_to_snake_issue680) { char *err = NULL; @@ -7455,6 +7478,7 @@ SUITE(cli) { RUN_TEST(cli_build_args_json_bare_boolean_issue680); RUN_TEST(cli_build_args_json_unknown_flag_rejected); RUN_TEST(cli_build_args_json_repeated_array_issue680); + RUN_TEST(cli_build_args_json_json_array_value); RUN_TEST(cli_build_args_json_kebab_to_snake_issue680); RUN_TEST(cli_build_args_json_key_equals_value_issue680); RUN_TEST(cli_build_args_json_bad_positional_errors_issue680); diff --git a/tests/test_mcp.c b/tests/test_mcp.c index c24cd7014..f2bdba876 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -1822,6 +1822,27 @@ TEST(first_response_context_uses_ready_overlay_schema) { PASS(); } +/* cross-repo-intelligence must honor the `name` override exactly like an + * indexing call. Previously the mode derived the project from repo_path and + * silently matched a different (possibly never-indexed) project than the one + * indexed under `name`. The missing-source error must cite the overridden + * name, proving the override was used, and must not create a database. */ +TEST(tool_cross_repo_mode_honors_name_override) { + cbm_mcp_server_t *srv = setup_mcp_with_data(); + + char *resp = cbm_mcp_handle_tool( + srv, "index_repository", + "{\"repo_path\":\"/tmp/cbm-nonexistent-cross-src\",\"mode\":\"cross-repo-intelligence\"," + "\"name\":\"cross-name-override\",\"target_projects\":[\"*\"]}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "cross-name-override")); + ASSERT_NOT_NULL(strstr(resp, "not indexed")); + free(resp); + + cbm_mcp_server_free(srv); + PASS(); +} + TEST(tool_unknown_tool) { cbm_mcp_server_t *srv = setup_mcp_with_data(); @@ -12774,6 +12795,7 @@ SUITE(mcp) { RUN_TEST(tool_get_graph_schema_empty); RUN_TEST(tool_get_graph_schema_uses_ready_overlay_schema); RUN_TEST(first_response_context_uses_ready_overlay_schema); + RUN_TEST(tool_cross_repo_mode_honors_name_override); RUN_TEST(tool_unknown_tool); RUN_TEST(tool_unknown_argument_is_actionable_execution_error); RUN_TEST(tool_search_code_legacy_search_in_is_bounded_and_actionable); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 11125a061..d2c69f79d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -1596,6 +1596,117 @@ cleanup: return ok; } +/* A caller whose HTTP call uses a FULL URL literal must join the same + * canonical Route node the handler HANDLES. Previously route minting used the + * raw url_path, so "http://users-svc:5000/api/users" minted a second Route + * distinct from the registration's "/api/users": caller→Route and + * handler→Route never met and the cross-service join query returned nothing. */ +static int pipeline_full_url_call_joins_canonical_route_case(void) { + char tmp[CBM_SZ_256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_url_route_join_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + return 0; + } + + write_temp_file(tmp, "service/app.py", + "from flask import Flask, jsonify\n\n" + "app = Flask(__name__)\n\n\n" + "@app.route(\"/api/users\", methods=[\"GET\"])\n" + "def get_users():\n" + " return jsonify([])\n"); + write_temp_file(tmp, "client/consumer.py", + "import requests\n\n\n" + "def fetch_users():\n" + " return requests.get(\"http://users-svc:5000/api/users\").json()\n"); + + char db_path[CBM_PATH_MAX]; + int n = snprintf(db_path, sizeof(db_path), "%s/urljoin.db", tmp); + int ok = n > 0 && (size_t)n < sizeof(db_path); + cbm_pipeline_t *pipeline = ok ? cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL) : NULL; + cbm_store_t *store = NULL; + cbm_node_t *routes = NULL; + int route_count = 0; + cbm_edge_t *http_edges = NULL; + int http_count = 0; + cbm_edge_t *handles_edges = NULL; + int handles_count = 0; + const char *project = NULL; + if (!pipeline || cbm_pipeline_run(pipeline) != 0) { + ok = 0; + goto cleanup; + } + + store = cbm_store_open_path(db_path); + project = cbm_pipeline_project_name(pipeline); + if (!store) { + ok = 0; + goto cleanup; + } + + /* No Route node may embed a scheme/authority. */ + if (cbm_store_find_nodes_by_label(store, project, "Route", &routes, &route_count) != + CBM_STORE_OK || + route_count < 1) { + ok = 0; + goto cleanup; + } + for (int i = 0; i < route_count; i++) { + if (routes[i].name && strstr(routes[i].name, "://")) { + ok = 0; + } + } + + /* The join: at least one HTTP_CALLS edge must target the same Route node + * that a HANDLES edge targets, and that Route must be "/api/users". */ + if (cbm_store_find_edges_by_type(store, project, "HTTP_CALLS", &http_edges, &http_count) != + CBM_STORE_OK || + cbm_store_find_edges_by_type(store, project, "HANDLES", &handles_edges, &handles_count) != + CBM_STORE_OK) { + ok = 0; + goto cleanup; + } + bool joined = false; + for (int i = 0; i < http_count && !joined; i++) { + for (int j = 0; j < handles_count && !joined; j++) { + if (http_edges[i].target_id != handles_edges[j].target_id) { + continue; + } + cbm_node_t route = {0}; + if (cbm_store_find_node_by_id(store, http_edges[i].target_id, &route) == + CBM_STORE_OK && + route.name && strcmp(route.name, "/api/users") == 0) { + joined = true; + } + cbm_node_free_fields(&route); + } + } + if (!joined) { + ok = 0; + } + +cleanup: + if (!ok && store) { + fprintf(stderr, " [URL-JOIN] routes=%d http=%d handles=%d\n", route_count, http_count, + handles_count); + for (int i = 0; i < route_count; i++) { + fprintf(stderr, " route name=%s qn=%s\n", routes[i].name ? routes[i].name : "", + routes[i].qualified_name ? routes[i].qualified_name : ""); + } + } + cbm_store_free_edges(http_edges, http_count); + cbm_store_free_edges(handles_edges, handles_count); + cbm_store_free_nodes(routes, route_count); + cbm_store_close(store); + cbm_pipeline_free(pipeline); + th_rmtree(tmp); + return ok; +} + +TEST(pipeline_full_url_call_joins_canonical_route) { + ASSERT_TRUE(pipeline_full_url_call_joins_canonical_route_case()); + PASS(); +} + TEST(pipeline_route_discovery_uses_canonical_identities_sequential) { ASSERT_TRUE(pipeline_route_discovery_uses_canonical_identities_case(false)); PASS(); @@ -17911,6 +18022,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_full_and_incremental_persist_file_state); RUN_TEST(pipeline_incremental_full_index_rebuilds_owner_metadata); RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_full_url_call_joins_canonical_route); RUN_TEST(pipeline_route_discovery_uses_canonical_identities_sequential); RUN_TEST(pipeline_route_discovery_uses_canonical_identities_parallel); RUN_TEST(pipeline_httplink_collection_has_no_fixed_item_ceiling);