From c548d8e46a820cb3eb27de55fb1d0a54dab628c1 Mon Sep 17 00:00:00 2001 From: Divakant Pandey Date: Sat, 31 Oct 2026 14:16:04 +0530 Subject: [PATCH 1/5] fix(pipeline): infer HTTP method for Angular and verb-suffix client calls (#2768) Signed-off-by: Divakant Pandey --- internal/cbm/service_patterns.c | 7 ++ src/pipeline/pass_calls.c | 15 ++- src/pipeline/pass_cross_repo.c | 10 ++ src/pipeline/pass_parallel.c | 25 ++++- src/pipeline/pass_route_nodes.c | 3 + tests/test_cross_repo.c | 187 ++++++++++++++++++++++++++++++++ tests/test_pipeline.c | 95 ++++++++++++++++ 7 files changed, 332 insertions(+), 10 deletions(-) diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 7d428b8e3..35313452b 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -46,6 +46,13 @@ static const lib_pattern_t http_libraries[] = { * case-sensitive, so without this entry `Axios.get(url)` fell through to * the `.get` route-suffix fallback and became a route registration. */ {"Axios", CBM_SVC_HTTP, NULL}, + {"@angular/common/http", CBM_SVC_HTTP, NULL}, + {"angular.common.http", CBM_SVC_HTTP, NULL}, + {"HttpClient", CBM_SVC_HTTP, NULL}, + {"httpClient", CBM_SVC_HTTP, NULL}, + {"this.http", CBM_SVC_HTTP, NULL}, + {"this.httpClient", CBM_SVC_HTTP, NULL}, + {"$http", CBM_SVC_HTTP, NULL}, {"superagent", CBM_SVC_HTTP, NULL}, {"needle", CBM_SVC_HTTP, NULL}, {"node-fetch", CBM_SVC_HTTP, NULL}, diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 1df1fe155..2a9549f26 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -939,15 +939,20 @@ static int calls_emit_unresolved_service(cbm_pipeline_ctx_t *ctx, const CBMCall const char **imp_keys, const char **imp_vals, int imp_count, const char *route_mount, CBMLanguage lang, CBMCallEvidence *evidence) { - if (calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, - route_mount, lang, evidence)) { - return SKIP_ONE; - } cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name); if (svc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) { svc = CBM_SVC_HTTP; } - return calls_emit_service_call(ctx, call, source_node, svc, NULL, evidence); + if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) { + if (calls_emit_service_call(ctx, call, source_node, svc, NULL, evidence)) { + return SKIP_ONE; + } + } + if (calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, + route_mount, lang, evidence)) { + return SKIP_ONE; + } + return 0; } /* Apply the field-call policy after a registry target has been found. */ diff --git a/src/pipeline/pass_cross_repo.c b/src/pipeline/pass_cross_repo.c index 1f57d1c25..830cacfe1 100644 --- a/src/pipeline/pass_cross_repo.c +++ b/src/pipeline/pass_cross_repo.c @@ -720,6 +720,16 @@ static cr_match_result_t match_http_routes(cbm_store_t *src_store, const char *s char method[CBM_SZ_32] = {0}; json_str_prop(props, "url_path", url_path, sizeof(url_path)); json_str_prop(props, "method", method, sizeof(method)); + if (!method[0]) { + char callee[CBM_SZ_256] = {0}; + json_str_prop(props, "callee", callee, sizeof(callee)); + if (callee[0]) { + const char *m = cbm_service_pattern_http_method(callee); + if (m) { + snprintf(method, sizeof(method), "%s", m); + } + } + } if (!url_path[0]) { continue; } diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 4dbfae9e9..f495e8d5b 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2591,19 +2591,34 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, if (!cbm_service_pattern_is_http_route_literal(norm, call->callee_name)) { continue; } + const char *method = cbm_service_pattern_http_method(call->callee_name); + const char *prefix = method ? method : "ANY"; char route_qn[CBM_ROUTE_QN_SIZE]; char cpath[CBM_SZ_256]; - snprintf(route_qn, sizeof(route_qn), "__route__ANY__%s", + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", prefix, cbm_route_canon_path(norm, cpath, sizeof(cpath))); - int64_t route_id = cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, - "{\"source\":\"arg_url\"}"); + char route_props[CBM_SZ_256]; + if (method) { + snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\",\"method\":\"%s\"}", + method); + } else { + snprintf(route_props, sizeof(route_props), "{\"source\":\"arg_url\"}"); + } + int64_t route_id = + cbm_gbuf_upsert_node(gbuf, "Route", norm, route_qn, "", 0, 0, route_props); char esc_c[CBM_SZ_256]; char esc_n[CBM_SZ_256]; cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name); cbm_json_escape(esc_n, sizeof(esc_n), norm); char eprops[CBM_SZ_512]; - snprintf(eprops, sizeof(eprops), - "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); + if (method) { + snprintf(eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\",\"method\":\"%s\"}", + esc_c, esc_n, method); + } else { + snprintf(eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); + } cbm_gbuf_insert_edge(gbuf, source->id, route_id, "HTTP_CALLS", eprops); break; } diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 23dff8150..4e19f06ef 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -113,6 +113,9 @@ static void route_edge_visitor(const cbm_gbuf_edge_t *edge, void *userdata) { json_extract(edge->properties_json, "method", method_buf, sizeof(method_buf)); const char *broker = json_extract(edge->properties_json, "broker", broker_buf, sizeof(broker_buf)); + if (!method && strcmp(edge->type, "HTTP_CALLS") == 0 && callee) { + method = cbm_service_pattern_http_method(callee); + } /* Build Route QN */ char route_qn[CBM_ROUTE_QN_SIZE]; diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 9d318f8c0..7c72681d2 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -944,10 +944,197 @@ TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459) { PASS(); } +static bool cross_repo_seed_server_route(const cross_repo_fixture_t *fixture, + const char *target_project, const char *method, + const char *route_path, const char *handler_name) { + char target_path[512]; + if (!cross_repo_project_path(fixture, target_project, target_path, sizeof(target_path))) { + return false; + } + cbm_store_t *target = cbm_store_open_path(target_path); + if (!target) { + return false; + } + bool ok = cbm_store_upsert_project(target, target_project, fixture->cache) == CBM_STORE_OK; + char target_route_qn[256]; + char handler_qn[256]; + char route_name[128]; + snprintf(target_route_qn, sizeof(target_route_qn), "__route__%s__%s", method, route_path); + snprintf(handler_qn, sizeof(handler_qn), "%s.%s", target_project, handler_name); + snprintf(route_name, sizeof(route_name), "%s %s", method, route_path); + + cbm_node_t target_route = {.project = target_project, + .label = "Route", + .name = route_name, + .qualified_name = target_route_qn, + .file_path = "server.java"}; + cbm_node_t handler = {.project = target_project, + .label = "Function", + .name = handler_name, + .qualified_name = handler_qn, + .file_path = "server.java"}; + int64_t target_route_id = ok ? cbm_store_upsert_node(target, &target_route) : 0; + int64_t handler_id = ok ? cbm_store_upsert_node(target, &handler) : 0; + cbm_edge_t handles = {.project = target_project, + .source_id = handler_id, + .target_id = target_route_id, + .type = "HANDLES"}; + ok = ok && target_route_id > 0 && handler_id > 0 && cbm_store_insert_edge(target, &handles) > 0; + cbm_store_close(target); + return ok; +} + +static bool cross_repo_seed_client_call(const cross_repo_fixture_t *fixture, + const char *source_project, const char *caller_name, + const char *callee_name, const char *url_path, + const char *method) { + char source_path[512]; + if (!cross_repo_project_path(fixture, source_project, source_path, sizeof(source_path))) { + return false; + } + cbm_store_t *source = cbm_store_open_path(source_path); + if (!source) { + return false; + } + bool ok = cbm_store_upsert_project(source, source_project, fixture->cache) == CBM_STORE_OK; + char caller_qn[256]; + char local_route_qn[256]; + char edge_props[512]; + snprintf(caller_qn, sizeof(caller_qn), "%s.%s", source_project, caller_name); + snprintf(local_route_qn, sizeof(local_route_qn), "__route__%s__%s", method ? method : "ANY", + url_path); + if (method) { + snprintf(edge_props, sizeof(edge_props), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"method\":\"%s\"}", callee_name, + url_path, method); + } else { + snprintf(edge_props, sizeof(edge_props), + "{\"callee\":\"%s\",\"url_path\":\"%s\"}", callee_name, url_path); + } + cbm_node_t caller = {.project = source_project, + .label = "Function", + .name = caller_name, + .qualified_name = caller_qn, + .file_path = "item.service.ts"}; + cbm_node_t local_route = {.project = source_project, + .label = "Route", + .name = url_path, + .qualified_name = local_route_qn, + .file_path = "item.service.ts"}; + int64_t caller_id = ok ? cbm_store_upsert_node(source, &caller) : 0; + int64_t local_route_id = ok ? cbm_store_upsert_node(source, &local_route) : 0; + cbm_edge_t http_call = {.project = source_project, + .source_id = caller_id, + .target_id = local_route_id, + .type = "HTTP_CALLS", + .properties_json = edge_props}; + ok = ok && caller_id > 0 && local_route_id > 0 && cbm_store_insert_edge(source, &http_call) > 0; + cbm_store_close(source); + return ok; +} + +static int cross_repo_count_edges_like(const cross_repo_fixture_t *fixture, const char *project, + const char *edge_type, const char *pattern) { + char path[512]; + if (!cross_repo_project_path(fixture, project, path, sizeof(path))) { + return -1; + } + cbm_store_t *store = cbm_store_open_path_query(path); + if (!store) { + return -1; + } + sqlite3_stmt *s = NULL; + int count = -1; + if (sqlite3_prepare_v2( + cbm_store_get_db(store), + "SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = ?2 AND properties LIKE ?3", + -1, &s, NULL) == SQLITE_OK) { + sqlite3_bind_text(s, 1, project, -1, SQLITE_STATIC); + sqlite3_bind_text(s, 2, edge_type, -1, SQLITE_STATIC); + sqlite3_bind_text(s, 3, pattern, -1, SQLITE_STATIC); + if (sqlite3_step(s) == SQLITE_ROW) { + count = sqlite3_column_int(s, 0); + } + sqlite3_finalize(s); + } + cbm_store_close(store); + return count; +} + +/* #2768: Angular HttpClient calls must match cross-repo routes with exact HTTP verbs. + * When client makes GET requests to /items/list, /items/42, and /items/job/10, + * and the server defines overlapping routes: + * DELETE /items/{id} (seeded first in DB) + * GET /items/{id} (seeded second in DB) + * POST /items/{parentId}/{id} (seeded first in DB) + * GET /items/job/{jobId} (seeded second in DB) + * the fuzzy route matcher must enforce method compatibility, linking the GET calls + * to their respective GET endpoints and never to DELETE or POST endpoints. */ +TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768) { + cross_repo_fixture_t fixture; + bool setup = cross_repo_fixture_begin(&fixture) && + /* Target server with overlapping endpoints */ + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/list", + "handle_list_items") && + cross_repo_seed_server_route(&fixture, "api-2768", "DELETE", "/items/{}", + "handle_delete_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/{}", + "handle_get_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "POST", "/items/{}/{}", + "handle_post_nested_item") && + cross_repo_seed_server_route(&fixture, "api-2768", "GET", "/items/job/{}", + "handle_get_job") && + /* Angular client making GET requests */ + cross_repo_seed_client_call(&fixture, "angular-2768", "getItems", + "this.http.get", "/items/list", "GET") && + cross_repo_seed_client_call(&fixture, "angular-2768", "getItem", + "this.http.get", "/items/42", "GET") && + cross_repo_seed_client_call(&fixture, "angular-2768", "getJob", + "this.http.get", "/items/job/10", "GET"); + if (!setup) { + cross_repo_fixture_end(&fixture); + FAIL("failed to seed issue 2768 fixture"); + } + + const char *target = "api-2768"; + cbm_cross_repo_result_t fwd = cbm_cross_repo_match("angular-2768", &target, 1); + ASSERT_FALSE(fwd.failed); + ASSERT_EQ(fwd.http_edges, 3); + + /* Assert that client CROSS_HTTP_CALLS edges are linked to GET handlers, not DELETE or POST */ + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_delete_item%"), + 0); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_post_nested_item%"), + 0); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_list_items%"), + 1); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_get_item%"), + 1); + ASSERT_EQ(cross_repo_count_edges_like(&fixture, "angular-2768", "CROSS_HTTP_CALLS", + "%handle_get_job%"), + 1); + + /* Reverse pass */ + const char *consumer = "angular-2768"; + cbm_cross_repo_result_t rev = cbm_cross_repo_match("api-2768", &consumer, 1); + ASSERT_FALSE(rev.failed); + ASSERT_EQ(rev.http_edges, 3); + + cross_repo_fixture_end(&fixture); + cbm_cross_repo_result_free(&fwd); + cbm_cross_repo_result_free(&rev); + PASS(); +} + SUITE(cross_repo) { RUN_TEST(cross_repo_caller_local_handler_blocks_cross_http_issue1459); RUN_TEST(cross_repo_methodless_call_to_local_get_route_blocks_cross_http_issue1459); RUN_TEST(cross_repo_caller_without_local_handler_keeps_cross_http_issue1459); + RUN_TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768); RUN_TEST(cross_repo_accepts_project_with_missed_shadow_row_issue1609); RUN_TEST(cross_repo_null_target_fails_without_dereference); RUN_TEST(cross_repo_wildcard_keeps_projects_containing_internal_tokens); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 94edecc29..919dbbdb2 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -14939,6 +14939,99 @@ TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential) { return typed_http_no_url_twin_case(0); } +/* Issue #2768: Angular HttpClient calls (this.http.get, this.httpClient.post, + * this.http.delete) emit HTTP_CALLS edges typed with the method verb, and are + * never misclassified as route registrations. */ +static int angular_http_client_typed_case(int fillers) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_angular_http_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "src/item.service.ts", + "import { Injectable } from '@angular/core';\n" + "import { HttpClient } from '@angular/common/http';\n" + "\n" + "@Injectable({ providedIn: 'root' })\n" + "export class ItemService {\n" + " constructor(private http: HttpClient) {}\n" + "\n" + " getItems() {\n" + " return this.http.get('/items/list');\n" + " }\n" + "\n" + " getItem(id: string) {\n" + " return this.http.get(`/items/${id}`);\n" + " }\n" + "\n" + " createItem(item: any) {\n" + " return this.http.post('/items', item);\n" + " }\n" + "\n" + " deleteItem(id: string) {\n" + " return this.http.delete(`/items/${id}`);\n" + " }\n" + "}\n"); + + for (int i = 0; i < fillers; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/angular_http.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* Assert exact HTTP_CALLS with proper methods */ + ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__GET__/items/list"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__GET__/items/{}"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__POST__/items"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__DELETE__/items/{}"), 1); + + /* Assert no ANY twins */ + ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__ANY__"), 0); + + /* Angular service must NOT emit HANDLES edges (not a server route registration) */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HANDLES"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_angular_http_client_typed_parallel) { + return angular_http_client_typed_case(52); +} + +TEST(pipeline_angular_http_client_typed_sequential) { + return angular_http_client_typed_case(0); +} + /* #706 / #1147 end to end: a URL composed from module constants reaches the * HTTP classifier, so the client call forms an HTTP_CALLS edge. Python * `API_URL = API_BASE_URL + "/orders/create"` (env-derived base, literal path @@ -29383,6 +29476,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_local_fetch_shadow_not_classified_as_http); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin); RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential); + RUN_TEST(pipeline_angular_http_client_typed_parallel); + RUN_TEST(pipeline_angular_http_client_typed_sequential); RUN_TEST(pipeline_url_constant_folding_http_calls_issue706_1147); RUN_TEST(pipeline_folded_topics_preserve_identity_sequential_parallel); RUN_TEST(pipeline_relative_folded_wrapper_url_candidates); From 22a7dff3ff19a53ba958d1ddc50721bb22356c23 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 20:59:40 +0100 Subject: [PATCH 2/5] fix(service-patterns): end the Angular receiver rows at a separator (#2768) The new HttpClient receiver rows matched by prefix, and an uppercase letter after an id counts as CamelCase glue, so "this.http" also matched this.httpAdapter.get (a NestJS route registration) and this.httpServer.post, and "$http" matched $httpBackend.expectGET (an AngularJS test double). Each receiver row now ends in a dot, like "gin.", plus a line-break twin (LF, and CR for CRLF files): a chain wrapped before the verb keeps the break in its callee text ("this.http\n .post"). On gothinkster/angular-realworld-example-app 5 of the 18 client calls are written that way; with dot-only rows they fell back to phantom route registrations. "HttpClient" duplicated the Java/C# row and "this.httpClient." is covered by "httpClient.". Unit cases pin both directions, and the Angular pipeline test gains a wrapped call on both resolver routes. Signed-off-by: Martin Vogel --- internal/cbm/service_patterns.c | 22 +++++++++++++++++----- tests/test_infrascan.c | 19 +++++++++++++++++++ tests/test_pipeline.c | 8 ++++++++ 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/internal/cbm/service_patterns.c b/internal/cbm/service_patterns.c index 35313452b..f6b5a0a00 100644 --- a/internal/cbm/service_patterns.c +++ b/internal/cbm/service_patterns.c @@ -46,13 +46,25 @@ static const lib_pattern_t http_libraries[] = { * case-sensitive, so without this entry `Axios.get(url)` fell through to * the `.get` route-suffix fallback and became a route registration. */ {"Axios", CBM_SVC_HTTP, NULL}, + /* Angular HttpClient (#2768). The receiver rows end in a dot, like "gin.": + * a bare "this.http" / "$http" would also match this.httpAdapter.get + * (NestJS route registration) and $httpBackend.expectGET (an AngularJS test + * double), because an uppercase letter after an id counts as CamelCase + * glue. A chain wrapped before the verb keeps its line break in the callee + * text ("this.http\n .post", prettier's style), so each receiver also + * has a line-break twin: LF, and CR for CRLF files. Java/C# HttpClient is + * already listed below. */ {"@angular/common/http", CBM_SVC_HTTP, NULL}, {"angular.common.http", CBM_SVC_HTTP, NULL}, - {"HttpClient", CBM_SVC_HTTP, NULL}, - {"httpClient", CBM_SVC_HTTP, NULL}, - {"this.http", CBM_SVC_HTTP, NULL}, - {"this.httpClient", CBM_SVC_HTTP, NULL}, - {"$http", CBM_SVC_HTTP, NULL}, + {"this.http.", CBM_SVC_HTTP, NULL}, + {"this.http\n", CBM_SVC_HTTP, NULL}, + {"this.http\r", CBM_SVC_HTTP, NULL}, + {"httpClient.", CBM_SVC_HTTP, NULL}, + {"httpClient\n", CBM_SVC_HTTP, NULL}, + {"httpClient\r", CBM_SVC_HTTP, NULL}, + {"$http.", CBM_SVC_HTTP, NULL}, + {"$http\n", CBM_SVC_HTTP, NULL}, + {"$http\r", CBM_SVC_HTTP, NULL}, {"superagent", CBM_SVC_HTTP, NULL}, {"needle", CBM_SVC_HTTP, NULL}, {"node-fetch", CBM_SVC_HTTP, NULL}, diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index 3b31f62e1..ef765a3d5 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -173,6 +173,13 @@ TEST(infrascan_service_pattern_match_rejects_ids_inside_words) { {"kafkaesque.story.tell", CBM_SVC_NONE}, /* "Axios" glued to a lowercase continuation */ {"geo.Axiosphere.map", CBM_SVC_NONE}, + /* Angular receiver rows end in a dot (#2768): a longer receiver that + * only starts with the same letters is not the HttpClient (NestJS + * route registration, AngularJS test double) */ + {"this.httpAdapter.get", CBM_SVC_NONE}, + {"this.httpServer.post", CBM_SVC_NONE}, + {"$httpBackend.expectGET", CBM_SVC_NONE}, + {"this.httpAdapter\n .get", CBM_SVC_NONE}, {NULL, CBM_SVC_NONE}, }; ASSERT_EQ(svc_case_mismatches(cases), 0); @@ -202,6 +209,18 @@ TEST(infrascan_service_pattern_match_keeps_real_library_qns) { {"Axios.post", CBM_SVC_HTTP}, {"AxiosInstance.get", CBM_SVC_HTTP}, {"curl_exec", CBM_SVC_HTTP}, + /* Angular HttpClient receivers (#2768); `_httpClient` is the common + * C# field spelling of the same client */ + {"this.http.get", CBM_SVC_HTTP}, + {"this.httpClient.post", CBM_SVC_HTTP}, + {"httpClient.put", CBM_SVC_HTTP}, + {"_httpClient.GetAsync", CBM_SVC_HTTP}, + {"$http.get", CBM_SVC_HTTP}, + /* a chain wrapped before the verb keeps the line break (LF / CRLF) */ + {"this.http\n .post", CBM_SVC_HTTP}, + {"this.http\r\n .delete", CBM_SVC_HTTP}, + {"this.httpClient\n .put", CBM_SVC_HTTP}, + {"$http\n .get", CBM_SVC_HTTP}, {"surf.get", CBM_SVC_HTTP}, {"hyper.Client.request", CBM_SVC_HTTP}, {"phin.promisified", CBM_SVC_HTTP}, diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 919dbbdb2..b7c125086 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -14972,6 +14972,11 @@ static int angular_http_client_typed_case(int fillers) { " deleteItem(id: string) {\n" " return this.http.delete(`/items/${id}`);\n" " }\n" + "\n" + " updateItem(id: string, item: any) {\n" + " return this.http\n" + " .put(`/items/${id}`, item);\n" + " }\n" "}\n"); for (int i = 0; i < fillers; i++) { @@ -15002,6 +15007,9 @@ static int angular_http_client_typed_case(int fillers) { ASSERT_EQ(http_calls_to_route(s, project, "getItem", "__route__GET__/items/{}"), 1); ASSERT_EQ(http_calls_to_route(s, project, "createItem", "__route__POST__/items"), 1); ASSERT_EQ(http_calls_to_route(s, project, "deleteItem", "__route__DELETE__/items/{}"), 1); + /* A chain wrapped before the verb (prettier style) is the same client call */ + ASSERT_EQ(http_calls_to_route(s, project, "updateItem", "__route__PUT__/items/{}"), 1); + ASSERT_EQ(http_calls_to_route(s, project, "updateItem", "__route__ANY__"), 0); /* Assert no ANY twins */ ASSERT_EQ(http_calls_to_route(s, project, "getItems", "__route__ANY__"), 0); From e12d3db387e7b4d5c95a7fc2033c36b1c3cbce87 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 20:59:47 +0100 Subject: [PATCH 3/5] revert(pipeline): drop two hunks that change no output (#2768) calls_emit_unresolved_service order: resolve_single_call already emits every callee-matched HTTP/ASYNC call with a URL argument before it reaches the empty-resolution fallback, and the global fetch has no route suffix, so the order inside the fallback never decides an edge. route_edge_visitor method fallback: every HTTP_CALLS emitter writes "method" exactly when cbm_service_pattern_http_method(callee) finds a verb, so recomputing it from the stored callee always yields NULL there. Both files return to their main versions. Signed-off-by: Martin Vogel --- src/pipeline/pass_calls.c | 15 +++++---------- src/pipeline/pass_route_nodes.c | 3 --- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 2a9549f26..1df1fe155 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -939,20 +939,15 @@ static int calls_emit_unresolved_service(cbm_pipeline_ctx_t *ctx, const CBMCall const char **imp_keys, const char **imp_vals, int imp_count, const char *route_mount, CBMLanguage lang, CBMCallEvidence *evidence) { - cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name); - if (svc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) { - svc = CBM_SVC_HTTP; - } - if (svc == CBM_SVC_HTTP || svc == CBM_SVC_ASYNC) { - if (calls_emit_service_call(ctx, call, source_node, svc, NULL, evidence)) { - return SKIP_ONE; - } - } if (calls_emit_route_call(ctx, call, source_node, module_qn, imp_keys, imp_vals, imp_count, route_mount, lang, evidence)) { return SKIP_ONE; } - return 0; + cbm_svc_kind_t svc = cbm_service_pattern_match(call->callee_name); + if (svc == CBM_SVC_NONE && cbm_service_pattern_is_global_fetch(call->callee_name)) { + svc = CBM_SVC_HTTP; + } + return calls_emit_service_call(ctx, call, source_node, svc, NULL, evidence); } /* Apply the field-call policy after a registry target has been found. */ diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 4e19f06ef..23dff8150 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -113,9 +113,6 @@ static void route_edge_visitor(const cbm_gbuf_edge_t *edge, void *userdata) { json_extract(edge->properties_json, "method", method_buf, sizeof(method_buf)); const char *broker = json_extract(edge->properties_json, "broker", broker_buf, sizeof(broker_buf)); - if (!method && strcmp(edge->type, "HTTP_CALLS") == 0 && callee) { - method = cbm_service_pattern_http_method(callee); - } /* Build Route QN */ char route_qn[CBM_ROUTE_QN_SIZE]; From b9bd036602e18b4a708c9d5682d41c93e341b2e0 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 20:59:47 +0100 Subject: [PATCH 4/5] test(cross-repo): seed method-less client edges so the verb fallback binds (#2768) All three seeded client edges carried "GET", so match_http_routes never entered its callee-verb fallback. getItem and getJob are now stored without a method, as an index that did not record the verb writes them. Without the fallback the method-less fuzzy match lands on the DELETE and POST routes seeded first; with it both reach their GET handlers. getItems keeps its explicit GET. Signed-off-by: Martin Vogel --- tests/test_cross_repo.c | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_cross_repo.c b/tests/test_cross_repo.c index 7c72681d2..0bf14c68b 100644 --- a/tests/test_cross_repo.c +++ b/tests/test_cross_repo.c @@ -1069,7 +1069,11 @@ static int cross_repo_count_edges_like(const cross_repo_fixture_t *fixture, cons * POST /items/{parentId}/{id} (seeded first in DB) * GET /items/job/{jobId} (seeded second in DB) * the fuzzy route matcher must enforce method compatibility, linking the GET calls - * to their respective GET endpoints and never to DELETE or POST endpoints. */ + * to their respective GET endpoints and never to DELETE or POST endpoints. + * getItem and getJob are seeded WITHOUT a method property (an edge written by an + * index that did not record the verb): the matcher must infer GET from the + * callee's verb suffix, or the method-less fuzzy match lands on the DELETE / POST + * route seeded first. getItems keeps its explicit "GET". */ TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768) { cross_repo_fixture_t fixture; bool setup = cross_repo_fixture_begin(&fixture) && @@ -1088,9 +1092,9 @@ TEST(cross_repo_angular_http_client_exact_verb_matching_issue2768) { cross_repo_seed_client_call(&fixture, "angular-2768", "getItems", "this.http.get", "/items/list", "GET") && cross_repo_seed_client_call(&fixture, "angular-2768", "getItem", - "this.http.get", "/items/42", "GET") && + "this.http.get", "/items/42", NULL) && cross_repo_seed_client_call(&fixture, "angular-2768", "getJob", - "this.http.get", "/items/job/10", "GET"); + "this.http.get", "/items/job/10", NULL); if (!setup) { cross_repo_fixture_end(&fixture); FAIL("failed to seed issue 2768 fixture"); From 6b6213e8cc46e19205b4168061fbc0edebeca3c6 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 20:59:55 +0100 Subject: [PATCH 5/5] fix(pipeline): format the arg-URL verb edge and test the path that reaches it (#2768) clang-format the new snprintf in detect_url_in_args (the line ran past 100 columns, the CI lint failure) and raise eprops to 1K: two escaped 255-byte strings plus the keys and the verb no longer fit in 512. Add a parallel-route test with wrapper calls that actually reach the arg-URL heuristic: a project wrapper whose verb is not a route suffix (this.api.head / this.api.options, URL first) and a route-verb wrapper whose URL sits in a keyword the registration reader does not take as a path (self.session.get(resource=...)). Each gets one HTTP_CALLS edge typed with its verb and no ANY twin. The sequential resolver has no arg-URL heuristic (#2848), so the test is parallel only. Signed-off-by: Martin Vogel --- src/pipeline/pass_parallel.c | 11 ++-- tests/test_pipeline.c | 97 ++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index f495e8d5b..89cc54b5d 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2610,11 +2610,14 @@ static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, char esc_n[CBM_SZ_256]; cbm_json_escape(esc_c, sizeof(esc_c), call->callee_name); cbm_json_escape(esc_n, sizeof(esc_n), norm); - char eprops[CBM_SZ_512]; + /* Two escaped 255-byte strings plus the fixed keys and the verb: 1K + * never truncates the JSON (512 could, once the method was added). */ + char eprops[CBM_SZ_1K]; if (method) { - snprintf(eprops, sizeof(eprops), - "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\",\"method\":\"%s\"}", - esc_c, esc_n, method); + snprintf( + eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\",\"method\":\"%s\"}", + esc_c, esc_n, method); } else { snprintf(eprops, sizeof(eprops), "{\"callee\":\"%s\",\"url_path\":\"%s\",\"via\":\"arg_url\"}", esc_c, esc_n); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index b7c125086..cd346936d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -15040,6 +15040,102 @@ TEST(pipeline_angular_http_client_typed_sequential) { return angular_http_client_typed_case(0); } +/* #2768: the arg-URL heuristic (detect_url_in_args, via=arg_url) covers calls + * no service pattern knows, and it now types the edge with the verb the callee + * names instead of minting a method-less __route__ANY__ route. Two wrapper + * shapes reach it on the parallel resolver: + * - a project wrapper method whose verb is not a route-registration suffix + * (`this.api.head('/items/health')`, `.options(...)`), resolved to the + * project method, so the URL may be the first argument; + * - a route-verb wrapper (`.get`) whose URL is not route shaped for the + * registration reader: here a keyword it does not read as a path + * (`self.session.get(resource="/items/list")`). + * Parallel only: the sequential resolver has no arg-URL heuristic (#2848). */ +TEST(pipeline_arg_url_wrapper_call_typed_with_verb_issue2768) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_arg_url_verb_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "src/items.api.ts", + "export class ItemsApi {\n" + " head(path: string): Promise {\n" + " return fetch(path, { method: 'HEAD' });\n" + " }\n" + " options(path: string): Promise {\n" + " return fetch(path, { method: 'OPTIONS' });\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/item.service.ts", + "import { ItemsApi } from './items.api';\n" + "export class ItemService {\n" + " constructor(private readonly api: ItemsApi) {}\n" + " pingItems() {\n" + " return this.api.head('/items/health');\n" + " }\n" + " probeItems() {\n" + " return this.api.options('/items/list');\n" + " }\n" + "}\n"); + write_temp_file(tmp, "src/reader.py", + "class ItemsSession:\n" + " def get(self, *args, **kwargs):\n" + " return args\n" + "\n" + "class Reader:\n" + " def __init__(self):\n" + " self.session = ItemsSession()\n" + "\n" + " def read_items(self):\n" + " return self.session.get(resource=\"/items/list\")\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.ts", i); + snprintf(body, sizeof(body), "export function filler%d(): number {\n return %d;\n}\n", i, + i); + write_temp_file(tmp, name, body); + } + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/arg_url_verb.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + static const struct { + const char *caller; + const char *typed_route; + } wrapped[] = {{"pingItems", "__route__HEAD__/items/health"}, + {"probeItems", "__route__OPTIONS__/items/list"}, + {"read_items", "__route__GET__/items/list"}}; + for (size_t i = 0; i < sizeof(wrapped) / sizeof(wrapped[0]); i++) { + /* The edge carries the callee's verb ... */ + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, wrapped[i].typed_route), 1); + /* ... and is the call site's only HTTP_CALLS edge: no ANY twin. */ + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, "__route__ANY__"), 0); + ASSERT_EQ(http_calls_to_route(s, project, wrapped[i].caller, "__route__"), 1); + } + /* Not a route registration */ + ASSERT_EQ(cbm_store_count_edges_by_type(s, project, "HANDLES"), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + /* #706 / #1147 end to end: a URL composed from module constants reaches the * HTTP classifier, so the client call forms an HTTP_CALLS edge. Python * `API_URL = API_BASE_URL + "/orders/create"` (env-derived base, literal path @@ -29486,6 +29582,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential); RUN_TEST(pipeline_angular_http_client_typed_parallel); RUN_TEST(pipeline_angular_http_client_typed_sequential); + RUN_TEST(pipeline_arg_url_wrapper_call_typed_with_verb_issue2768); RUN_TEST(pipeline_url_constant_folding_http_calls_issue706_1147); RUN_TEST(pipeline_folded_topics_preserve_identity_sequential_parallel); RUN_TEST(pipeline_relative_folded_wrapper_url_candidates);