mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-26 06:43:15 +08:00
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:
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user