diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index e1de896b8..e08be9c90 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -2061,6 +2061,56 @@ static void process_keyword_arg(CBMExtractCtx *ctx, TSNode arg_node, CBMCallArg } } +static const char *extract_string_value(CBMExtractCtx *ctx, TSNode val_node); + +/* A JS/TS request-config object -- `http({url: `/users/${id}`, method: 'GET'})`, + * `axios({url: '/orders'})`, the shape Orval and similar generators emit -- names + * its request URL in the `url` property. Return that URL (template literals + * flattened to the canonical "{}" form, like a bare template argument), or NULL. + * Only the `url` key counts: `path`/`endpoint` in an options object name other + * things too often. Route registrations are excluded: an object there is the + * route definition (Fastify `route({url, handler})`), not a client request, and + * its handler lives inside the object where the route pass cannot read it. So is + * axios `getUri(config)`: it takes the same config but only formats the URL and + * sends nothing (Orval's `getUrl` helpers). (#2235) */ +static bool config_object_callee_eligible(const CBMExtractCtx *ctx, const char *callee_name) { + if (ctx->language != CBM_LANG_JAVASCRIPT && ctx->language != CBM_LANG_TYPESCRIPT && + ctx->language != CBM_LANG_TSX && ctx->language != CBM_LANG_ARKTS) { + return false; + } + if (!callee_name || cbm_service_pattern_route_method(callee_name) != NULL) { + return false; + } + static const char get_uri[] = "getUri"; + size_t clen = strlen(callee_name); + size_t glen = sizeof(get_uri) - 1; + return !(clen >= glen && strcmp(callee_name + clen - glen, get_uri) == 0); +} + +static const char *js_config_object_url(CBMExtractCtx *ctx, TSNode obj, const char *callee_name) { + if (strcmp(ts_node_type(obj), "object") != 0 || + !config_object_callee_eligible(ctx, callee_name)) { + return NULL; + } + uint32_t n = ts_node_named_child_count(obj); + for (uint32_t i = 0; i < n; i++) { + TSNode pair = ts_node_named_child(obj, i); + if (strcmp(ts_node_type(pair), "pair") != 0) { + continue; + } + TSNode key_n = ts_node_child_by_field_name(pair, TS_FIELD("key")); + TSNode val_n = ts_node_child_by_field_name(pair, TS_FIELD("value")); + if (ts_node_is_null(key_n) || ts_node_is_null(val_n)) { + continue; + } + const char *key = strip_quotes(ctx->arena, cbm_node_text(ctx->arena, key_n, ctx->source)); + if (key && strcmp(key, "url") == 0) { + return extract_string_value(ctx, val_n); + } + } + return NULL; +} + /* Extract all arguments from a call expression into call->args[]. */ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) { uint32_t argc = ts_node_named_child_count(args); @@ -2103,6 +2153,10 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) { ca->value = cbm_template_string_text(ctx->arena, arg_node, ctx->source); } else if (strcmp(ak, "identifier") == 0 && ca->expr) { ca->value = lookup_string_constant(ctx, ca->expr); + } else if (strcmp(ak, "object") == 0) { + /* Request-config object: its `url` is what the arg-url + * heuristic reads for a local fetch wrapper (#2235). */ + ca->value = js_config_object_url(ctx, arg_node, call->callee_name); } else if (strcmp(ak, "call_expression") == 0) { /* URL-builder helper call (issue #1009): resolve * client(buildPath(id)) through the per-file builder map. */ @@ -2367,7 +2421,8 @@ static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const } // Extract URL/topic from keyword or positional args. -static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) { +static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args, + const char *callee_name) { uint32_t nc = ts_node_named_child_count(args); for (uint32_t ai = 0; ai < nc; ai++) { TSNode arg = ts_node_named_child(args, ai); @@ -2395,6 +2450,15 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) { continue; } + /* JS/TS request-config object: `axios({url: '/orders'})` (#2235). */ + if (strcmp(ak, "object") == 0) { + const char *val = js_config_object_url(ctx, arg, callee_name); + if (val) { + return val; + } + continue; + } + /* Cloud SDK dispatch via input struct: the queue/topic target is a field * of a composite literal (Go `&sqs.SendMessageInput{QueueUrl: ...}`), not * a bare string arg. Recover it so the async edge forms. */ @@ -3832,7 +3896,7 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML args = swift_call_args(node); } if (!ts_node_is_null(args)) { - call.first_string_arg = extract_url_or_topic_arg(ctx, args); + call.first_string_arg = extract_url_or_topic_arg(ctx, args, call.callee_name); /* #952: routes registered inside Laravel `prefix()->group()` * closures must carry the composed path — the resolve passes * only see the flat CBMCall, so the enclosing chain can only diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 1e6506ed1..c03746c6a 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4867,6 +4867,62 @@ TEST(extract_ts_template_string_url_issue1006) { PASS(); } +/* Issue #2235: a request-config object argument carries the URL in its `url` + * property (Orval/axios style: `http({url: `/x/${id}`, method: 'GET'})`). + * The property's template literal flattens to the canonical "{}" form both on + * the argument (read by the arg-url heuristic for local helpers) and as the + * call's URL (read by HTTP-client classification, e.g. `axios({url})`). Other + * keys never stand in for the URL, and a route registration keeps its own + * (object-free) handling. */ +TEST(extract_ts_config_object_url_issue2235) { + CBMFileResult *r = + extract("export const get1 = (id: string) => {\n" + " return http({url: `/api/customers/${id}`, method: 'GET'});\n" + "};\n" + "export const load = () => axios({method: 'POST', 'url': '/api/orders'});\n" + "export const other = () => http({path: '/api/ignored/x'});\n" + "export const reg = () => fastify.route({url: '/srv/x', handler: h});\n" + "export const u = () => axiosInstance.getUri({url: `/pets`});\n", + CBM_LANG_TYPESCRIPT, "t", "client.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = NULL; + const CBMCall *o = NULL; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *k = &r->calls.items[i]; + if (k->callee_name && strcmp(k->callee_name, "http") == 0 && k->arg_count > 0) { + if (k->args[0].expr && strstr(k->args[0].expr, "customers")) { + c = k; + } else { + o = k; + } + } + } + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->args[0].value); + ASSERT_STR_EQ(c->args[0].value, "/api/customers/{}"); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "/api/customers/{}"); + const CBMCall *a = find_call_by_callee(r, "axios"); + ASSERT_NOT_NULL(a); + ASSERT_NOT_NULL(a->first_string_arg); + ASSERT_STR_EQ(a->first_string_arg, "/api/orders"); + ASSERT_NOT_NULL(o); + ASSERT_NULL(o->args[0].value); + ASSERT_NULL(o->first_string_arg); + const CBMCall *g = find_call_by_callee(r, "fastify.route"); + ASSERT_NOT_NULL(g); + ASSERT_NULL(g->first_string_arg); + ASSERT(g->arg_count == 0 || g->args[0].value == NULL); + /* getUri only formats the URL; it sends no request. */ + const CBMCall *gu = find_call_by_callee(r, "axiosInstance.getUri"); + ASSERT_NOT_NULL(gu); + ASSERT_NULL(gu->first_string_arg); + ASSERT(gu->arg_count == 0 || gu->args[0].value == NULL); + cbm_free_result(r); + PASS(); +} + /* Issue #1249: a mux route built as `configVar + "/literal"` (Go's idiomatic * configurable-base-path pattern) must index the literal suffix, both for a * route registration and for an outbound URL built the same way. A real BFF @@ -8605,6 +8661,7 @@ SUITE(extraction) { RUN_TEST(extract_razor_page_directive_routes_cshtml_view); RUN_TEST(extract_razor_layout_without_page_has_no_route); RUN_TEST(extract_ts_template_string_url_issue1006); + RUN_TEST(extract_ts_config_object_url_issue2235); RUN_TEST(extract_go_binary_concat_url_issue1249); RUN_TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249); RUN_TEST(extract_ts_url_builder_issue1009); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 6ef16a89b..08d569310 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -6726,6 +6726,72 @@ TEST(pipeline_arg_url_rejects_non_http_slash_arguments) { PASS(); } +/* #2235: an Orval-style generated client passes its URL inside a request + * config object to a LOCAL helper -- `http({url: `/api/customers/${id}`, + * method: 'GET'})`. The template literal itself already canonicalizes to + * "/api/customers/{}"; what was lost is the object wrapper: the arg-url + * heuristic only read bare string/template arguments, so the call produced + * no HTTP_CALLS edge and cross-repo matching had nothing to join. Parallel + * resolver (> 50 files), where the arg-url heuristic lives. A config object + * without a `url` key must not mint a route. */ +TEST(pipeline_ts_config_object_url_http_calls_issue2235) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_i2235_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "src/http.ts", + "export const http = (config: { url: string; method: string }): " + "Promise => {\n" + " return fetch(config.url, { method: config.method }).then((r) => r.json());\n" + "};\n"); + write_temp_file( + tmp, "src/gen/customer-controller.ts", + "import { http } from '../http';\n" + "export const get1 = (id: string, signal?: AbortSignal) => {\n" + " return http({url: `/api/customers/${id}`, method: 'GET', signal});\n" + "};\n" + "export const noUrl = () => {\n" + " return http({path: '/api/ignored/x', method: 'GET'});\n" + "};\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 { return %d; }\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/i2235.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); + + /* RED before the fix: the url inside the config object was never read. */ + ASSERT_EQ(named_edge_count(s, project, "HTTP_CALLS", "get1", "/api/customers/{}"), 1); + /* Only the `url` key names the request URL. */ + ASSERT_EQ(count_nodes_named(s, project, "/api/ignored/x"), 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(); +} + /* Native `fetch()` (#856), sequential path (< 50 files → pass_calls.c). A bare * unqualified call to the global fetch API has no import and no local * definition anywhere in this project, so registry resolution comes back @@ -15160,6 +15226,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier); RUN_TEST(pipeline_parallel_rust_cross_only_macro_hidden_gets_synthetic_carrier); RUN_TEST(pipeline_arg_url_rejects_non_http_slash_arguments); + RUN_TEST(pipeline_ts_config_object_url_http_calls_issue2235); RUN_TEST(pipeline_native_fetch_classified_as_http_calls); RUN_TEST(pipeline_swift_nested_url_makes_route_issue1892); RUN_TEST(pipeline_swift_http_call_makes_route_issue1892);