diff --git a/Makefile.cbm b/Makefile.cbm index c0e0aa4a0..f1402881b 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -366,6 +366,7 @@ EXTRACTION_SRCS = \ $(CBM_DIR)/extract_imports.c \ $(CBM_DIR)/extract_usages.c \ $(CBM_DIR)/extract_unified.c \ + $(CBM_DIR)/extract_url_fold.c \ $(CBM_DIR)/extract_semantic.c \ $(CBM_DIR)/extract_type_refs.c \ $(CBM_DIR)/extract_type_assigns.c \ diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index f9d42ec36..e9dad3427 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -309,9 +309,10 @@ typedef struct { /* Argument captured from a call expression */ typedef struct { const char *expr; // raw expression text ("payload.info", "MY_URL", "'hello'") - const char *value; // resolved string value or NULL (constant propagation) + const char *value; // raw resolved string value or NULL (constant propagation) const char *keyword; // keyword name if keyword arg ("url", "topic_id"), NULL if positional int index; // positional index (0-based) + const char *url_value; // URL projection when different; never a topic/string identity } CBMCallArg; #define CBM_MAX_CALL_ARGS 8 @@ -868,6 +869,7 @@ typedef struct { typedef struct { const char *names[CBM_MAX_STRING_CONSTANTS]; const char *values[CBM_MAX_STRING_CONSTANTS]; + const char *url_values[CBM_MAX_STRING_CONSTANTS]; // extraction-only URL projections bool is_url_builder[CBM_MAX_STRING_CONSTANTS]; int count; } CBMStringConstantMap; diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index a86367b6b..112f4a8cf 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -4,6 +4,7 @@ #include "lang_specs.h" #include "macro_table.h" #include "extract_unified.h" +#include "extract_url_fold.h" // URL constant folding (#706, #1147) #include "foundation/constants.h" #include "extract_node_stack.h" #include "service_patterns.h" // cbm_service_pattern_route_method (#952) @@ -77,6 +78,14 @@ static const char *strip_quotes(CBMArena *a, const char *text) { return text; } +/* A constant reference in a folding language (#706, #1147): when it does not + * fold to a usable value it stays unresolved -- the raw map entry may hold an + * unresolved "{}" base and is never a URL on its own. */ +static bool is_folded_reference(const CBMExtractCtx *ctx, const char *kind) { + return cbm_url_fold_lang(ctx->language) && + (strcmp(kind, "identifier") == 0 || strcmp(kind, "member_expression") == 0); +} + // Callee suffixes for IRIS Python interop string-dispatch. Kept at file scope // (not inside the function) to satisfy cppcheck variableScope. static const char *s_py_dispatch_suffixes[] = {".classMethodValue", ".classMethodVoid", @@ -2428,6 +2437,30 @@ static const char *python_iris_cls_callee(CBMExtractCtx *ctx, TSNode call_node) // --- Unified handler: called once per node by the cursor walk --- +/* Topic slots carry their literal identity even when a URL projection exists. */ +static bool is_topic_keyword(const char *key) { + static const char *keywords[] = {"topic", "topic_id", "topic_name", "queue", "queue_name", + "queue_id", "subject", "channel", NULL}; + for (int i = 0; key && keywords[i]; i++) { + if (strcmp(key, keywords[i]) == 0) { + return true; + } + } + return false; +} + +/* Generic argument values stay raw. Only URL consumers may select the separate + * projection, and explicit topic slots never carry one. */ +static void fold_call_arg(CBMExtractCtx *ctx, TSNode node, CBMCallArg *ca) { + ca->value = cbm_url_fold_exact(ctx, node); + if (!is_topic_keyword(ca->keyword)) { + const char *url = cbm_url_fold_call_url(ctx, node); + if (url && (!ca->value || strcmp(url, ca->value) != 0)) { + ca->url_value = url; + } + } +} + // Process a keyword argument (keyword_argument or pair node). static void process_keyword_arg(CBMExtractCtx *ctx, TSNode arg_node, CBMCallArg *ca) { TSNode key_n = ts_node_child_by_field_name(arg_node, TS_FIELD("name")); @@ -2440,6 +2473,11 @@ static void process_keyword_arg(CBMExtractCtx *ctx, TSNode arg_node, CBMCallArg } if (!ts_node_is_null(val_n)) { ca->expr = cbm_node_text(ctx->arena, val_n, ctx->source); + /* Only a fully resolved fold is an exact value (#706, #1147). */ + fold_call_arg(ctx, val_n, ca); + if (ca->value || is_folded_reference(ctx, ts_node_type(val_n))) { + return; + } if (strcmp(ts_node_type(val_n), "identifier") == 0 && ca->expr) { ca->value = lookup_string_constant(ctx, ca->expr); } else if (is_string_like(ts_node_type(val_n)) && ca->expr) { @@ -2448,7 +2486,7 @@ static void process_keyword_arg(CBMExtractCtx *ctx, TSNode arg_node, CBMCallArg } } -static const char *extract_string_value(CBMExtractCtx *ctx, TSNode val_node); +static const char *extract_string_value(CBMExtractCtx *ctx, TSNode val_node, bool url_context); /* A JS/TS request-config object -- `http({url: `/users/${id}`, method: 'GET'})`, * `axios({url: '/orders'})`, the shape Orval and similar generators emit -- names @@ -2474,7 +2512,8 @@ static bool config_object_callee_eligible(const CBMExtractCtx *ctx, const char * 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) { +static const char *js_config_object_url(CBMExtractCtx *ctx, TSNode obj, const char *callee_name, + bool url_context) { if (strcmp(ts_node_type(obj), "object") != 0 || !config_object_callee_eligible(ctx, callee_name)) { return NULL; @@ -2492,7 +2531,7 @@ static const char *js_config_object_url(CBMExtractCtx *ctx, TSNode obj, const ch } 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 extract_string_value(ctx, val_n, url_context); } } return NULL; @@ -2532,6 +2571,14 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) { } else { ca->expr = cbm_node_text(ctx->arena, arg_node, ctx->source); ca->index = positional_idx++; + /* Python / JS / TS: a URL composed from module constants carries its + * folded value, but only when nothing leading stayed unresolved + * (#706, #1147); otherwise the long-standing handling below. */ + fold_call_arg(ctx, arg_node, ca); + if (ca->value || is_folded_reference(ctx, ak)) { + call->arg_count++; + continue; + } if (is_string_like(ak) && ca->expr) { ca->value = strip_quotes(ctx->arena, ca->expr); } else if (strcmp(ak, "template_string") == 0) { @@ -2543,7 +2590,11 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) { } 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); + ca->value = js_config_object_url(ctx, arg_node, call->callee_name, false); + const char *url = js_config_object_url(ctx, arg_node, call->callee_name, true); + if (url && (!ca->value || strcmp(url, ca->value) != 0)) { + ca->url_value = url; + } } else if (strcmp(ak, "call_expression") == 0) { /* URL-builder helper call (issue #1009): resolve * client(buildPath(id)) through the per-file builder map. */ @@ -2564,20 +2615,12 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) { static bool is_url_or_topic_keyword(const char *key) { static const char *url_keywords[] = {"url", "endpoint", "path", "uri", "target_url", "base_url", NULL}; - static const char *topic_keywords[] = {"topic", "topic_id", "topic_name", - "queue", "queue_name", "queue_id", - "subject", "channel", NULL}; for (int i = 0; url_keywords[i]; i++) { if (strcmp(key, url_keywords[i]) == 0) { return true; } } - for (int i = 0; topic_keywords[i]; i++) { - if (strcmp(key, topic_keywords[i]) == 0) { - return true; - } - } - return false; + return is_topic_keyword(key); } // Check if a struct-field name identifies a queue/topic target. Cloud SDKs pass @@ -2599,8 +2642,13 @@ static bool is_queue_topic_field(const char *key) { } // Extract string value from a node (literal or constant reference). -static const char *extract_string_value(CBMExtractCtx *ctx, TSNode val_node) { +static const char *extract_string_value(CBMExtractCtx *ctx, TSNode val_node, bool url_context) { const char *vk = ts_node_type(val_node); + const char *folded = + url_context ? cbm_url_fold_call_url(ctx, val_node) : cbm_url_fold_call_raw(ctx, val_node); + if (folded || is_folded_reference(ctx, vk)) { + return folded; + } if (strcmp(vk, "template_string") == 0) { return cbm_template_string_text(ctx->arena, val_node, ctx->source); } @@ -2671,7 +2719,7 @@ static const char *extract_composite_queue_field(CBMExtractCtx *ctx, TSNode node if (!is_queue_topic_field(key)) { continue; } - const char *resolved = extract_string_value(ctx, val_n); + const char *resolved = extract_string_value(ctx, val_n, false); if (resolved && resolved[0]) { return resolved; } @@ -2686,7 +2734,7 @@ static const char *extract_composite_queue_field(CBMExtractCtx *ctx, TSNode node } // Try to extract URL/topic from a keyword_argument or pair node. -static const char *extract_keyword_url(CBMExtractCtx *ctx, TSNode arg) { +static const char *extract_keyword_url(CBMExtractCtx *ctx, TSNode arg, const char *callee_name) { TSNode key_node = ts_node_child_by_field_name(arg, TS_FIELD("name")); TSNode val_node = ts_node_child_by_field_name(arg, TS_FIELD("value")); if (ts_node_is_null(key_node)) { @@ -2699,7 +2747,10 @@ static const char *extract_keyword_url(CBMExtractCtx *ctx, TSNode arg) { if (!key || !is_url_or_topic_keyword(key)) { return NULL; } - return extract_string_value(ctx, val_node); + bool url_context = + !is_topic_keyword(key) && (cbm_service_pattern_match(callee_name) == CBM_SVC_HTTP || + cbm_service_pattern_is_global_fetch(callee_name)); + return extract_string_value(ctx, val_node, url_context); } // `prefixVar + "/route"` (Go's idiomatic configurable-base-path pattern): @@ -2776,12 +2827,43 @@ static TSNode swift_unwrap_url_constructor(CBMExtractCtx *ctx, TSNode arg) { return swift_argument_value(ts_node_named_child(inner, 0)); } -static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak) { +/* `${BASE}/route` -- the JS/TS twin of #1249's `base + "/route"`. A template + * that opens with one substitution and continues with a literal path is a + * configurable base URL (`${process.env.BACKEND_URL}/wallet`), so the route is + * the literal path after it. Anything else (`${a}${b}`, `${BASE}/`, a template + * that opens with text) comes back unchanged: no path is fabricated (#2291). */ +static const char *template_path_after_base(TSNode tpl, const char *flat) { + if (ts_node_named_child_count(tpl) < PAIR_LEN || + strcmp(ts_node_type(ts_node_named_child(tpl, 0)), "template_substitution") != 0) { + return flat; + } + const char *path = flat + PAIR_LEN; /* past the base's "{}" placeholder */ + /* `${base}/${tail}` has no literal path to recover: "/{}" is not a route. */ + return (path[0] == '/' && cbm_url_has_literal_path(path)) ? path : flat; +} + +static const char *extract_positional_url(CBMExtractCtx *ctx, TSNode arg, const char *ak, + bool url_context) { + /* Python / JS / TS: fold a URL composed from module constants (#706, + * #1147). A reference that folds to nothing usable stays unresolved; a + * template or concatenation falls back to the #1006 / #1249 / #2291 + * handling below. */ + const char *folded = + url_context ? cbm_url_fold_call_url(ctx, arg) : cbm_url_fold_call_raw(ctx, arg); + if (folded) { + return strip_and_validate_string_arg(ctx->arena, (char *)folded); + } + if (is_folded_reference(ctx, ak)) { + return NULL; + } /* JS/TS template literals: `/things/${id}` normalizes to "/things/{}" so the * client URL joins the server route's canonical placeholder (issue #1006). */ if (strcmp(ak, "template_string") == 0) { const char *flat = cbm_template_string_text(ctx->arena, arg, ctx->source); if (flat) { + if (url_context) { + flat = template_path_after_base(arg, flat); + } return strip_and_validate_string_arg(ctx->arena, (char *)flat); } } @@ -2830,7 +2912,7 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args, const char *ak = ts_node_type(arg); if (strcmp(ak, "keyword_argument") == 0 || strcmp(ak, "pair") == 0) { - const char *val = extract_keyword_url(ctx, arg); + const char *val = extract_keyword_url(ctx, arg, callee_name); if (val) { return val; } @@ -2839,7 +2921,9 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args, /* 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); + bool url_context = cbm_service_pattern_match(callee_name) == CBM_SVC_HTTP || + cbm_service_pattern_is_global_fetch(callee_name); + const char *val = js_config_object_url(ctx, arg, callee_name, url_context); if (val) { return val; } @@ -2870,7 +2954,11 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args, } if (ai < MAX_POSITIONAL_SCAN) { - const char *val = extract_positional_url(ctx, arg, ak); + /* Unknown/aliased callees keep raw text until service resolution. + * Relative HTTP candidates remain available in args[].url_value. */ + bool url_context = cbm_service_pattern_match(callee_name) == CBM_SVC_HTTP || + cbm_service_pattern_is_global_fetch(callee_name); + const char *val = extract_positional_url(ctx, arg, ak, url_context); if (val) { return val; } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 4e83ca900..f0590de5f 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -2,8 +2,9 @@ #include "arena.h" // cbm_arena_sprintf #include "cbm.h" // CBMExtractCtx #include "helpers.h" -#include "lang_specs.h" // CBMLangSpec, cbm_lang_spec, CBM_LANG_* -#include "tree_sitter/api.h" // TSNode, TSTreeCursor, ts_tree_cursor_*, ts_node_* +#include "extract_url_fold.h" // cbm_url_fold_record_constant (#706, #1147) +#include "lang_specs.h" // CBMLangSpec, cbm_lang_spec, CBM_LANG_* +#include "tree_sitter/api.h" // TSNode, TSTreeCursor, ts_tree_cursor_*, ts_node_* #include "foundation/constants.h" #include "foundation/compat.h" // cbm_thread_cpu_time_ns #include @@ -1219,6 +1220,14 @@ static void handle_string_constants(CBMExtractCtx *ctx, TSNode node, const WalkS return; } + /* Python and JS/TS fold the value: `BASE + "/path"`, `${BASE}/path`, + * f"{BASE}/path" and object-literal endpoint maps (#706, #1147). */ + if (cbm_url_fold_lang(ctx->language)) { + cbm_url_fold_record_constant(ctx, cbm_node_text(ctx->arena, name_node, ctx->source), + value_node); + return; + } + /* Value must be a string literal (template literals flatten to "{}" form) */ const char *value_kind = ts_node_type(value_node); const char *flat_value = NULL; diff --git a/internal/cbm/extract_url_fold.c b/internal/cbm/extract_url_fold.c new file mode 100644 index 000000000..63f8b4bb5 --- /dev/null +++ b/internal/cbm/extract_url_fold.c @@ -0,0 +1,451 @@ +/* URL constant folding for HTTP client calls (issues #706, #1147). + * See extract_url_fold.h for the contract. */ +#include "extract_url_fold.h" +#include "arena.h" // cbm_arena_strndup, cbm_arena_sprintf +#include "helpers.h" +#include "foundation/constants.h" +#include +#include +#include +#include +#include + +/* FOLD_BUF matches the template flattener's limit; FOLD_MAX_DEPTH bounds the + * recursion of nested `a + b + c` chains and templates; object-literal endpoint + * maps are followed at most FOLD_MAX_OBJ_DEPTH levels deep. */ +enum { FOLD_BUF = 512, FOLD_MAX_DEPTH = 16, FOLD_MAX_OBJ_DEPTH = 4, FOLD_NAME_BUF = 256 }; + +static const char k_unknown[] = "{}"; + +bool cbm_url_fold_lang(CBMLanguage lang) { + return lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || + lang == CBM_LANG_TSX; +} + +static bool at_placeholder(const char *s) { + return s[0] == '{' && s[SKIP_ONE] == '}'; +} + +bool cbm_url_has_literal_path(const char *url) { + if (!url) { + return false; + } + const char *p = url; + while (*p) { + if (at_placeholder(p)) { + p += PAIR_LEN; + } else if (*p == '/') { + p++; + } else { + return true; + } + } + return false; +} + +/* Any text at all besides "{}" placeholders ("/" counts: a root constant). */ +static bool has_literal_text(const char *s) { + const char *p = s; + while (*p) { + if (!at_placeholder(p)) { + return true; + } + p += PAIR_LEN; + } + return false; +} + +/* "some/path", "api/users/{}": a relative URL path of at least two segments. + * Only such a value is read as a path under a base URL. */ +static bool relative_path_shaped(const char *s) { + if (!isalnum((unsigned char)s[0]) && s[0] != '_') { + return false; + } + bool slash = false; + bool segment_after_slash = false; + for (const char *p = s; *p; p++) { + unsigned char c = (unsigned char)*p; + if (c == '/') { + if (p[SKIP_ONE] == '/') { + return false; + } + slash = true; + continue; + } + if (!isalnum(c) && !strchr("-_.~%{}", c)) { + return false; + } + segment_after_slash = segment_after_slash || slash; + } + return slash && segment_after_slash; +} + +static const char *lookup_constant(const CBMExtractCtx *ctx, const char *name, bool url) { + const CBMStringConstantMap *map = &ctx->string_constants; + for (int i = 0; i < map->count; i++) { + if (!map->is_url_builder[i] && map->values[i] && strcmp(map->names[i], name) == 0) { + return url && map->url_values[i] ? map->url_values[i] : map->values[i]; + } + } + return NULL; +} + +static void push_constant(CBMExtractCtx *ctx, const char *name, const char *value, + const char *url_value) { + CBMStringConstantMap *map = &ctx->string_constants; + if (!name || !value || map->count >= CBM_MAX_STRING_CONSTANTS) { + return; + } + map->names[map->count] = name; + map->values[map->count] = value; + map->url_values[map->count] = url_value; + map->is_url_builder[map->count] = false; + map->count++; +} + +/* --- folding --------------------------------------------------------- */ + +typedef struct { + char text[FOLD_BUF]; + size_t len; + bool overflow; + bool url; /* URL projection; false preserves raw topic/string identity */ + bool base_ref; /* the first part is a resolved constant reference */ + bool has_parts; /* at least one part appended */ +} fold_buf_t; + +static const char *fold_node(CBMExtractCtx *ctx, TSNode node, int depth, bool url); + +static void buf_append(fold_buf_t *b, const char *piece) { + /* Only URL projections collapse the boundary between a base ending in '/' + * and a path starting with '/'. Raw strings/topics retain both separators + * ("tenant/" + "/events" is "tenant//events"). Avoid doubling a separator ("{}/" + "/api/x"); a + * scheme's "://" is kept. */ + if (b->url && b->len > 0 && b->text[b->len - SKIP_ONE] == '/' && piece[0] == '/' && + !(b->len >= PAIR_LEN && b->text[b->len - PAIR_LEN] == ':')) { + piece++; + } + size_t pl = strlen(piece); + if (b->len + pl >= FOLD_BUF) { + b->overflow = true; + return; + } + memcpy(b->text + b->len, piece, pl); + b->len += pl; + b->text[b->len] = '\0'; +} + +static bool is_reference_kind(const char *k) { + return strcmp(k, "identifier") == 0 || strcmp(k, "member_expression") == 0 || + strcmp(k, "template_substitution") == 0 || strcmp(k, "interpolation") == 0; +} + +/* Append one part of a composition. The value of a template substitution or + * f-string interpolation is its inner expression. */ +static void fold_part(CBMExtractCtx *ctx, fold_buf_t *b, TSNode part, int depth) { + const char *k = ts_node_type(part); + TSNode expr = part; + if (strcmp(k, "template_substitution") == 0) { + expr = ts_node_named_child(part, 0); + } else if (strcmp(k, "interpolation") == 0) { + expr = ts_node_child_by_field_name(part, TS_FIELD("expression")); + } + const char *v = ts_node_is_null(expr) ? NULL : fold_node(ctx, expr, depth + SKIP_ONE, b->url); + if (!b->has_parts) { + b->base_ref = v && is_reference_kind(k) && !at_placeholder(v); + } + b->has_parts = true; + buf_append(b, v ? v : k_unknown); +} + +static void fold_literal_piece(CBMExtractCtx *ctx, fold_buf_t *b, TSNode piece) { + const char *t = cbm_node_text(ctx->arena, piece, ctx->source); + b->has_parts = true; + buf_append(b, t ? t : ""); +} + +/* In URL projections, a resolved first constant is `BASE + path`: a + * relative BASE ('api') joined to a path is a path under the client's base + * URL, so it is read from the root ("/api/users/me"). */ +static const char *finish(CBMExtractCtx *ctx, const fold_buf_t *b) { + if (b->overflow) { + return NULL; + } + if (b->url && b->base_ref && b->text[0] != '/' && !strstr(b->text, "://") && + relative_path_shaped(b->text)) { + return cbm_arena_sprintf(ctx->arena, "/%s", b->text); + } + return cbm_arena_strndup(ctx->arena, b->text, b->len); +} + +/* JS/TS `string`, Python `string` (also f-strings), JS `template_string`. */ +static const char *fold_string_like(CBMExtractCtx *ctx, TSNode node, int depth, bool url) { + fold_buf_t b = {.len = 0, .url = url}; + b.text[0] = '\0'; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + TSNode c = ts_node_named_child(node, i); + const char *k = ts_node_type(c); + if (strcmp(k, "template_substitution") == 0 || strcmp(k, "interpolation") == 0) { + fold_part(ctx, &b, c, depth); + } else if (strcmp(k, "string_fragment") == 0 || strcmp(k, "string_content") == 0 || + strcmp(k, "escape_sequence") == 0) { + fold_literal_piece(ctx, &b, c); + } else if (strcmp(k, "escape_interpolation") == 0) { + b.has_parts = true; + buf_append(&b, "{"); /* `{{` in an f-string is one literal brace */ + } + } + return finish(ctx, &b); +} + +static bool is_plus(CBMExtractCtx *ctx, TSNode bin) { + TSNode op = ts_node_child_by_field_name(bin, TS_FIELD("operator")); + if (ts_node_is_null(op)) { + return false; + } + const char *t = cbm_node_text(ctx->arena, op, ctx->source); + return t && strcmp(t, "+") == 0; +} + +/* `a + b` (JS binary_expression / Python binary_operator). */ +static const char *fold_concat(CBMExtractCtx *ctx, TSNode node, int depth, bool url) { + if (!is_plus(ctx, node)) { + return NULL; + } + TSNode left = ts_node_child_by_field_name(node, TS_FIELD("left")); + TSNode right = ts_node_child_by_field_name(node, TS_FIELD("right")); + if (ts_node_is_null(left) || ts_node_is_null(right)) { + return NULL; + } + fold_buf_t b = {.len = 0, .url = url}; + b.text[0] = '\0'; + fold_part(ctx, &b, left, depth); + fold_part(ctx, &b, right, depth); + return finish(ctx, &b); +} + +/* Python implicit concatenation: "a" "b". */ +static const char *fold_implicit_concat(CBMExtractCtx *ctx, TSNode node, int depth, bool url) { + fold_buf_t b = {.len = 0, .url = url}; + b.text[0] = '\0'; + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + fold_part(ctx, &b, ts_node_named_child(node, i), depth); + } + return finish(ctx, &b); +} + +/* "A.B.C" for a JS member chain over identifiers, or false. */ +static bool dotted_name(CBMExtractCtx *ctx, TSNode node, char *out, size_t cap, int depth) { + const char *k = ts_node_type(node); + if (strcmp(k, "identifier") == 0) { + const char *t = cbm_node_text(ctx->arena, node, ctx->source); + return t && snprintf(out, cap, "%s", t) < (int)cap; + } + if (strcmp(k, "member_expression") != 0 || depth >= FOLD_MAX_OBJ_DEPTH) { + return false; + } + TSNode obj = ts_node_child_by_field_name(node, TS_FIELD("object")); + TSNode prop = ts_node_child_by_field_name(node, TS_FIELD("property")); + if (ts_node_is_null(obj) || ts_node_is_null(prop) || + strcmp(ts_node_type(prop), "property_identifier") != 0 || + !dotted_name(ctx, obj, out, cap, depth + SKIP_ONE)) { + return false; + } + const char *p = cbm_node_text(ctx->arena, prop, ctx->source); + size_t used = strlen(out); + return p && snprintf(out + used, cap - used, ".%s", p) < (int)(cap - used); +} + +static const char *fold_reference(CBMExtractCtx *ctx, TSNode node, bool url) { + char name[FOLD_NAME_BUF]; + if (!dotted_name(ctx, node, name, sizeof(name), 0)) { + return NULL; + } + return lookup_constant(ctx, name, url); +} + +/* Type-only wrappers and grouping: `x as const`, `x satisfies T`, `x!`, `(x)`. */ +static bool is_transparent_wrapper(const char *k) { + return strcmp(k, "parenthesized_expression") == 0 || strcmp(k, "as_expression") == 0 || + strcmp(k, "satisfies_expression") == 0 || strcmp(k, "non_null_expression") == 0; +} + +static const char *fold_node(CBMExtractCtx *ctx, TSNode node, int depth, bool url) { + if (depth > FOLD_MAX_DEPTH || ts_node_is_null(node)) { + return NULL; + } + const char *k = ts_node_type(node); + if (strcmp(k, "string") == 0 || strcmp(k, "template_string") == 0) { + return fold_string_like(ctx, node, depth, url); + } + if (strcmp(k, "identifier") == 0 || strcmp(k, "member_expression") == 0) { + return fold_reference(ctx, node, url); + } + if (strcmp(k, "binary_expression") == 0 || strcmp(k, "binary_operator") == 0) { + return fold_concat(ctx, node, depth, url); + } + if (strcmp(k, "concatenated_string") == 0) { + return fold_implicit_concat(ctx, node, depth, url); + } + if (is_transparent_wrapper(k) && ts_node_named_child_count(node) > 0) { + return fold_node(ctx, ts_node_named_child(node, 0), depth + SKIP_ONE, url); + } + return NULL; +} + +/* --- constants ------------------------------------------------------- */ + +/* Strip type wrappers and `Object.freeze(...)` from a constant's value. */ +static TSNode unwrap_value(CBMExtractCtx *ctx, TSNode v) { + for (int i = 0; i < FOLD_MAX_OBJ_DEPTH && !ts_node_is_null(v); i++) { + const char *k = ts_node_type(v); + if (is_transparent_wrapper(k) && ts_node_named_child_count(v) > 0) { + v = ts_node_named_child(v, 0); + continue; + } + if (strcmp(k, "call_expression") != 0) { + break; + } + TSNode fn = ts_node_child_by_field_name(v, TS_FIELD("function")); + TSNode args = ts_node_child_by_field_name(v, TS_FIELD("arguments")); + const char *ft = ts_node_is_null(fn) ? NULL : cbm_node_text(ctx->arena, fn, ctx->source); + if (!ft || strcmp(ft, "Object.freeze") != 0 || ts_node_is_null(args) || + ts_node_named_child_count(args) != SKIP_ONE) { + break; + } + v = ts_node_named_child(args, 0); + } + return v; +} + +static void record_value(CBMExtractCtx *ctx, const char *name, TSNode value, int depth); + +/* The key of an object-literal pair as a plain name, or NULL. */ +static const char *pair_key(CBMExtractCtx *ctx, TSNode pair) { + TSNode key = ts_node_child_by_field_name(pair, TS_FIELD("key")); + if (ts_node_is_null(key)) { + return NULL; + } + const char *k = ts_node_type(key); + if (strcmp(k, "property_identifier") == 0) { + return cbm_node_text(ctx->arena, key, ctx->source); + } + if (strcmp(k, "string") == 0) { + return fold_string_like(ctx, key, 0, false); + } + return NULL; +} + +static void record_object(CBMExtractCtx *ctx, const char *prefix, TSNode obj, int depth) { + uint32_t nc = ts_node_named_child_count(obj); + for (uint32_t i = 0; i < nc; i++) { + TSNode member = ts_node_named_child(obj, i); + const char *k = ts_node_type(member); + if (strcmp(k, "pair") == 0) { + const char *key = pair_key(ctx, member); + TSNode value = ts_node_child_by_field_name(member, TS_FIELD("value")); + if (key && key[0] && !ts_node_is_null(value)) { + record_value(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, key), value, + depth + SKIP_ONE); + } + } else if (strcmp(k, "shorthand_property_identifier") == 0) { + /* `{ BASE }` carries the constant of the same name. */ + const char *key = cbm_node_text(ctx->arena, member, ctx->source); + const char *v = key ? lookup_constant(ctx, key, false) : NULL; + if (v) { + push_constant(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", prefix, key), v, + lookup_constant(ctx, key, true)); + } + } + } +} + +static void record_value(CBMExtractCtx *ctx, const char *name, TSNode value, int depth) { + if (!name || depth > FOLD_MAX_OBJ_DEPTH) { + return; + } + value = unwrap_value(ctx, value); + if (ts_node_is_null(value)) { + return; + } + if (strcmp(ts_node_type(value), "object") == 0) { + record_object(ctx, name, value, depth); + return; + } + const char *v = fold_node(ctx, value, 0, false); + if (v && has_literal_text(v)) { + push_constant(ctx, name, v, fold_node(ctx, value, 0, true)); + } +} + +void cbm_url_fold_record_constant(CBMExtractCtx *ctx, const char *name, TSNode value) { + if (!name || !name[0] || ts_node_is_null(value)) { + return; + } + record_value(ctx, name, value, 0); +} + +/* --- call sites ------------------------------------------------------ */ + +/* Python strings fold only when they interpolate (an f-string); a plain + * literal keeps the long-standing literal path. */ +static bool is_interpolated_string(TSNode node) { + uint32_t nc = ts_node_named_child_count(node); + for (uint32_t i = 0; i < nc; i++) { + if (strcmp(ts_node_type(ts_node_named_child(node, i)), "interpolation") == 0) { + return true; + } + } + return false; +} + +static bool is_foldable_arg(TSNode arg) { + const char *k = ts_node_type(arg); + if (strcmp(k, "string") == 0) { + return is_interpolated_string(arg); + } + return strcmp(k, "template_string") == 0 || strcmp(k, "binary_expression") == 0 || + strcmp(k, "binary_operator") == 0 || strcmp(k, "identifier") == 0 || + strcmp(k, "member_expression") == 0 || strcmp(k, "concatenated_string") == 0 || + is_transparent_wrapper(k); +} + +const char *cbm_url_fold_call_url(CBMExtractCtx *ctx, TSNode arg) { + if (!cbm_url_fold_lang(ctx->language) || ts_node_is_null(arg) || !is_foldable_arg(arg)) { + return NULL; + } + const char *s = fold_node(ctx, arg, 0, true); + if (!s) { + return NULL; + } + if (!at_placeholder(s)) { + return has_literal_text(s) ? s : NULL; + } + /* Unresolvable base: keep the literal path after it, never the base. */ + const char *rest = s + PAIR_LEN; + if (rest[0] == '/') { + return cbm_url_has_literal_path(rest) ? rest : NULL; + } + if (relative_path_shaped(rest)) { + return cbm_arena_sprintf(ctx->arena, "/%s", rest); + } + return NULL; +} + +const char *cbm_url_fold_exact(CBMExtractCtx *ctx, TSNode arg) { + if (!cbm_url_fold_lang(ctx->language) || ts_node_is_null(arg) || !is_foldable_arg(arg)) { + return NULL; + } + const char *s = fold_node(ctx, arg, 0, false); + return (s && !at_placeholder(s) && has_literal_text(s)) ? s : NULL; +} + +const char *cbm_url_fold_call_raw(CBMExtractCtx *ctx, TSNode arg) { + if (!cbm_url_fold_lang(ctx->language) || ts_node_is_null(arg) || !is_foldable_arg(arg)) { + return NULL; + } + const char *s = fold_node(ctx, arg, 0, false); + return s && has_literal_text(s) ? s : NULL; +} diff --git a/internal/cbm/extract_url_fold.h b/internal/cbm/extract_url_fold.h new file mode 100644 index 000000000..6dae8bd52 --- /dev/null +++ b/internal/cbm/extract_url_fold.h @@ -0,0 +1,45 @@ +#ifndef CBM_EXTRACT_URL_FOLD_H +#define CBM_EXTRACT_URL_FOLD_H + +/* URL constant folding for HTTP client calls (issues #706, #1147). + * + * A caller that centralises its endpoints writes the URL as an expression over + * module constants -- `API_URL = API_BASE_URL + "some/path"` in Python, + * `${BASE}/games/launch` or `API_ENDPOINTS.USER` in JS/TS. These helpers fold + * such an expression into one string: string literals and module-level string + * constants (including members of an object-literal constant) inline, and a + * part that cannot be resolved becomes the canonical "{}" placeholder. Python + * and the JS/TS family only. */ + +#include "cbm.h" +#include "tree_sitter/api.h" + +/* True for the languages whose expressions are folded. */ +bool cbm_url_fold_lang(CBMLanguage lang); + +/* True when a URL carries something literal besides '/' and "{}" + * placeholders, i.e. a real path to recover rather than only unknowns. */ +bool cbm_url_has_literal_path(const char *url); + +/* Record a module-level `name = value` in the per-file string constant map + * with separate raw and URL-projected values. An object-literal value records each string-valued + * member as "name.KEY" (nested objects as "name.A.B"). */ +void cbm_url_fold_record_constant(CBMExtractCtx *ctx, const char *name, TSNode value); + +/* The URL a client call receives through `arg`, or NULL when the argument is + * not a foldable expression or holds nothing literal. A base that cannot be + * resolved is dropped and the literal path after it kept ("{}/foo" -> "/foo", + * "{}some/path" -> "/some/path"), exactly like #1249 / #2291: the path is + * partial, never presented with an invented base. */ +const char *cbm_url_fold_call_url(CBMExtractCtx *ctx, TSNode arg); + +/* The raw fold, including unresolved placeholders. Never changes separators + * or removes a leading unknown component; suitable for topics and generic calls. */ +const char *cbm_url_fold_call_raw(CBMExtractCtx *ctx, TSNode arg); + +/* The raw resolved value of a foldable, non-literal argument, or NULL when + * any leading part stayed unresolved. For slots that must hold an exact + * value (CBMCallArg.value). */ +const char *cbm_url_fold_exact(CBMExtractCtx *ctx, TSNode arg); + +#endif /* CBM_EXTRACT_URL_FOLD_H */ diff --git a/internal/cbm/result_compact.c b/internal/cbm/result_compact.c index b9e4d2be6..cb77ead58 100644 --- a/internal/cbm/result_compact.c +++ b/internal/cbm/result_compact.c @@ -294,6 +294,7 @@ static void cr_walk_call(cr_ctx_t *c, CBMCall *call) { for (int i = 0; i < argc && call->args; i++) { cr_str(c, &call->args[i].expr); cr_str(c, &call->args[i].value); + cr_str(c, &call->args[i].url_value); cr_str(c, &call->args[i].keyword); } } else if (c->phase == CR_COPY) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 8cdfb80e8..7144a8963 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2461,19 +2461,38 @@ static bool normalize_url_arg(const char *url, char *norm, int norm_sz) { return !is_junk_url(norm); } +static bool arg_is_topic(const CBMCallArg *arg) { + static const char *keywords[] = {"topic", "topic_id", "topic_name", "queue", "queue_name", + "queue_id", "subject", "channel", NULL}; + for (int i = 0; arg->keyword && keywords[i]; i++) { + if (strcmp(arg->keyword, keywords[i]) == 0) { + return true; + } + } + return false; +} + /* Detect API paths in call arguments and create HTTP_CALLS edges. */ -static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, - const CBMCall *call) { +static void detect_url_in_args(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, const CBMCall *call, + cbm_svc_kind_t svc) { + /* A resolved alias may be ASYNC even when its source spelling is unknown. + * Neither raw slash topics nor URL projections are HTTP evidence there. */ + if (svc == CBM_SVC_ASYNC || cbm_service_pattern_match(call->callee_name) == CBM_SVC_ASYNC) { + return; + } for (int ai = 0; ai < call->arg_count; ai++) { const CBMCallArg *ca = &call->args[ai]; + if (arg_is_topic(ca)) { + continue; + } /* A slash-prefixed raw expression is not a URL string. In JS/TS this * is notably a regex literal (`/value && ca->expr && ca->expr[0] == '/') { + if (!ca->value && !ca->url_value && ca->expr && ca->expr[0] == '/') { continue; } - const char *url = ca->value ? ca->value : ca->expr; + const char *url = ca->url_value ? ca->url_value : (ca->value ? ca->value : ca->expr); if (!url || (url[0] != '/' && url[0] != '`')) { continue; } @@ -2753,7 +2772,7 @@ static void emit_service_edge(cbm_gbuf_t *gbuf, const cbm_gbuf_node_t *source, * __route__ANY__ twin of the typed edge, which cross-repo matching then * bound to server handlers of any method. */ if (!http_edge_emitted) { - detect_url_in_args(gbuf, source, call); + detect_url_in_args(gbuf, source, call, svc); } } diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index f8f72916f..36ca30e7f 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -2054,6 +2054,7 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c str_call_enclosing += census_len(call->enclosing_func_qn); for (int a = 0; a < call->arg_count && a < CBM_MAX_CALL_ARGS; a++) { str_call_args += census_len(call->args[a].expr) + census_len(call->args[a].value) + + census_len(call->args[a].url_value) + census_len(call->args[a].keyword); } } diff --git a/tests/test_extraction.c b/tests/test_extraction.c index aa53b5c87..92580fa88 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -11,6 +11,7 @@ #include "preprocessor.h" /* cbm_export_macro_candidates (#1989) */ #include "../src/foundation/compat.h" /* cbm_clock_gettime (wide-flat scaling guard) */ #include "../src/foundation/compat_fs.h" +#include #include #include "macro_table.h" #include "result_spill.h" @@ -5769,6 +5770,439 @@ TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249) { PASS(); } +/* Issue #2291: the JS/TS twin of #1249. A BFF that builds its backend URL as + * `${process.env.BACKEND_URL}/wallet/topup` from a configurable base must + * index the literal path after the base, exactly as `BACKEND_URL + "/x"` + * already does. The flattened "{}/wallet/topup" was dropped because it + * neither starts with '/' nor carries a scheme, so no HTTP_CALLS edge formed + * and the call could never be matched to the backend's route. */ +TEST(extract_ts_template_base_url_issue2291) { + CBMFileResult *r = extract("const BACKEND_URL = process.env.BACKEND_URL;\n" + "export async function topup(body: string) {\n" + " return fetch(`${process.env.BACKEND_URL}/wallet/topup/zibal`, {\n" + " method: 'POST', body });\n" + "}\n" + "export async function tickets() {\n" + " return axios.get(`${BACKEND_URL}/tickets/mine`);\n" + "}\n" + "export async function items(id: string) {\n" + " return got(`${BACKEND_URL}/orders/${id}/items`);\n" + "}\n", + CBM_LANG_TYPESCRIPT, "t", "route.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + const CBMCall *f = find_call_by_callee(r, "fetch"); + ASSERT_NOT_NULL(f); + ASSERT_NOT_NULL(f->first_string_arg); + ASSERT_STR_EQ(f->first_string_arg, "/wallet/topup/zibal"); + + const CBMCall *g = find_call_by_callee(r, "axios.get"); + ASSERT_NOT_NULL(g); + ASSERT_NOT_NULL(g->first_string_arg); + ASSERT_STR_EQ(g->first_string_arg, "/tickets/mine"); + + const CBMCall *h = find_call_by_callee(r, "got"); + ASSERT_NOT_NULL(h); + ASSERT_NOT_NULL(h->first_string_arg); + /* `got` has no service-table entry: preserve the raw first argument and + * carry the recovered path as an explicit candidate for HTTP detection. */ + ASSERT_STR_EQ(h->first_string_arg, "{}/orders/{}/items"); + ASSERT_GTE(h->arg_count, 1); + ASSERT_NOT_NULL(h->args[0].url_value); + ASSERT_STR_EQ(h->args[0].url_value, "/orders/{}/items"); + + cbm_free_result(r); + PASS(); +} + +/* Same issue: with no literal path after the base (`${BASE}${path}`, + * `${BASE}/`) there is no route to recover, so none may be fabricated. */ +TEST(extract_ts_template_base_url_no_literal_path_issue2291) { + CBMFileResult *r = extract("export async function proxy(path: string) {\n" + " return fetch(`${process.env.BACKEND_URL}${path}`);\n" + "}\n" + "export async function root() {\n" + " return axios.get(`${process.env.BACKEND_URL}/`);\n" + "}\n" + "export function tail(tail: string) {\n" + " return axios.post(`${BASE}/${tail}`);\n" + "}\n", + CBM_LANG_TYPESCRIPT, "t", "proxy.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + const CBMCall *f = find_call_by_callee(r, "fetch"); + ASSERT_NOT_NULL(f); + ASSERT(f->first_string_arg == NULL || f->first_string_arg[0] != '/'); + + const CBMCall *g = find_call_by_callee(r, "axios.get"); + ASSERT_NOT_NULL(g); + ASSERT(g->first_string_arg == NULL || g->first_string_arg[0] != '/'); + + const CBMCall *h = find_call_by_callee(r, "axios.post"); + ASSERT_NOT_NULL(h); + ASSERT(h->first_string_arg == NULL || h->first_string_arg[0] != '/'); + ASSERT_GTE(h->arg_count, 1); + ASSERT_NULL(h->args[0].url_value); + + cbm_free_result(r); + PASS(); +} + +/* The call `callee` made inside function `func` (its enclosing QN ends in + * ".func"), so one file can hold several calls to the same client. */ +static const CBMCall *find_call_in_func(CBMFileResult *r, const char *callee, const char *func) { + size_t fl = strlen(func); + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + const char *q = c->enclosing_func_qn; + if (!c->callee_name || strcmp(c->callee_name, callee) != 0 || !q) { + continue; + } + size_t ql = strlen(q); + if (ql > fl && q[ql - fl - 1] == '.' && strcmp(q + ql - fl, func) == 0) { + return c; + } + } + return NULL; +} + +/* The URL a call hands to its client: first_string_arg, or "" when none. */ +static const char *url_in(CBMFileResult *r, const char *callee, const char *func) { + const CBMCall *c = find_call_in_func(r, callee, func); + return (c && c->first_string_arg) ? c->first_string_arg : ""; +} + +/* True when the URL is not presented as a path or an absolute URL. */ +static bool url_not_claimed(CBMFileResult *r, const char *callee, const char *func) { + const CBMCall *call = find_call_in_func(r, callee, func); + if (!call) { + return false; + } + const char *u = call->first_string_arg; + return !u || (u[0] != '/' && strstr(u, "://") == NULL); +} + +/* Issue #706: a Python caller that centralises its endpoint URLs in module + * constants -- `API_URL = API_BASE_URL + "some/path"` -- lost the URL, so no + * HTTP_CALLS edge formed and cross-repo-intelligence reported 0 links. The + * concatenation must be folded: resolved parts inline, and an unresolvable + * base (an env var) leaves the literal path after it. */ +TEST(extract_py_concat_const_url_issue706) { + CBMFileResult *r = extract("import os\n" + "import requests\n" + "API_BASE_URL = os.environ[\"API_BASE_URL\"]\n" + "HOST = \"http://provider:8000\"\n" + "PREFIX = \"/v2\"\n" + "API_URL = API_BASE_URL + \"some/path\"\n" + "URL = API_BASE_URL + \"/foo\"\n" + "ORDERS_URL = HOST + \"/orders\"\n" + "REPORTS_URL = PREFIX + \"/reports\"\n" + "ITEMS_URL = f\"{API_BASE_URL}/items\"\n" + "DAILY_URL = REPORTS_URL + \"/daily\"\n" + "def send(p):\n" + " requests.post(API_URL, json=p)\n" + "def send_foo(p):\n" + " requests.post(URL, json=p)\n" + "def orders():\n" + " return requests.get(ORDERS_URL)\n" + "def reports():\n" + " return requests.get(REPORTS_URL)\n" + "def items():\n" + " return requests.get(ITEMS_URL)\n" + "def daily():\n" + " return requests.get(DAILY_URL)\n" + "def direct(p):\n" + " return requests.post(API_BASE_URL + \"/direct\", json=p)\n" + "def inline():\n" + " return requests.get(PREFIX + \"/inline\")\n" + "def kw(p):\n" + " return requests.post(url=API_URL, json=p)\n", + CBM_LANG_PYTHON, "t", "client.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + ASSERT_STR_EQ(url_in(r, "requests.post", "send"), "/some/path"); + ASSERT_STR_EQ(url_in(r, "requests.post", "send_foo"), "/foo"); + ASSERT_STR_EQ(url_in(r, "requests.get", "orders"), "http://provider:8000/orders"); + ASSERT_STR_EQ(url_in(r, "requests.get", "reports"), "/v2/reports"); + ASSERT_STR_EQ(url_in(r, "requests.get", "items"), "/items"); + ASSERT_STR_EQ(url_in(r, "requests.get", "daily"), "/v2/reports/daily"); + ASSERT_STR_EQ(url_in(r, "requests.post", "direct"), "/direct"); + ASSERT_STR_EQ(url_in(r, "requests.get", "inline"), "/v2/inline"); + ASSERT_STR_EQ(url_in(r, "requests.post", "kw"), "/some/path"); + + /* A fully resolved fold is an exact argument value; a path left behind by + * an unresolvable base is not, so it never reaches the exact-URL slot. */ + const CBMCall *rep = find_call_in_func(r, "requests.get", "reports"); + ASSERT_NOT_NULL(rep); + ASSERT_GTE(rep->arg_count, 1); + ASSERT_NOT_NULL(rep->args[0].value); + ASSERT_STR_EQ(rep->args[0].value, "/v2/reports"); + const CBMCall *snd = find_call_in_func(r, "requests.post", "send"); + ASSERT_NOT_NULL(snd); + ASSERT_GTE(snd->arg_count, 1); + ASSERT(snd->args[0].value == NULL || snd->args[0].value[0] != '/'); + + cbm_free_result(r); + PASS(); +} + +/* Same issue, the controls: a URL with nothing literal to recover -- a local + * computed value, a bare env-derived base, a base plus "/" or plus another + * variable -- must not be turned into a path or an exact URL. */ +TEST(extract_py_url_fold_no_fabrication_issue706) { + CBMFileResult *r = extract("import os\n" + "import requests\n" + "API_BASE_URL = os.environ[\"API_BASE_URL\"]\n" + "EMPTY = API_BASE_URL + \"/\"\n" + "def compute():\n" + " return os.environ.get(\"X\")\n" + "def dyn():\n" + " u = compute()\n" + " return requests.get(u)\n" + "def bare():\n" + " return requests.get(API_BASE_URL)\n" + "def slash():\n" + " return requests.get(API_BASE_URL + \"/\")\n" + "def empty():\n" + " return requests.get(EMPTY)\n" + "def two(p):\n" + " return requests.get(API_BASE_URL + p)\n", + CBM_LANG_PYTHON, "t", "ctl.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + ASSERT_TRUE(url_not_claimed(r, "requests.get", "dyn")); + ASSERT_TRUE(url_not_claimed(r, "requests.get", "bare")); + ASSERT_TRUE(url_not_claimed(r, "requests.get", "slash")); + ASSERT_TRUE(url_not_claimed(r, "requests.get", "empty")); + ASSERT_TRUE(url_not_claimed(r, "requests.get", "two")); + + cbm_free_result(r); + PASS(); +} + +/* Issue #1147: a frontend that composes every endpoint from module constants + * -- a base constant, template literals over it, and an object-literal map of + * endpoints -- extracted none of its API calls. Each template / concatenation + * over a resolvable constant must fold to the full path, `OBJ.KEY` of an + * object-literal const must resolve, and a base that stays unresolvable + * (`${getApiBaseUrl()}`) leaves the path after it. A relative base ('api') + * joined to a path is a path under the client's base URL. */ +TEST(extract_ts_const_template_url_issue1147) { + CBMFileResult *r = extract( + "const BASE = '/api'\n" + "const REL = 'api'\n" + "const GAMES = '/games'\n" + "export const API_ENDPOINTS = {\n" + " LOGIN: `${BASE}/users/login`,\n" + " USER: `${REL}/users/me`,\n" + " GAME_LAUNCH_URL: `${REL}/games/launch`,\n" + " NESTED: { DEEP: BASE + '/deep' },\n" + "} as const\n" + "export const getApiBaseUrl = () => `${API_PROTOCOL}://${process.env.API_DOMAIN}/`\n" + "export async function launch() { return fetch(`${BASE}/games/launch`) }\n" + "export async function list() { return fetch(`${GAMES}/list`) }\n" + "export async function login() { return fetch(API_ENDPOINTS.LOGIN) }\n" + "export async function user() {\n" + " return fetch(`${getApiBaseUrl()}/${API_ENDPOINTS.USER}`, { method: 'GET' })\n" + "}\n" + "export async function game(p: unknown) {\n" + " return apiClient.post(API_ENDPOINTS.GAME_LAUNCH_URL, p)\n" + "}\n" + "export async function deep() { return fetch(API_ENDPOINTS.NESTED.DEEP) }\n" + "export async function me() { return axios.get(BASE + '/users/me') }\n", + CBM_LANG_TYPESCRIPT, "t", "client.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + ASSERT_STR_EQ(url_in(r, "fetch", "launch"), "/api/games/launch"); + ASSERT_STR_EQ(url_in(r, "fetch", "list"), "/games/list"); + ASSERT_STR_EQ(url_in(r, "fetch", "login"), "/api/users/login"); + ASSERT_STR_EQ(url_in(r, "fetch", "user"), "/api/users/me"); + ASSERT_STR_EQ(url_in(r, "apiClient.post", "game"), "api/games/launch"); + ASSERT_STR_EQ(url_in(r, "fetch", "deep"), "/api/deep"); + ASSERT_STR_EQ(url_in(r, "axios.get", "me"), "/api/users/me"); + + /* A wrapper client has raw string identity plus an explicit URL candidate. + * Only HTTP detection may select the normalized relative path. */ + const CBMCall *g = find_call_in_func(r, "apiClient.post", "game"); + ASSERT_NOT_NULL(g); + ASSERT_GTE(g->arg_count, 1); + ASSERT_NOT_NULL(g->args[0].value); + ASSERT_STR_EQ(g->args[0].value, "api/games/launch"); + ASSERT_NOT_NULL(g->args[0].url_value); + ASSERT_STR_EQ(g->args[0].url_value, "/api/games/launch"); + + cbm_free_result(r); + PASS(); +} + +/* Same issue, the controls: a parameter, a bare substitution, a member of an + * unknown object, an env-only base, and an unresolvable base followed only by + * an unresolvable tail carry no literal path, so none may be fabricated (the + * last one used to come out as "/{}"). */ +TEST(extract_ts_url_fold_no_fabrication_issue1147) { + CBMFileResult *r = + extract("const HEALTH_BASE = process.env.HEALTH_BASE\n" + "export async function dynamic(path: string) { return fetch(path) }\n" + "export async function dynTpl(x: string) { return fetch(`${x}`) }\n" + "export async function missing() { return fetch(API_ENDPOINTS.MISSING) }\n" + "export async function tail(t: string) { return fetch(`${getBase()}/${t}`) }\n" + "export async function envOnly() { return fetch(HEALTH_BASE) }\n", + CBM_LANG_TYPESCRIPT, "t", "ctl.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + + ASSERT_TRUE(url_not_claimed(r, "fetch", "dynamic")); + ASSERT_TRUE(url_not_claimed(r, "fetch", "dynTpl")); + ASSERT_TRUE(url_not_claimed(r, "fetch", "missing")); + ASSERT_TRUE(url_not_claimed(r, "fetch", "tail")); + ASSERT_TRUE(url_not_claimed(r, "fetch", "envOnly")); + + cbm_free_result(r); + PASS(); +} + +/* Shared folding must preserve topic/string identity before service resolution. */ +TEST(extract_ts_folded_topics_keep_raw_identity) { + CBMFileResult *r = extract( + "const TENANT = 'tenant';\n" + "const TOPIC = `${TENANT}/events`;\n" + "const TOPICS = { EVENTS: `${TENANT}/events` };\n" + "const PREFIX = 'tenant/';\n" + "const DOUBLE = `${PREFIX}/events`;\n" + "const UNKNOWN = `${tenant}/events`;\n" + "const UNKNOWN_TOPICS = { EVENTS: `${tenant}/events` };\n" + "const PLAIN = 'tenant/events';\n" + "export function direct() { mqtt.publish(`${TENANT}/events`, 'payload'); }\n" + "export function constant() { mqtt.publish(TOPIC, 'payload'); }\n" + "export function member() { mqtt.publish(TOPICS.EVENTS, 'payload'); }\n" + "export function literal() { mqtt.publish('tenant/events', 'payload'); }\n" + "export function plain() { mqtt.publish(PLAIN, 'payload'); }\n" + "export function doubled() { mqtt.publish(`${PREFIX}/events`, 'payload'); }\n" + "export function doubledConstant() { mqtt.publish(DOUBLE, 'payload'); }\n" + "export function unknown() { mqtt.publish(`${tenant}/events`, 'payload'); }\n" + "export function unknownConstant() { mqtt.publish(UNKNOWN, 'payload'); }\n" + "export function unknownMember() { mqtt.publish(UNKNOWN_TOPICS.EVENTS, 'payload'); }\n" + "export function alias() { bus.publish(TOPICS.EVENTS, 'payload'); }\n", + CBM_LANG_TYPESCRIPT, "t", "topics.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const char *functions[] = {"direct", "constant", "member", "literal", + "plain", "doubled", "doubledConstant"}; + for (size_t i = 0; i < sizeof(functions) / sizeof(functions[0]); i++) { + const CBMCall *c = find_call_in_func(r, "mqtt.publish", functions[i]); + const char *expected = i < 5 ? "tenant/events" : "tenant//events"; + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, expected); + ASSERT_GTE(c->arg_count, 1); + ASSERT_NOT_NULL(c->args[0].value); + ASSERT_STR_EQ(c->args[0].value, expected); + } + const char *unknowns[] = {"unknown", "unknownConstant", "unknownMember"}; + for (size_t i = 0; i < sizeof(unknowns) / sizeof(unknowns[0]); i++) { + const CBMCall *c = find_call_in_func(r, "mqtt.publish", unknowns[i]); + ASSERT_NOT_NULL(c); + ASSERT_NOT_NULL(c->first_string_arg); + ASSERT_STR_EQ(c->first_string_arg, "{}/events"); + ASSERT_GTE(c->arg_count, 1); + ASSERT(c->args[0].value == NULL || c->args[0].value[0] != '/'); + } + const CBMCall *alias = find_call_in_func(r, "bus.publish", "alias"); + ASSERT_NOT_NULL(alias); + ASSERT_STR_EQ(alias->first_string_arg, "tenant/events"); + ASSERT_GTE(alias->arg_count, 1); + ASSERT_STR_EQ(alias->args[0].value, "tenant/events"); + /* Classification has not happened yet: raw and URL views stay distinct. */ + ASSERT_NOT_NULL(alias->args[0].url_value); + ASSERT_STR_EQ(alias->args[0].url_value, "/tenant/events"); + cbm_free_result(r); + PASS(); +} + +TEST(extract_py_folded_topic_keywords_keep_raw_identity) { + CBMFileResult *r = extract("TENANT = 'tenant'\n" + "TOPIC = f'{TENANT}/events'\n" + "PREFIX = 'tenant/'\n" + "DOUBLE = PREFIX + '/events'\n" + "UNKNOWN = f'{tenant}/events'\n" + "def direct():\n mqtt.publish(f'{TENANT}/events', 'payload')\n" + "def topic():\n mqtt.publish(topic=TOPIC, payload='x')\n" + "def queue():\n mqtt.publish(queue=TOPIC, payload='x')\n" + "def channel():\n mqtt.publish(channel=TOPIC, payload='x')\n" + "def subject():\n mqtt.publish(subject=TOPIC, payload='x')\n" + "def unknown():\n mqtt.publish(topic=UNKNOWN, payload='x')\n" + "def doubled():\n mqtt.publish(topic=DOUBLE, payload='x')\n" + "def unknown_callee():\n send(topic=TOPIC, payload='x')\n", + CBM_LANG_PYTHON, "t", "topics.py"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const char *functions[] = {"direct", "topic", "queue", "channel", "subject"}; + for (size_t i = 0; i < sizeof(functions) / sizeof(functions[0]); i++) { + const CBMCall *c = find_call_in_func(r, "mqtt.publish", functions[i]); + ASSERT_NOT_NULL(c); + ASSERT_STR_EQ(c->first_string_arg, "tenant/events"); + ASSERT_GTE(c->arg_count, 1); + ASSERT_STR_EQ(c->args[0].value, "tenant/events"); + if (i > 0) { + ASSERT_NULL(c->args[0].url_value); + } + } + ASSERT_STR_EQ(url_in(r, "mqtt.publish", "unknown"), "{}/events"); + ASSERT_STR_EQ(url_in(r, "mqtt.publish", "doubled"), "tenant//events"); + const CBMCall *c = find_call_in_func(r, "send", "unknown_callee"); + ASSERT_NOT_NULL(c); + ASSERT_STR_EQ(c->first_string_arg, "tenant/events"); + ASSERT_GTE(c->arg_count, 1); + ASSERT_STR_EQ(c->args[0].value, "tenant/events"); + ASSERT_NULL(c->args[0].url_value); + cbm_free_result(r); + PASS(); +} + +/* #2235's URL slot carries the projection separately; unknown callees stay raw. */ +TEST(extract_ts_folded_config_url_and_exclusions) { + CBMFileResult *r = + extract("const REL = 'api';\n" + "const API_ENDPOINTS = { USER: `${REL}/users/me` };\n" + "export function config() { return http({url: API_ENDPOINTS.USER}); }\n" + "export function format() { return axios.getUri({url: API_ENDPOINTS.USER}); }\n" + "export function register() { app.route({url: API_ENDPOINTS.USER, handler}); }\n" + "export function relative() { return fetch(`${REL}/users/me`); }\n", + CBM_LANG_TYPESCRIPT, "t", "config.ts"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const CBMCall *c = find_call_in_func(r, "http", "config"); + ASSERT_NOT_NULL(c); + ASSERT_STR_EQ(c->first_string_arg, "api/users/me"); + ASSERT_GTE(c->arg_count, 1); + ASSERT_STR_EQ(c->args[0].value, "api/users/me"); + ASSERT_NOT_NULL(c->args[0].url_value); + ASSERT_STR_EQ(c->args[0].url_value, "/api/users/me"); + const char *callees[] = {"axios.getUri", "app.route"}; + const char *functions[] = {"format", "register"}; + for (size_t i = 0; i < sizeof(callees) / sizeof(callees[0]); i++) { + c = find_call_in_func(r, callees[i], functions[i]); + ASSERT_NOT_NULL(c); + ASSERT_NULL(c->first_string_arg); + ASSERT_GTE(c->arg_count, 1); + ASSERT_NULL(c->args[0].value); + ASSERT_NULL(c->args[0].url_value); + } + c = find_call_in_func(r, "fetch", "relative"); + ASSERT_NOT_NULL(c); + ASSERT_STR_EQ(c->first_string_arg, "/api/users/me"); + ASSERT_GTE(c->arg_count, 1); + ASSERT_STR_EQ(c->args[0].value, "api/users/me"); + ASSERT_STR_EQ(c->args[0].url_value, "/api/users/me"); + cbm_free_result(r); + PASS(); +} + /* Reproduce-first: Java module QN must derive from the CONTAINING DIRECTORY, not * the filename stem, so a top-level class `Outer` in `Outer.java` is `t.Outer`, * NOT the doubled `t.Outer.Outer`. The nested method def QN must also equal the @@ -10234,6 +10668,10 @@ TEST(non_config_language_module_has_no_promoted_description_issue519) { /* ── Result compaction (cbm_result_compact) ────────────────────────────── */ static const char *COMPACT_PY_SRC = "import os\n" + "BASE = 'api'\n" + "URL = f'{BASE}/users/me'\n" + "def url_candidate():\n" + " return client(URL)\n" "from typing import List\n" "\n" "@app.route(\"/items\")\n" @@ -10301,6 +10739,16 @@ TEST(extract_compact_keeps_every_field_and_shrinks_the_arena) { size_t cap_before = arena_capacity(&r->arena); cbm_result_compact(r); + const CBMCall *candidate = find_call_in_func(r, "client", "url_candidate"); + ASSERT_NOT_NULL(candidate); + ASSERT_GTE(candidate->arg_count, 1); + ASSERT_STR_EQ(candidate->args[0].value, "api/users/me"); + ASSERT_STR_EQ(candidate->args[0].url_value, "/api/users/me"); + uintptr_t candidate_addr = (uintptr_t)candidate->args[0].url_value; + uintptr_t arena_addr = (uintptr_t)r->arena.blocks[0]; + ASSERT(candidate_addr >= arena_addr); + ASSERT(candidate_addr - arena_addr < r->arena.used); + /* One exact block: capacity == bytes used, no dead headroom. */ ASSERT_EQ(r->arena.nblocks, 1); ASSERT_EQ(arena_capacity(&r->arena), cbm_arena_total(&r->arena)); @@ -10362,6 +10810,7 @@ TEST(extract_compact_keeps_every_field_and_shrinks_the_arena) { for (int k = 0; k < a->arg_count; k++) { ASSERT(cmp_str_eq(a->args[k].expr, b->args[k].expr)); ASSERT(cmp_str_eq(a->args[k].value, b->args[k].value)); + ASSERT(cmp_str_eq(a->args[k].url_value, b->args[k].url_value)); ASSERT(cmp_str_eq(a->args[k].keyword, b->args[k].keyword)); ASSERT_EQ(a->args[k].index, b->args[k].index); } @@ -10526,6 +10975,16 @@ TEST(extract_spill_round_trip_keeps_every_field) { ASSERT(cmp_str_eq(back->module_qn, ref->module_qn)); ASSERT(cmp_list_eq(back->exports, ref->exports)); + const CBMCall *candidate = find_call_in_func(back, "client", "url_candidate"); + ASSERT_NOT_NULL(candidate); + ASSERT_GTE(candidate->arg_count, 1); + ASSERT_STR_EQ(candidate->args[0].value, "api/users/me"); + ASSERT_STR_EQ(candidate->args[0].url_value, "/api/users/me"); + uintptr_t candidate_addr = (uintptr_t)candidate->args[0].url_value; + uintptr_t arena_addr = (uintptr_t)back->arena.blocks[0]; + ASSERT(candidate_addr >= arena_addr); + ASSERT(candidate_addr - arena_addr < back->arena.used); + /* Loading twice yields two independent copies. */ CBMFileResult *again = cbm_result_spill_load(sp, 1); ASSERT_NOT_NULL(again); @@ -11365,6 +11824,15 @@ SUITE(extraction) { 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_template_base_url_issue2291); + RUN_TEST(extract_ts_template_base_url_no_literal_path_issue2291); + RUN_TEST(extract_py_concat_const_url_issue706); + RUN_TEST(extract_py_url_fold_no_fabrication_issue706); + RUN_TEST(extract_ts_const_template_url_issue1147); + RUN_TEST(extract_ts_url_fold_no_fabrication_issue1147); + RUN_TEST(extract_ts_folded_topics_keep_raw_identity); + RUN_TEST(extract_py_folded_topic_keywords_keep_raw_identity); + RUN_TEST(extract_ts_folded_config_url_and_exclusions); RUN_TEST(extract_ts_url_builder_issue1009); RUN_TEST(extract_ts_await_generic_call_issue2210); RUN_TEST(extract_ts_route_handler_after_named_middleware); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index f95adce16..9af0b2024 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -10115,6 +10115,251 @@ TEST(pipeline_typed_http_call_has_no_arg_url_twin_sequential) { return typed_http_no_url_twin_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 + * kept) and a TS object-literal endpoint map over a base constant. The + * unresolvable control (`requests.get(u)` on a computed value) adds none. */ +TEST(pipeline_url_constant_folding_http_calls_issue706_1147) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_urlfold_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "svc/client.py", + "import os\n" + "import requests\n" + "API_BASE_URL = os.environ[\"API_BASE_URL\"]\n" + "API_URL = API_BASE_URL + \"/orders/create\"\n" + "def send(payload):\n" + " return requests.post(API_URL, json=payload)\n" + "def dyn():\n" + " u = os.environ.get(\"X\")\n" + " return requests.get(u)\n"); + write_temp_file(tmp, "web/api.ts", + "const BASE = '/api'\n" + "export const API_ENDPOINTS = {\n" + " USER: `${BASE}/users/me`,\n" + "}\n" + "export function loadUser(): unknown {\n" + " return fetch(API_ENDPOINTS.USER);\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/urlfold.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); + + /* Asserted per edge, not by count: a client call may also carry a second, + * arg_url-sourced HTTP_CALLS edge for the same path. */ + cbm_edge_t *edges = NULL; + int edge_count = 0; + ASSERT_EQ(cbm_store_find_edges_by_type(s, project, "HTTP_CALLS", &edges, &edge_count), + CBM_STORE_OK); + int py_edges = 0; + int ts_edges = 0; + int control_edges = 0; + for (int i = 0; i < edge_count; i++) { + const char *props = edges[i].properties_json ? edges[i].properties_json : ""; + cbm_node_t src = {0}; + cbm_store_find_node_by_id(s, edges[i].source_id, &src); + if (src.name && strcmp(src.name, "dyn") == 0) { + control_edges++; + } else if (src.name && strcmp(src.name, "send") == 0 && + strstr(props, "\"url_path\":\"/orders/create\"")) { + py_edges++; + } else if (src.name && strcmp(src.name, "loadUser") == 0 && + strstr(props, "\"url_path\":\"/api/users/me\"")) { + ts_edges++; + } + cbm_node_free_fields(&src); + } + cbm_store_free_edges(edges, edge_count); + ASSERT_GTE(py_edges, 1); + ASSERT_GTE(ts_edges, 1); + ASSERT_EQ(control_edges, 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Run the same identities through both service resolvers. An import alias is + * deliberately absent from the callee-name pattern table until resolution. */ +TEST(pipeline_folded_topics_preserve_identity_sequential_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_topicfold_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file(tmp, "src/topics.ts", + "const TENANT = 'tenant';\n" + "const TOPIC = `${TENANT}/events`;\n" + "const TOPICS = { EVENTS: `${TENANT}/events` };\n" + "const PREFIX = 'tenant/';\n" + "const DOUBLE = `${PREFIX}/events`;\n" + "const UNKNOWN = `${tenant}/events`;\n" + "export function literal() { mqtt.publish('tenant/events', 'payload'); }\n" + "export function direct() { mqtt.publish(`${TENANT}/events`, 'payload'); }\n" + "export function constant() { mqtt.publish(TOPIC, 'payload'); }\n" + "export function member() { mqtt.publish(TOPICS.EVENTS, 'payload'); }\n" + "export function doubled() { mqtt.publish(DOUBLE, 'payload'); }\n" + "export function unknown() { mqtt.publish(UNKNOWN, 'payload'); }\n"); + write_temp_file(tmp, "src/aliases.py", + "from paho.mqtt import publish as send\n" + "TENANT = 'tenant'\n" + "TOPIC = f'{TENANT}/events'\n" + "ABS = f'/{TENANT}/events'\n" + "def alias():\n send(TOPIC, 'payload')\n" + "def keyword():\n send(topic=TOPIC, payload='x')\n" + "def slash_topic():\n send(ABS, 'payload')\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/padding%d.ts", i); + snprintf(body, sizeof(body), "export function padding%d() { return %d; }\n", i, i); + write_temp_file(tmp, name, body); + } + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + int run_rc[2] = {-1, -1}; + bool opened[2] = {false, false}; + int matches[2][9] = {{0}}; + int http_edges[2] = {-1, -1}; + int invented[2] = {-1, -1}; + const char *functions[] = {"literal", "direct", "constant", "member", "doubled", + "unknown", "alias", "keyword", "slash_topic"}; + const char *topics[] = {"tenant/events", "tenant/events", "tenant/events", + "tenant/events", "tenant//events", "{}/events", + "tenant/events", "tenant/events", "/tenant/events"}; + for (int mode = 0; mode < 2; mode++) { + if (mode == 0) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", "1", 1); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/topics_%d.db", tmp, mode); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + run_rc[mode] = p ? cbm_pipeline_run(p) : -1; + const char *project = p ? cbm_pipeline_project_name(p) : NULL; + cbm_store_t *s = cbm_store_open_path(db_path); + opened[mode] = s != NULL; + if (s && project) { + for (size_t i = 0; i < sizeof(functions) / sizeof(functions[0]); i++) { + matches[mode][i] = + named_edge_count(s, project, "ASYNC_CALLS", functions[i], topics[i]); + } + http_edges[mode] = cbm_store_count_edges_by_type(s, project, "HTTP_CALLS"); + invented[mode] = count_nodes_named(s, project, "/events"); + /* Only slash_topic really contains a leading slash. */ + for (size_t i = 0; i < 8; i++) { + invented[mode] += + named_edge_count(s, project, "ASYNC_CALLS", functions[i], "/tenant/events"); + } + } + if (s) { + cbm_store_close(s); + } + cbm_pipeline_free(p); + } + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + for (int mode = 0; mode < 2; mode++) { + ASSERT_EQ(run_rc[mode], 0); + ASSERT_TRUE(opened[mode]); + for (size_t i = 0; i < sizeof(functions) / sizeof(functions[0]); i++) { + ASSERT_GTE(matches[mode][i], 1); + } + ASSERT_EQ(http_edges[mode], 0); + ASSERT_EQ(invented[mode], 0); + } + PASS(); +} + +/* Relative endpoint maps still supply the parallel wrapper-client heuristic + * through their URL candidate, including the request-config slot from #2235. */ +TEST(pipeline_relative_folded_wrapper_url_candidates) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_relativefold_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmp)); + write_temp_file( + tmp, "src/wrapper.ts", + "const REL = 'api';\n" + "const API_ENDPOINTS = { GAME_LAUNCH_URL: `${REL}/games/launch` };\n" + "function client(url: string) { return url; }\n" + "function http(config: {url: string}) { return config; }\n" + "export function launch() { return client(API_ENDPOINTS.GAME_LAUNCH_URL); }\n" + "export function config() { return http({url: API_ENDPOINTS.GAME_LAUNCH_URL}); }\n"); + for (int i = 0; i < 52; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/padding%d.ts", i); + snprintf(body, sizeof(body), "export function padding%d() { return %d; }\n", i, i); + write_temp_file(tmp, name, body); + } + const char *old_single = getenv("CBM_INDEX_SINGLE_THREAD"); + char *saved_single = old_single ? strdup(old_single) : NULL; + const char *old_workers = getenv("CBM_WORKERS"); + char *saved_workers = old_workers ? strdup(old_workers) : NULL; + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + cbm_setenv("CBM_WORKERS", "4", 1); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/wrappers.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + int run_rc = p ? cbm_pipeline_run(p) : -1; + const char *project = p ? cbm_pipeline_project_name(p) : NULL; + cbm_store_t *s = cbm_store_open_path(db_path); + bool opened = s != NULL; + int launch_edges = 0; + int config_edges = 0; + if (s && project) { + launch_edges = named_edge_count(s, project, "HTTP_CALLS", "launch", "/api/games/launch"); + config_edges = named_edge_count(s, project, "HTTP_CALLS", "config", "/api/games/launch"); + } + if (s) { + cbm_store_close(s); + } + cbm_pipeline_free(p); + if (saved_single) { + cbm_setenv("CBM_INDEX_SINGLE_THREAD", saved_single, 1); + free(saved_single); + } else { + cbm_unsetenv("CBM_INDEX_SINGLE_THREAD"); + } + if (saved_workers) { + cbm_setenv("CBM_WORKERS", saved_workers, 1); + free(saved_workers); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + ASSERT_EQ(run_rc, 0); + ASSERT_TRUE(opened); + ASSERT_GTE(launch_edges, 1); + ASSERT_GTE(config_edges, 1); + PASS(); +} + /* ── Git history pass tests ─────────────────────────────────────── */ TEST(githistory_is_trackable) { @@ -21224,6 +21469,9 @@ 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_url_constant_folding_http_calls_issue706_1147); + RUN_TEST(pipeline_folded_topics_preserve_identity_sequential_parallel); + RUN_TEST(pipeline_relative_folded_wrapper_url_candidates); /* Git history pass */ RUN_TEST(githistory_is_trackable); RUN_TEST(githistory_compute_coupling);