mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-02 07:53:41 +08:00
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:
+29
-1
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user