fix(cross-repo): honor name override, report missing projects, join full-URL routes

Four defects found while exercising cross-service route linking end to end:

1. index_repository mode=cross-repo-intelligence parsed the name override and then ignored it, deriving the project from repo_path, so it matched (or auto-created) a different project than the one indexed under --name. handle_cross_repo_mode now resolves name via cbm_project_name_from_path + cbm_validate_project_name exactly like indexing, and the derived-name path no longer leaks the cbm_project_name_from_path allocation.

2. cbm_cross_repo_match opened project stores with create-on-open, so a never-indexed source or target name silently materialized an empty <name>.db and reported success with zero matches (observed as a database literally named ["*"].db). Missing sources set source_missing and run nothing; missing named targets are skipped and counted in targets_missing; neither creates a database. The MCP response surfaces targets_missing and returns an actionable error for a missing source.

3. cbm_cli_build_args_json wrapped a JSON-array-shaped value ('["*"]') as one literal array element; array-typed flags now parse all-string JSON array values into elements while plain values and repeated flags accumulate as before.

4. A caller using a full URL literal (requests.get("http://users-svc:5000/api/users")) minted a Route named by the raw URL that never merged with the registration's canonical /api/users Route, so caller->Route and handler->Route stayed disconnected. Route identity and display names now strip scheme/authority through cbm_pipeline_route_url_path (shared with pass_cross_repo, replacing its duplicate cr_url_path), and phase 2a attaches handlers to already-minted method-variant routes across ANY (GET registration covers the ANY variant; ANY registration covers each concrete verb) with RN_SOURCE_METHOD_VARIANT ownership for incremental cleanup.

Verification: new tests cli_build_args_json_json_array_value, tool_cross_repo_mode_honors_name_override, pipeline_full_url_call_joins_canonical_route, and repro_issue523_missing_projects_not_created each failed before their fix and pass after; suites under ASan/UBSan: cli 236, mcp 272, pipeline 385, parallel 36, edge_types_probe 57, route_canon 15, httplink 39, incremental 164, repro_issue523 6/6; full make -f Makefile.cbm test exits 0.
Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
Andrew Hundt
2026-07-21 18:53:32 -04:00
parent fdf3d5aab8
commit 2aaf44d730
10 changed files with 388 additions and 36 deletions
+29 -1
View File
@@ -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;
}
+38 -4
View File
@@ -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);
+16 -20
View File
@@ -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) {
+7 -2
View File
@@ -7,6 +7,8 @@
#include "store/store.h"
#include <stdbool.h>
/* 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);
+63 -9
View File
@@ -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) {
+6
View File
@@ -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);
+71
View File
@@ -57,6 +57,7 @@
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
/* ── 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
* <name>.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);
}
+24
View File
@@ -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);
+22
View File
@@ -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);
+112
View File
@@ -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 : "<null>",
routes[i].qualified_name ? routes[i].qualified_name : "<null>");
}
}
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);