From c548d8e46a820cb3eb27de55fb1d0a54dab628c1 Mon Sep 17 00:00:00 2001 From: Divakant Pandey Date: Sat, 31 Oct 2026 14:16:04 +0530 Subject: [PATCH] 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);