fix(pipeline): infer HTTP method for Angular and verb-suffix client calls (#2768)

Signed-off-by: Divakant Pandey <divakant01@gmail.com>
This commit is contained in:
Divakant Pandey committed 2026-10-31 14:16:04 +05:30
1 parent 9f0fdcb400
commit c548d8e46a
7 files changed
+332 -10

No files matched your search

+7
View File
@@ -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},
+10 -5
View File
@@ -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. */
+10
View File
@@ -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;
}
+20 -5
View File
@@ -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;
}
+3
View File
@@ -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];
+187
View File
@@ -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);
+95
View File
@@ -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);