3535 CR_INIT_CAP = 32 ,
3636 CR_COL_3 = 3 ,
3737 CR_COL_4 = 4 ,
38+ CR_SCHEME_SKIP = 3 , /* strlen("://") */
39+ CR_ROUTE_PREFIX_LEN = 9 , /* strlen("__route__") */
40+ CR_ANY_LEN = 3 , /* strlen("ANY") */
3841};
3942
4043#define CR_MS_PER_SEC 1000.0
@@ -115,7 +118,10 @@ static void delete_cross_edges(cbm_store_t *store, const char *project) {
115118 cbm_store_delete_edges_by_type (store , project , "CROSS_TRPC_CALLS" );
116119}
117120
118- /* Insert a CROSS_* edge into a store. */
121+ /* Insert a CROSS_* edge into a store. Idempotent by construction: the edges
122+ * table is UNIQUE(source_id, target_id, type) and cbm_store_insert_edge
123+ * upserts on conflict, so a pair reached from both match directions or on a
124+ * repeat run never duplicates a row. (#523) */
119125static void insert_cross_edge (cbm_store_t * store , const char * project , int64_t from_id ,
120126 int64_t to_id , const char * edge_type , const char * props ) {
121127 cbm_edge_t edge = {
@@ -128,6 +134,24 @@ static void insert_cross_edge(cbm_store_t *store, const char *project, int64_t f
128134 cbm_store_insert_edge (store , & edge );
129135}
130136
137+ /* Strip "scheme://host[:port]" from a stored HTTP_CALLS url, returning the
138+ * path. url_path property values are stored raw from the call's first string
139+ * argument, so they can be full URLs ("scheme://host:port/v2/x") — and
140+ * cbm_route_canon_path only canonicalizes placeholder syntax, never strips
141+ * authorities. Returns "/" for a URL with no path after the host (a request
142+ * against the bare base URL targets the root route). (#523) */
143+ static const char * cr_url_path (const char * url ) {
144+ if (!url ) {
145+ return url ;
146+ }
147+ const char * scheme_end = strstr (url , "://" );
148+ if (!scheme_end ) {
149+ return url ; /* already a bare path */
150+ }
151+ const char * path_start = strchr (scheme_end + CR_SCHEME_SKIP , '/' );
152+ return path_start ? path_start : "/" ;
153+ }
154+
131155/* Look up a node's name and file_path by id. */
132156static void lookup_node_info (struct sqlite3 * db , int64_t node_id , char * name_out , size_t name_sz ,
133157 char * file_out , size_t file_sz ) {
@@ -208,6 +232,114 @@ static int64_t find_route_handler(cbm_store_t *target_store, const char *route_q
208232 return handler_id ;
209233}
210234
235+ /* Segment-wise match of a concrete path against a route template path, where a
236+ * "{...}" segment in the template matches any single non-empty concrete
237+ * segment. Both inputs are bare paths (no method prefix, no authority).
238+ * Leading/trailing slashes are insignificant. Returns true on a full match. */
239+ static bool cr_path_matches_template (const char * concrete , const char * templ ) {
240+ const char * c = concrete ;
241+ const char * t = templ ;
242+ while (* c && * t ) {
243+ if (* c == '/' ) {
244+ c ++ ;
245+ }
246+ if (* t == '/' ) {
247+ t ++ ;
248+ }
249+ const char * cseg = c ;
250+ while (* c && * c != '/' ) {
251+ c ++ ;
252+ }
253+ const char * tseg = t ;
254+ while (* t && * t != '/' ) {
255+ t ++ ;
256+ }
257+ size_t clen = (size_t )(c - cseg );
258+ size_t tlen = (size_t )(t - tseg );
259+ bool t_is_param = (tlen >= PAIR_LEN && tseg [0 ] == '{' && tseg [tlen - 1 ] == '}' );
260+ if (!t_is_param ) {
261+ if (clen != tlen || strncmp (cseg , tseg , clen ) != 0 ) {
262+ return false;
263+ }
264+ } else if (clen == 0 ) {
265+ return false; /* a parameter never matches an empty segment */
266+ }
267+ }
268+ while (* c == '/' ) {
269+ c ++ ;
270+ }
271+ while (* t == '/' ) {
272+ t ++ ;
273+ }
274+ return * c == '\0' && * t == '\0' ;
275+ }
276+
277+ /* Fallback for when the exact route-QN lookup misses: a concrete client path
278+ * ("/v2/orders/123") never exact-matches a templated route QN
279+ * ("__route__GET__/v2/orders/{}"). Enumerate the target store's Route nodes
280+ * and segment-match the concrete path against each template. On a match, copy
281+ * the route QN into out_qn and return the handler id; returns 0 on no match.
282+ *
283+ * COST: this scans every Route node of the target project once per unmatched
284+ * HTTP_CALLS edge — O(calls × routes) per project pair. Acceptable while both
285+ * factors stay small (calls are capped at CR_MAX_EDGES and it only runs for
286+ * edges the exact lookup missed); revisit with a prepared template index if
287+ * cross-repo matching ever shows up in profiles. (#523) */
288+ static int64_t find_route_handler_fuzzy (cbm_store_t * target_store , const char * concrete_path ,
289+ const char * method , char * out_qn , size_t out_qn_sz ,
290+ char * handler_name , size_t name_sz , char * handler_file ,
291+ size_t file_sz ) {
292+ struct sqlite3 * db = cbm_store_get_db (target_store );
293+ if (!db || !concrete_path || !concrete_path [0 ]) {
294+ return 0 ;
295+ }
296+ sqlite3_stmt * s = NULL ;
297+ if (sqlite3_prepare_v2 (db , "SELECT qualified_name FROM nodes WHERE label = 'Route'" ,
298+ CBM_NOT_FOUND , & s , NULL ) != SQLITE_OK ) {
299+ return 0 ;
300+ }
301+ int64_t found = 0 ;
302+ while (sqlite3_step (s ) == SQLITE_ROW ) {
303+ const char * qn = (const char * )sqlite3_column_text (s , 0 );
304+ if (!qn || strncmp (qn , "__route__" , CR_ROUTE_PREFIX_LEN ) != 0 ) {
305+ continue ;
306+ }
307+ /* Split "__route__<METHOD>__<path>" */
308+ const char * rest = qn + CR_ROUTE_PREFIX_LEN ;
309+ const char * sep = strstr (rest , "__" );
310+ if (!sep ) {
311+ continue ;
312+ }
313+ size_t mlen = (size_t )(sep - rest );
314+ const char * rpath = sep + PAIR_LEN ;
315+ /* Method gate: the route's method must equal the caller's, or be ANY.
316+ * A missing caller method matches any route method. */
317+ if (method && method [0 ]) {
318+ bool same_method = (strncmp (rest , method , mlen ) == 0 && method [mlen ] == '\0' );
319+ bool route_any = (mlen == CR_ANY_LEN && strncmp (rest , "ANY" , CR_ANY_LEN ) == 0 );
320+ if (!same_method && !route_any ) {
321+ continue ;
322+ }
323+ }
324+ if (!cr_path_matches_template (concrete_path , rpath )) {
325+ continue ;
326+ }
327+ /* A concrete path can match more than one stored template (e.g. a raw
328+ * "{id}" variant and its canonical "{}" form). Only accept a Route that
329+ * actually has a HANDLES edge — the handler is attached to the
330+ * canonical node. Keep scanning otherwise. */
331+ int64_t hid =
332+ find_route_handler (target_store , qn , handler_name , name_sz , handler_file , file_sz );
333+ if (hid != 0 ) {
334+ snprintf (out_qn , out_qn_sz , "%s" , qn );
335+ found = hid ;
336+ break ;
337+ }
338+ }
339+ sqlite3_finalize (s );
340+ return found ;
341+ }
342+
211343/* Emit CROSS_* edge for a route match: forward into source, reverse into target. */
212344static void emit_cross_route_bidirectional (cbm_store_t * src_store , const char * src_project ,
213345 struct sqlite3 * src_db , int64_t caller_id ,
@@ -284,12 +416,12 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project,
284416 continue ;
285417 }
286418
287- /* Build the expected Route QN in the target project (param-canonicalized
288- * so client url_path matches the server handler regardless of framework
289- * placeholder syntax). */
419+ /* Build the expected Route QN in the target project (authority-stripped
420+ * and param-canonicalized so client url_path matches the server handler
421+ * regardless of base URL and framework placeholder syntax). */
290422 char route_qn [CR_QN_BUF ];
291423 char cpath [CBM_SZ_256 ];
292- const char * curl = cbm_route_canon_path (url_path , cpath , sizeof (cpath ));
424+ const char * curl = cbm_route_canon_path (cr_url_path ( url_path ) , cpath , sizeof (cpath ));
293425 snprintf (route_qn , sizeof (route_qn ), "__route__%s__%s" , method [0 ] ? method : "ANY" , curl );
294426
295427 char handler_name [CBM_SZ_256 ] = {0 };
@@ -303,6 +435,14 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project,
303435 handler_id = find_route_handler (tgt_store , route_qn , handler_name , sizeof (handler_name ),
304436 handler_file , sizeof (handler_file ));
305437 }
438+ if (handler_id == 0 ) {
439+ /* Exact QN lookup missed. A concrete client path ("/v2/orders/123")
440+ * never exact-matches a templated route ("/v2/orders/{}"), so fall
441+ * back to segment-wise template matching. (#523) */
442+ handler_id = find_route_handler_fuzzy (
443+ tgt_store , curl , method [0 ] ? method : NULL , route_qn , sizeof (route_qn ),
444+ handler_name , sizeof (handler_name ), handler_file , sizeof (handler_file ));
445+ }
306446 if (handler_id == 0 ) {
307447 continue ;
308448 }
@@ -655,6 +795,15 @@ cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **t
655795 }
656796
657797 result .http_edges += match_http_routes (src_store , project , tgt_store , tgt );
798+ /* Reverse direction: when this pass runs from the provider side, the
799+ * consumer's HTTP_CALLS live in tgt, not src — the forward pass above
800+ * finds nothing because the provider has no outbound calls. This also
801+ * re-creates the provider-side reverse edges that delete_cross_edges
802+ * just wiped, which a provider-side run previously destroyed for good.
803+ * A caller edge lives in exactly one DB, so the two directions scan
804+ * disjoint edge sets and never double-count a pair; the store's
805+ * (source, target, type) upsert keeps re-recorded rows unique. (#523) */
806+ result .http_edges += match_http_routes (tgt_store , tgt , src_store , project );
658807 result .async_edges += match_async_routes (src_store , project , tgt_store , tgt );
659808 result .channel_edges += match_channels (src_store , project , tgt_store , tgt );
660809 result .grpc_edges += match_typed_routes (src_store , project , tgt_store , tgt , "GRPC_CALLS" ,
0 commit comments