Merge pull request #1010 from CharlesQueiroz/fix/url-builder-routes

fix: resolve URL-builder helper calls to Route/HTTP_CALLS
This commit is contained in:
Martin Vogel authored and GitHub committed 2026-08-26 22:45:07 +02:00
commit 909051ccee
4 files changed
+437 -2

No files matched your search

+1
View File
@@ -551,6 +551,7 @@ typedef struct {
typedef struct {
const char *names[CBM_MAX_STRING_CONSTANTS];
const char *values[CBM_MAX_STRING_CONSTANTS];
bool is_url_builder[CBM_MAX_STRING_CONSTANTS];
int count;
} CBMStringConstantMap;
+44 -2
View File
@@ -30,14 +30,29 @@ enum { MIN_PRINTABLE = 0x20 };
/* Handler arg scan start index (skip first positional). */
enum { HANDLER_START_IDX = 1 };
/* Look up a module-level string constant by name. */
/* Look up a module-level string constant by name. URL-builder entries share the
* map but are not constants: a bare `thingPath` reference is the function, not
* the URL it would build (issue #1009). */
static const char *lookup_string_constant(const CBMExtractCtx *ctx, const char *name) {
if (!name || !name[0]) {
return NULL;
}
const CBMStringConstantMap *map = &ctx->string_constants;
for (int i = 0; i < map->count; i++) {
if (strcmp(map->names[i], name) == 0) {
if (!map->is_url_builder[i] && strcmp(map->names[i], name) == 0) {
return map->values[i];
}
}
return NULL;
}
static const char *lookup_url_builder(const CBMExtractCtx *ctx, const char *name) {
if (!name || !name[0]) {
return NULL;
}
const CBMStringConstantMap *map = &ctx->string_constants;
for (int i = 0; i < map->count; i++) {
if (map->is_url_builder[i] && strcmp(map->names[i], name) == 0) {
return map->values[i];
}
}
@@ -1855,8 +1870,22 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) {
ca->index = positional_idx++;
if (is_string_like(ak) && ca->expr) {
ca->value = strip_quotes(ctx->arena, ca->expr);
} else if (strcmp(ak, "template_string") == 0) {
/* Flattened {} form so downstream url-arg detection joins the
* canonical server route shape (issue #1006/#1009). */
ca->value = cbm_template_string_text(ctx->arena, arg_node, ctx->source);
} else if (strcmp(ak, "identifier") == 0 && ca->expr) {
ca->value = lookup_string_constant(ctx, ca->expr);
} else if (strcmp(ak, "call_expression") == 0) {
/* URL-builder helper call (issue #1009): resolve
* client(buildPath(id)) through the per-file builder map. */
TSNode fn = ts_node_child_by_field_name(arg_node, TS_FIELD("function"));
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) {
char *fname = cbm_node_text(ctx->arena, fn, ctx->source);
if (fname) {
ca->value = lookup_url_builder(ctx, fname);
}
}
}
call->arg_count++;
}
@@ -2085,6 +2114,19 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) {
}
}
/* URL-builder helper call (issue #1009): `client(buildPath(id))` — the
* builder's returned URL was recorded in the per-file constant map. */
if (strcmp(ak, "call_expression") == 0) {
TSNode fn = ts_node_child_by_field_name(arg, TS_FIELD("function"));
if (!ts_node_is_null(fn) && strcmp(ts_node_type(fn), "identifier") == 0) {
char *fname = cbm_node_text(ctx->arena, fn, ctx->source);
const char *val = fname ? lookup_url_builder(ctx, fname) : NULL;
if (val) {
return val;
}
}
}
if (ai < MAX_POSITIONAL_SCAN) {
const char *val = extract_positional_url(ctx, arg, ak);
if (val) {
+222
View File
@@ -1099,6 +1099,227 @@ static void handle_string_refs(CBMExtractCtx *ctx, TSNode node, const WalkState
cbm_stringref_push(&ctx->result->string_refs, ctx->arena, ref);
}
// --- URL-builder helpers (issue #1009) ---
/* Map-aware template flatten for builder bodies: a ${...} substitution that is
* a bare identifier or a call to an already-recorded name (const or an earlier
* builder in the same file) inlines that value; anything else becomes "{}".
* The query string is not part of a route's identity, so the result is
* truncated at the first '?'. Handles the composed-builder shape
* `return \`${basePath(id)}?${params}\``. */
static const char *builder_template_text(CBMExtractCtx *ctx, TSNode node) {
enum { BLD_BUF = 512 };
char buf[BLD_BUF];
size_t pos = 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);
const char *piece = NULL;
if (strcmp(k, "string_fragment") == 0) {
piece = cbm_node_text(ctx->arena, c, ctx->source);
} else if (strcmp(k, "template_substitution") == 0) {
piece = "{}";
if (ts_node_named_child_count(c) > 0) {
TSNode expr = ts_node_named_child(c, 0);
/* `${basePath(id)}` inlines a builder, `${BASE}` a const. A bare
* name never resolves to a builder: that reads the function. */
bool want_builder = strcmp(ts_node_type(expr), "call_expression") == 0;
TSNode name_node =
want_builder ? ts_node_child_by_field_name(expr, TS_FIELD("function")) : expr;
if (!ts_node_is_null(name_node) &&
strcmp(ts_node_type(name_node), "identifier") == 0) {
char *nm = cbm_node_text(ctx->arena, name_node, ctx->source);
if (nm) {
const CBMStringConstantMap *map = &ctx->string_constants;
for (int mi = 0; mi < map->count; mi++) {
if (map->values[mi] && map->is_url_builder[mi] == want_builder &&
strcmp(map->names[mi], nm) == 0) {
piece = map->values[mi];
break;
}
}
}
}
}
} else {
continue;
}
if (!piece) {
continue;
}
size_t pl = strlen(piece);
if (pos + pl >= BLD_BUF) {
return NULL;
}
memcpy(buf + pos, piece, pl);
pos += pl;
}
/* Route identity excludes the query string. */
for (size_t qi = 0; qi < pos; qi++) {
if (buf[qi] == '?') {
pos = qi;
break;
}
}
if (pos == 0) {
return NULL;
}
return cbm_arena_strndup(ctx->arena, buf, pos);
}
/* Flatten a string-ish node (plain string or template literal) to text. */
static const char *url_builder_literal_text(CBMExtractCtx *ctx, TSNode value_node) {
const char *kind = ts_node_type(value_node);
if (strcmp(kind, "template_string") == 0) {
return builder_template_text(ctx, value_node);
}
if (!is_string_node(kind)) {
return NULL;
}
char *text = cbm_node_text(ctx->arena, value_node, ctx->source);
if (!text || !text[0]) {
return NULL;
}
int len = (int)strlen(text);
if (len >= CBM_QUOTE_PAIR && (text[0] == '"' || text[0] == '\'')) {
text = cbm_arena_strndup(ctx->arena, text + SKIP_ONE, (size_t)(len - PAIR_LEN));
}
return text;
}
/* A route-shaped URL literal: an absolute path that classifies as a URL. */
static const char *builder_route_url(CBMExtractCtx *ctx, TSNode value_node) {
const char *url = url_builder_literal_text(ctx, value_node);
if (!url || url[0] != '/' || cbm_classify_string(url, (int)strlen(url)) != CBM_STRREF_URL) {
return NULL;
}
return url;
}
static void record_url_builder(CBMExtractCtx *ctx, const char *name, const char *url) {
if (!name || !name[0] || !url) {
return;
}
CBMStringConstantMap *map = &ctx->string_constants;
for (int i = 0; i < map->count; i++) {
if (strcmp(map->names[i], name) == 0) {
if (map->is_url_builder[i] && map->values[i] && strcmp(map->values[i], url) != 0) {
map->values[i] = NULL; /* ambiguous builder */
}
return;
}
}
if (map->count < CBM_MAX_STRING_CONSTANTS) {
map->names[map->count] = (char *)name;
map->values[map->count] = (char *)url;
map->is_url_builder[map->count] = true;
map->count++;
}
}
/* Every `return` this function owns yields a route-shaped URL literal. A body
* that also returns a computed path (`return computePath(kind)`) would attribute
* its one literal to call sites that never produce it, so a mixed builder is
* declined rather than guessed. Returns inside a nested function belong to that
* function, not to this one. */
static bool builder_returns_only_urls(CBMExtractCtx *ctx, TSNode func) {
TSNode body = ts_node_child_by_field_name(func, TS_FIELD("body"));
if (ts_node_is_null(body)) {
return false;
}
bool only_urls = true;
TSTreeCursor cursor = ts_tree_cursor_new(body);
for (;;) {
TSNode node = ts_tree_cursor_current_node(&cursor);
if (strcmp(ts_node_type(node), "return_statement") == 0 &&
ts_node_eq(cbm_find_enclosing_func(node, ctx->language), func)) {
if (ts_node_named_child_count(node) == 0 ||
!builder_route_url(ctx, ts_node_named_child(node, 0))) {
only_urls = false;
break;
}
}
if (ts_tree_cursor_goto_first_child(&cursor)) {
continue;
}
if (ts_tree_cursor_goto_next_sibling(&cursor)) {
continue;
}
bool found = false;
while (ts_tree_cursor_goto_parent(&cursor)) {
if (ts_tree_cursor_goto_next_sibling(&cursor)) {
found = true;
break;
}
}
if (!found) {
break;
}
}
ts_tree_cursor_delete(&cursor);
return only_urls;
}
/* URL-builder helper pattern (issue #1009): a small function whose return value
* is a URL-shaped literal, consumed as `client(buildPath(id))`. The literal
* never appears as a call argument, so first_string_arg resolution cannot see
* it; record `functionName -> url` in the per-file constant map and let the
* call-site call_expression branch resolve it. Covers `return`-statement bodies
* and arrow-function expression bodies.
*
* JS/TS only. The predicate accepts any absolute pathname, and `return` plus a
* string literal is also the shape of every C or Go helper handing back a
* filesystem path (`/etc/...`, `/proc/self/...`); recording those would mint a
* Route node and an HTTP_CALLS edge in a language that speaks no HTTP. */
static void handle_url_builders(CBMExtractCtx *ctx, TSNode node, const WalkState *state) {
if (ctx->language != CBM_LANG_JAVASCRIPT && ctx->language != CBM_LANG_TYPESCRIPT &&
ctx->language != CBM_LANG_TSX) {
return;
}
const char *kind = ts_node_type(node);
if (strcmp(kind, "arrow_function") == 0) {
TSNode body = ts_node_child_by_field_name(node, TS_FIELD("body"));
if (ts_node_is_null(body) || strcmp(ts_node_type(body), "statement_block") == 0) {
return;
}
const char *url = builder_route_url(ctx, body);
if (!url) {
return;
}
TSNode parent = ts_node_parent(node);
if (!ts_node_is_null(parent) && strcmp(ts_node_type(parent), "variable_declarator") == 0) {
TSNode name_node = ts_node_child_by_field_name(parent, TS_FIELD("name"));
if (!ts_node_is_null(name_node)) {
record_url_builder(ctx, cbm_node_text(ctx->arena, name_node, ctx->source), url);
}
}
return;
}
if (strcmp(kind, "return_statement") != 0) {
return;
}
if (!state->enclosing_func_qn || state->enclosing_func_qn == ctx->module_qn) {
return;
}
if (ts_node_named_child_count(node) == 0) {
return;
}
const char *url = builder_route_url(ctx, ts_node_named_child(node, 0));
if (!url) {
return;
}
TSNode func = cbm_find_enclosing_func(node, ctx->language);
if (ts_node_is_null(func) || !builder_returns_only_urls(ctx, func)) {
return;
}
const char *name = strrchr(state->enclosing_func_qn, '.');
name = name ? name + 1 : state->enclosing_func_qn;
record_url_builder(ctx, name, url);
}
// --- YAML nested field extraction (D2) ---
/* Recursively walk YAML block_mapping_pair nodes, building dotted key paths.
@@ -2160,6 +2381,7 @@ void cbm_extract_unified(CBMExtractCtx *ctx) {
handle_string_constants(ctx, node, &state);
handle_objectscript_type_map(ctx, node, &state);
handle_url_builders(ctx, node, &state);
CBMInvocationDescriptor invocation = handle_calls(ctx, node, spec, &state);
handle_usages(ctx, node, spec, &state);
handle_throws(ctx, node, spec, &state);
+170
View File
@@ -3370,6 +3370,169 @@ static const CBMCall *find_call_by_callee(CBMFileResult *r, const char *callee)
return NULL;
}
/* Issue #1009: URL-builder helper pattern — a function returning a URL-shaped
* literal, consumed as client(buildPath(id)). The builder's URL is recorded in
* the per-file constant map and resolved at the call site, for both return
* statements and arrow expression bodies. */
TEST(extract_ts_url_builder_issue1009) {
CBMFileResult *r = extract("function thingDetail(id: string): string {\n"
" return `/api/v1/things/${id}/detail`;\n"
"}\n"
"const arrowPath = (id: string) => `/api/v1/arrows/${id}`;\n"
"export function useThing(id: string) {\n"
" return apiGet(thingDetail(id));\n"
"}\n"
"export function useArrow(id: string) {\n"
" return apiFetch(arrowPath(id));\n"
"}\n",
CBM_LANG_TYPESCRIPT, "t", "builders.ts");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
const CBMCall *c1 = find_call_by_callee(r, "apiGet");
ASSERT_NOT_NULL(c1);
ASSERT_NOT_NULL(c1->first_string_arg);
ASSERT_STR_EQ(c1->first_string_arg, "/api/v1/things/{}/detail");
const CBMCall *c2 = find_call_by_callee(r, "apiFetch");
ASSERT_NOT_NULL(c2);
ASSERT_NOT_NULL(c2->first_string_arg);
ASSERT_STR_EQ(c2->first_string_arg, "/api/v1/arrows/{}");
cbm_free_result(r);
PASS();
}
/* Issue #1009 (composed builders): a builder whose template inlines an earlier
* builder's call plus a query string: `return \`${basePath(id)}?${params}\``.
* The known-substitution is inlined and the query string is truncated, so the
* resolved URL joins the server route exactly. */
TEST(extract_ts_url_builder_composed_issue1009) {
CBMFileResult *r = extract("function activityPath(id: string): string {\n"
" return `/api/v1/team-members/${id}/activity`;\n"
"}\n"
"function buildPath(id: string, cursor: string): string {\n"
" const params = new URLSearchParams();\n"
" params.set('cursor', cursor);\n"
" return `${activityPath(id)}?${params.toString()}`;\n"
"}\n"
"export function useActivity(id: string, cursor: string) {\n"
" return apiGet(buildPath(id, cursor));\n"
"}\n",
CBM_LANG_TYPESCRIPT, "t", "composed.ts");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
const CBMCall *c = find_call_by_callee(r, "apiGet");
ASSERT_NOT_NULL(c);
ASSERT_NOT_NULL(c->first_string_arg);
ASSERT_STR_EQ(c->first_string_arg, "/api/v1/team-members/{}/activity");
cbm_free_result(r);
PASS();
}
static bool any_call_arg_resolves_to(CBMFileResult *r, const char *url) {
for (int i = 0; i < r->calls.count; i++) {
const CBMCall *c = &r->calls.items[i];
if (c->first_string_arg && strcmp(c->first_string_arg, url) == 0) {
return true;
}
for (int a = 0; a < c->arg_count; a++) {
if (c->args[a].value && strcmp(c->args[a].value, url) == 0) {
return true;
}
}
}
return false;
}
TEST(extract_c_url_builder_gated_issue1009) {
CBMFileResult *r = extract("static const char *cfg_path(void) {\n"
" return \"/etc/myapp/conf.d\";\n"
"}\n"
"void init(void) {\n"
" parse_config(cfg_path());\n"
"}\n",
CBM_LANG_C, "t", "conf.c");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_FALSE(any_call_arg_resolves_to(r, "/etc/myapp/conf.d"));
cbm_free_result(r);
PASS();
}
/* One literal return next to a computed one would attribute the literal to every
* call site, including those taking the computed branch. A builder whose returns
* are not all URL literals is declined. */
TEST(extract_ts_url_builder_mixed_returns_issue1009) {
CBMFileResult *r = extract("function pathFor(kind: string): string {\n"
" if (kind === 'user') return '/api/users';\n"
" return computePath(kind);\n"
"}\n"
"export function load(kind: string) {\n"
" return apiGet(pathFor(kind));\n"
"}\n",
CBM_LANG_TYPESCRIPT, "t", "mixed.ts");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_FALSE(any_call_arg_resolves_to(r, "/api/users"));
cbm_free_result(r);
PASS();
}
/* Two different literal URLs make the call site's route unknowable, so the
* builder is tombstoned and lookups miss. */
TEST(extract_ts_url_builder_ambiguous_issue1009) {
CBMFileResult *r = extract("function pathFor(kind: string): string {\n"
" if (kind === 'user') return '/api/users';\n"
" return '/api/teams';\n"
"}\n"
"export function load(kind: string) {\n"
" return apiGet(pathFor(kind));\n"
"}\n",
CBM_LANG_TYPESCRIPT, "t", "ambiguous.ts");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_FALSE(any_call_arg_resolves_to(r, "/api/users"));
ASSERT_FALSE(any_call_arg_resolves_to(r, "/api/teams"));
cbm_free_result(r);
PASS();
}
/* Handing a builder to a callback position builds no URL. Only a call to it
* resolves, so the mapping function does not inherit an HTTP_CALLS edge to the
* route. */
TEST(extract_ts_url_builder_reference_issue1009) {
CBMFileResult *r = extract("function thingPath(id: string): string {\n"
" return `/api/v1/things/${id}`;\n"
"}\n"
"export function loadAll(ids: string[]) {\n"
" return ids.map(thingPath);\n"
"}\n",
CBM_LANG_TYPESCRIPT, "t", "reference.ts");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
ASSERT_FALSE(any_call_arg_resolves_to(r, "/api/v1/things/{}"));
cbm_free_result(r);
PASS();
}
/* A helper returning an ordinary string is not a URL builder, so its call site
* gets no resolved string argument. */
TEST(extract_ts_url_builder_non_url_issue1009) {
CBMFileResult *r = extract("function label(): string {\n"
" return 'plain text';\n"
"}\n"
"export function render() {\n"
" return send(label());\n"
"}\n",
CBM_LANG_TYPESCRIPT, "t", "label.ts");
ASSERT_NOT_NULL(r);
ASSERT_FALSE(r->has_error);
const CBMCall *c = find_call_by_callee(r, "send");
ASSERT_NOT_NULL(c);
ASSERT_NULL(c->first_string_arg);
ASSERT_FALSE(any_call_arg_resolves_to(r, "plain text"));
cbm_free_result(r);
PASS();
}
/* Issue #1006: JS/TS template-literal URLs must flatten ${...} substitutions
* to the canonical "{}" placeholder, both as call arguments (HTTP_CALLS) and
* as URL-shaped string_refs collected from const/return positions. */
@@ -5786,6 +5949,13 @@ SUITE(extraction) {
RUN_TEST(extract_ts_template_string_url_issue1006);
RUN_TEST(extract_go_binary_concat_url_issue1249);
RUN_TEST(extract_go_binary_concat_url_no_literal_suffix_issue1249);
RUN_TEST(extract_ts_url_builder_issue1009);
RUN_TEST(extract_ts_url_builder_composed_issue1009);
RUN_TEST(extract_c_url_builder_gated_issue1009);
RUN_TEST(extract_ts_url_builder_mixed_returns_issue1009);
RUN_TEST(extract_ts_url_builder_ambiguous_issue1009);
RUN_TEST(extract_ts_url_builder_reference_issue1009);
RUN_TEST(extract_ts_url_builder_non_url_issue1009);
RUN_TEST(extract_java_no_double_class_qn);
RUN_TEST(extract_go_no_filename_in_module_qn);
RUN_TEST(extract_large_ts_has_functions_issue213);