@@ -115,9 +115,25 @@ static void delete_cross_edges(cbm_store_t *store, const char *project) {
115115 cbm_store_delete_edges_by_type (store , project , "CROSS_TRPC_CALLS" );
116116}
117117
118- /* Insert a CROSS_* edge into a store. */
119- static void insert_cross_edge (cbm_store_t * store , const char * project , int64_t from_id ,
120- int64_t to_id , const char * edge_type , const char * props ) {
118+ /* Insert a CROSS_* edge, skipping if an identical (source, target, type) edge
119+ * already exists. The pass can reach the same caller/route pair from both
120+ * directions and emit_cross_route_bidirectional writes both DBs, so this guard
121+ * keeps emission idempotent. Returns 1 if a new row was inserted, else 0, so
122+ * callers count rows actually added rather than matches found. Issue #523. */
123+ static int insert_cross_edge (cbm_store_t * store , const char * project , int64_t from_id ,
124+ int64_t to_id , const char * edge_type , const char * props ) {
125+ cbm_edge_t * existing = NULL ;
126+ int existing_count = 0 ;
127+ if (cbm_store_find_edges_by_source_type (store , from_id , edge_type , & existing ,
128+ & existing_count ) == 0 ) {
129+ for (int i = 0 ; i < existing_count ; i ++ ) {
130+ if (existing [i ].target_id == to_id ) {
131+ cbm_store_free_edges (existing , existing_count );
132+ return 0 ; /* already present - do not duplicate */
133+ }
134+ }
135+ cbm_store_free_edges (existing , existing_count );
136+ }
121137 cbm_edge_t edge = {
122138 .project = project ,
123139 .source_id = from_id ,
@@ -126,6 +142,22 @@ static void insert_cross_edge(cbm_store_t *store, const char *project, int64_t f
126142 .properties_json = props ,
127143 };
128144 cbm_store_insert_edge (store , & edge );
145+ return 1 ;
146+ }
147+
148+ /* Strip scheme + authority from a URL, returning a pointer to the path.
149+ * cbm_route_canon_path handles placeholder syntax but not scheme/host, so a
150+ * consumer's full URL needs this before the route-QN lookup. Issue #523. */
151+ static const char * cr_url_path (const char * url ) {
152+ if (!url ) {
153+ return url ;
154+ }
155+ const char * scheme_end = strstr (url , "://" );
156+ if (!scheme_end ) {
157+ return url ;
158+ }
159+ const char * path_start = strchr (scheme_end + 3 , '/' );
160+ return path_start ? path_start : url ;
129161}
130162
131163/* Look up a node's name and file_path by id. */
@@ -208,29 +240,135 @@ static int64_t find_route_handler(cbm_store_t *target_store, const char *route_q
208240 return handler_id ;
209241}
210242
243+ /* Segment-wise match of a concrete path against a route template path, where a
244+ * "{...}" segment in the template matches any single concrete segment. Both
245+ * inputs are bare paths (no method prefix). Returns true on a full match. */
246+ static bool cr_path_matches_template (const char * concrete , const char * templ ) {
247+ const char * c = concrete ;
248+ const char * t = templ ;
249+ while (* c && * t ) {
250+ if (* c == '/' ) {
251+ c ++ ;
252+ }
253+ if (* t == '/' ) {
254+ t ++ ;
255+ }
256+ const char * cseg = c ;
257+ while (* c && * c != '/' ) {
258+ c ++ ;
259+ }
260+ const char * tseg = t ;
261+ while (* t && * t != '/' ) {
262+ t ++ ;
263+ }
264+ size_t clen = (size_t )(c - cseg );
265+ size_t tlen = (size_t )(t - tseg );
266+ bool t_is_param = (tlen >= 2 && tseg [0 ] == '{' && tseg [tlen - 1 ] == '}' );
267+ if (!t_is_param ) {
268+ if (clen != tlen || strncmp (cseg , tseg , clen ) != 0 ) {
269+ return false;
270+ }
271+ } else if (clen == 0 ) {
272+ return false;
273+ }
274+ }
275+ while (* c == '/' ) {
276+ c ++ ;
277+ }
278+ while (* t == '/' ) {
279+ t ++ ;
280+ }
281+ return * c == '\0' && * t == '\0' ;
282+ }
283+
284+ /* Fallback for when an exact route-QN lookup misses: a concrete client path
285+ * ("/v2/orders/123") will not exact-match a templated route QN
286+ * ("__route__GET__/v2/orders/{}"). Enumerate the target's Route nodes and
287+ * segment-match the concrete path against each template. On a match, copy the
288+ * route QN into out_qn and return the handler id. Returns 0 on no match. #523 */
289+ static int64_t find_route_handler_fuzzy (cbm_store_t * target_store , const char * concrete_path ,
290+ const char * method , char * out_qn , size_t out_qn_sz ,
291+ char * handler_name , size_t name_sz , char * handler_file ,
292+ size_t file_sz ) {
293+ struct sqlite3 * db = cbm_store_get_db (target_store );
294+ if (!db ) {
295+ return 0 ;
296+ }
297+ sqlite3_stmt * s = NULL ;
298+ if (sqlite3_prepare_v2 (
299+ db , "SELECT qualified_name FROM nodes WHERE label = 'Route' AND qualified_name LIKE ?1" ,
300+ CBM_NOT_FOUND , & s , NULL ) != SQLITE_OK ) {
301+ return 0 ;
302+ }
303+ sqlite3_bind_text (s , SKIP_ONE , "__route__%" , CBM_NOT_FOUND , SQLITE_STATIC );
304+ int64_t found = 0 ;
305+ char matched_qn [CR_QN_BUF ] = {0 };
306+ while (sqlite3_step (s ) == SQLITE_ROW ) {
307+ const char * qn = (const char * )sqlite3_column_text (s , 0 );
308+ if (!qn || strncmp (qn , "__route__" , 9 ) != 0 ) {
309+ continue ;
310+ }
311+ const char * rest = qn + 9 ;
312+ const char * sep = strstr (rest , "__" );
313+ if (!sep ) {
314+ continue ;
315+ }
316+ size_t mlen = (size_t )(sep - rest );
317+ const char * rpath = sep + 2 ;
318+ bool method_ok = true;
319+ if (method && method [0 ]) {
320+ method_ok = (strncmp (rest , method , mlen ) == 0 && method [mlen ] == '\0' ) ||
321+ (mlen == 3 && strncmp (rest , "ANY" , 3 ) == 0 );
322+ }
323+ if (!method_ok ) {
324+ continue ;
325+ }
326+ if (cr_path_matches_template (concrete_path , rpath )) {
327+ /* A path can match more than one stored template (e.g. both the
328+ * raw "{id}" route and its canonical "{}" form). Only accept a
329+ * match whose Route node actually has a HANDLES edge — the handler
330+ * is attached to the 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 (matched_qn , sizeof (matched_qn ), "%s" , qn );
335+ found = hid ;
336+ break ;
337+ }
338+ }
339+ }
340+ sqlite3_finalize (s );
341+ if (!found ) {
342+ return 0 ;
343+ }
344+ snprintf (out_qn , out_qn_sz , "%s" , matched_qn );
345+ return found ;
346+ }
347+
211348/* Emit CROSS_* edge for a route match: forward into source, reverse into target. */
212- static void emit_cross_route_bidirectional (cbm_store_t * src_store , const char * src_project ,
213- struct sqlite3 * src_db , int64_t caller_id ,
214- int64_t local_route_id , cbm_store_t * tgt_store ,
215- const char * tgt_project , int64_t handler_id ,
216- const char * route_qn , const char * handler_name ,
217- const char * handler_file , const char * url_path ,
218- const char * method , const char * edge_type ) {
349+ static int emit_cross_route_bidirectional (cbm_store_t * src_store , const char * src_project ,
350+ struct sqlite3 * src_db , int64_t caller_id ,
351+ int64_t local_route_id , cbm_store_t * tgt_store ,
352+ const char * tgt_project , int64_t handler_id ,
353+ const char * route_qn , const char * handler_name ,
354+ const char * handler_file , const char * url_path ,
355+ const char * method , const char * edge_type ) {
219356 /* Forward: caller → local Route in source DB */
220357 char fwd [CR_PROPS_BUF ];
221358 build_cross_props (fwd , sizeof (fwd ), tgt_project , handler_name , handler_file , url_path ,
222359 "url_path" , method );
223- insert_cross_edge (src_store , src_project , caller_id , local_route_id , edge_type , fwd );
360+ int inserted =
361+ insert_cross_edge (src_store , src_project , caller_id , local_route_id , edge_type , fwd );
224362
225363 /* Reverse: handler → Route in target DB */
226364 struct sqlite3 * tgt_db = cbm_store_get_db (tgt_store );
227365 if (!tgt_db ) {
228- return ;
366+ return inserted ;
229367 }
230368 sqlite3_stmt * rq = NULL ;
231369 if (sqlite3_prepare_v2 (tgt_db , "SELECT id FROM nodes WHERE qualified_name = ?1 LIMIT 1" ,
232370 CBM_NOT_FOUND , & rq , NULL ) != SQLITE_OK ) {
233- return ;
371+ return inserted ;
234372 }
235373 sqlite3_bind_text (rq , SKIP_ONE , route_qn , CBM_NOT_FOUND , SQLITE_STATIC );
236374 int64_t tgt_route_id = 0 ;
@@ -239,7 +377,7 @@ static void emit_cross_route_bidirectional(cbm_store_t *src_store, const char *s
239377 }
240378 sqlite3_finalize (rq );
241379 if (tgt_route_id == 0 ) {
242- return ;
380+ return inserted ;
243381 }
244382
245383 char caller_name [CBM_SZ_256 ] = {0 };
@@ -251,6 +389,7 @@ static void emit_cross_route_bidirectional(cbm_store_t *src_store, const char *s
251389 build_cross_props (rev , sizeof (rev ), src_project , caller_name , caller_file , url_path , "url_path" ,
252390 method );
253391 insert_cross_edge (tgt_store , tgt_project , handler_id , tgt_route_id , edge_type , rev );
392+ return inserted ;
254393}
255394
256395static int match_http_routes (cbm_store_t * src_store , const char * src_project ,
@@ -289,7 +428,7 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project,
289428 * placeholder syntax). */
290429 char route_qn [CR_QN_BUF ];
291430 char cpath [CBM_SZ_256 ];
292- const char * curl = cbm_route_canon_path (url_path , cpath , sizeof (cpath ));
431+ const char * curl = cbm_route_canon_path (cr_url_path ( url_path ) , cpath , sizeof (cpath ));
293432 snprintf (route_qn , sizeof (route_qn ), "__route__%s__%s" , method [0 ] ? method : "ANY" , curl );
294433
295434 char handler_name [CBM_SZ_256 ] = {0 };
@@ -303,15 +442,22 @@ static int match_http_routes(cbm_store_t *src_store, const char *src_project,
303442 handler_id = find_route_handler (tgt_store , route_qn , handler_name , sizeof (handler_name ),
304443 handler_file , sizeof (handler_file ));
305444 }
445+ if (handler_id == 0 ) {
446+ /* Exact QN lookup missed. A concrete client path ("/v2/orders/123")
447+ * does not exact-match a templated route ("/v2/orders/{}"), so fall
448+ * back to segment-wise template matching. Issue #523. */
449+ handler_id = find_route_handler_fuzzy (
450+ tgt_store , cr_url_path (url_path ), method [0 ] ? method : NULL , route_qn ,
451+ sizeof (route_qn ), handler_name , sizeof (handler_name ), handler_file ,
452+ sizeof (handler_file ));
453+ }
306454 if (handler_id == 0 ) {
307455 continue ;
308456 }
309457
310- emit_cross_route_bidirectional (src_store , src_project , src_db , caller_id , route_id ,
311- tgt_store , tgt_project , handler_id , route_qn , handler_name ,
312- handler_file , url_path , method , "CROSS_HTTP_CALLS" );
313-
314- count ++ ;
458+ count += emit_cross_route_bidirectional (
459+ src_store , src_project , src_db , caller_id , route_id , tgt_store , tgt_project , handler_id ,
460+ route_qn , handler_name , handler_file , url_path , method , "CROSS_HTTP_CALLS" );
315461 }
316462 sqlite3_finalize (s );
317463 return count ;
@@ -365,9 +511,8 @@ static int match_async_routes(cbm_store_t *src_store, const char *src_project,
365511 char edge_props [CR_PROPS_BUF ];
366512 build_cross_props (edge_props , sizeof (edge_props ), tgt_project , handler_name , handler_file ,
367513 url_path , "url_path" , broker );
368- insert_cross_edge (src_store , src_project , caller_id , route_id , "CROSS_ASYNC_CALLS" ,
369- edge_props );
370- count ++ ;
514+ count += insert_cross_edge (src_store , src_project , caller_id , route_id , "CROSS_ASYNC_CALLS" ,
515+ edge_props );
371516 }
372517 sqlite3_finalize (s );
373518 return count ;
@@ -543,10 +688,9 @@ static int match_typed_routes(cbm_store_t *src_store, const char *src_project,
543688 continue ;
544689 }
545690
546- emit_cross_route_bidirectional (src_store , src_project , src_db , caller_id , route_id ,
547- tgt_store , tgt_project , handler_id , route_qn , handler_name ,
548- handler_file , svc_val , svc_key , cross_edge_type );
549- count ++ ;
691+ count += emit_cross_route_bidirectional (
692+ src_store , src_project , src_db , caller_id , route_id , tgt_store , tgt_project , handler_id ,
693+ route_qn , handler_name , handler_file , svc_val , svc_key , cross_edge_type );
550694 }
551695 sqlite3_finalize (s );
552696 return count ;
@@ -655,6 +799,12 @@ cbm_cross_repo_result_t cbm_cross_repo_match(const char *project, const char **t
655799 }
656800
657801 result .http_edges += match_http_routes (src_store , project , tgt_store , tgt );
802+ /* Reverse direction: when this pass is run from the provider side, the
803+ * consumer's HTTP_CALLS live in tgt, not src — the forward pass above
804+ * finds nothing because the provider has no outbound calls. The dedup
805+ * guard in insert_cross_edge keeps a pair matched both ways from
806+ * inflating the count or duplicating rows. Issue #523. */
807+ result .http_edges += match_http_routes (tgt_store , tgt , src_store , project );
658808 result .async_edges += match_async_routes (src_store , project , tgt_store , tgt );
659809 result .channel_edges += match_channels (src_store , project , tgt_store , tgt );
660810 result .grpc_edges += match_typed_routes (src_store , project , tgt_store , tgt , "GRPC_CALLS" ,
0 commit comments