mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-31 07:33:32 +08:00
fix(extract): read the url of a JS/TS request-config object (#2235)
A generated API client (Orval and similar) passes its request as a config
object to a local fetch wrapper:
http<Customer>({url: `/api/customers/${id}`, method: 'GET', signal})
The template literal itself already canonicalizes to "/api/customers/{}"
(#1006). What was lost is the object wrapper: the call's arguments only
carried a value for bare strings, template literals, constants and URL
builders, so the object argument had no URL. The arg-url heuristic
therefore minted no HTTP_CALLS edge, and cross-repo matching had nothing to
join against the server's GET /api/customers/{id} Route. The same held for
HTTP clients called with a config object (`axios({url})`): no URL, no edge.
The extractor now reads the `url` property of an object argument in
JS/TS/TSX/ArkTS -- both as that argument's value (for the arg-url
heuristic) and as the call's URL (for HTTP-client classification). Only
the `url` key counts. Route registrations (Fastify `route({url, handler})`)
and axios `getUri(config)`, which formats a URL and sends nothing, are
excluded.
orval-labs/orval @ 08d7fcf1 (parallel resolver): HTTP_CALLS 1383 -> 1533
(+150 arg_url edges from the generated mutators such as customInstance,
responseType and customClient; 0 lost), Route 302 -> 308, CALLS unchanged
(19190). Without the getUri exclusion there would have been 546 URL-builder edges.
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
@@ -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 `get<Op>Url` 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
|
||||
|
||||
@@ -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<Customer>({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);
|
||||
|
||||
@@ -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 = <T,>(config: { url: string; method: string }): "
|
||||
"Promise<T> => {\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<unknown>({url: `/api/customers/${id}`, method: 'GET', signal});\n"
|
||||
"};\n"
|
||||
"export const noUrl = () => {\n"
|
||||
" return http<unknown>({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);
|
||||
|
||||
Reference in New Issue
Block a user