fix(extraction): fold local URLs without changing topic identities

Refs #2291, #706, #1147. Preserve raw argument values and carry URL projections separately through compaction and spill.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-10-13 00:31:19 +02:00
parent 87a815ae1e
commit 77bc4927f9
11 changed files with 1362 additions and 29 deletions
+1
View File
@@ -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 \
+3 -1
View File
@@ -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;
+109 -21
View File
@@ -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;
}
+11 -2
View File
@@ -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 <stdlib.h>
@@ -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;
+451
View File
@@ -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 <ctype.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <string.h>
/* 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;
}
+45
View File
@@ -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 */
+1
View File
@@ -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) {
+24 -5
View File
@@ -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 (`/<table/i`); genuine string literals
* and propagated constants are carried in `value`, while template
* literals keep their leading backtick in `expr`. */
if (!ca->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);
}
}
+1
View File
@@ -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);
}
}
+468
View File
@@ -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 <stdint.h>
#include <time.h>
#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);
+248
View File
@@ -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);