From d9f3088b66225a600b440350521b8549865da704 Mon Sep 17 00:00:00 2001 From: Charles Queiroz Date: Fri, 10 Jul 2026 14:14:53 -0300 Subject: [PATCH 1/3] fix: resolve URL-builder helper calls to Route/HTTP_CALLS (#1009) URL-shaped literals returned from small builder functions never reached call arguments, so client(buildPath(id)) produced no HTTP_CALLS edge and no Route node, static or template alike. handle_url_builders() records builderName -> returned URL in the same per-file constant map used for module-level consts, covering return statements and arrow expression bodies; a call_expression branch in extract_url_or_topic_arg() resolves client(buildPath(id)) through it. Composed builders inline already-recorded substitutions and truncate the query string, so `${basePath(id)}?${params}` joins the server route exactly. Ambiguous builders (two different URLs) are tombstoned. Signed-off-by: Charles Queiroz --- internal/cbm/extract_calls.c | 13 +++ internal/cbm/extract_unified.c | 158 +++++++++++++++++++++++++++++++++ tests/test_extraction.c | 59 ++++++++++++ 3 files changed, 230 insertions(+) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index c93e46a44..56593f0c9 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -2085,6 +2085,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_string_constant(ctx, fname) : NULL; + if (val) { + return val; + } + } + } + if (ai < MAX_POSITIONAL_SCAN) { const char *val = extract_positional_url(ctx, arg, ak); if (val) { diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 20e294c93..50bc96b69 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1099,6 +1099,163 @@ 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); + const char *ek = ts_node_type(expr); + TSNode name_node = expr; + if (strcmp(ek, "call_expression") == 0) { + name_node = ts_node_child_by_field_name(expr, TS_FIELD("function")); + } + 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] && 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; +} + +/* Record `name -> url` in the per-file constant map so call-site resolution + * (lookup_string_constant) can see it. A builder that returns two DIFFERENT + * urls is ambiguous: tombstone it with a NULL value so lookups miss. */ +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->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->count++; + } +} + +/* 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 same per-file constant map used for + * module-level consts and let the call-site call_expression branch resolve it. + * Covers `return`-statement bodies and arrow-function expression bodies. */ +static void handle_url_builders(CBMExtractCtx *ctx, TSNode node, const WalkState *state) { + 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 = url_builder_literal_text(ctx, body); + if (!url || url[0] != '/' || cbm_classify_string(url, (int)strlen(url)) != CBM_STRREF_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 = url_builder_literal_text(ctx, ts_node_named_child(node, 0)); + if (!url || url[0] != '/' || cbm_classify_string(url, (int)strlen(url)) != CBM_STRREF_URL) { + 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 +2317,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); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 88796c70d..67c2734ca 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -3370,6 +3370,63 @@ 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(); +} + /* 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 +5843,8 @@ 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_java_no_double_class_qn); RUN_TEST(extract_go_no_filename_in_module_qn); RUN_TEST(extract_large_ts_has_functions_issue213); From 5bbc7b7c140ce340d4f2aa4790bfcadf09580080 Mon Sep 17 00:00:00 2001 From: Charles Queiroz Date: Fri, 10 Jul 2026 14:28:14 -0300 Subject: [PATCH 2/3] fix: resolve builder calls and template literals in per-arg values too detect_url_in_args() (the arg_url HTTP_CALLS emitter used when the callee is a local client wrapper, not a known HTTP library) reads per-arg values, not first_string_arg. Resolve call_expression args through the builder map and flatten template_string args to the {} form there as well, so wrappers like apiFetch(buildPath(id)) emit the edge and join the canonical route. Signed-off-by: Charles Queiroz --- internal/cbm/extract_calls.c | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 56593f0c9..9d83ed0b6 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -1855,8 +1855,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_string_constant(ctx, fname); + } + } } call->arg_count++; } From 606052a2fc9168b8e5e2838a4ffc1a4f3e3b1cc0 Mon Sep 17 00:00:00 2001 From: Charles Queiroz Date: Fri, 21 Aug 2026 10:16:19 +0100 Subject: [PATCH 3/3] fix: gate URL-builder resolution to JS/TS and to call arguments handle_url_builders ran for every vendored grammar, so a C helper returning an absolute pathname became a URL builder and minted a Route node plus an HTTP_CALLS edge in a language that speaks no HTTP. Gate recording to JavaScript, TypeScript and TSX. Builder entries now carry a flag in the per-file constant map: only a call_expression argument resolves one, so handing a builder to a callback builds no request. A builder whose returns are not all route-shaped literals is declined instead of attributing its one literal to call sites that take the computed branch. Five negative tests cover the cross-language case, the mixed builder, the ambiguity tombstone, the builder reference and the non-URL helper. Signed-off-by: Charles Queiroz --- internal/cbm/cbm.h | 1 + internal/cbm/extract_calls.c | 23 +++++-- internal/cbm/extract_unified.c | 98 ++++++++++++++++++++++++----- tests/test_extraction.c | 111 +++++++++++++++++++++++++++++++++ 4 files changed, 212 insertions(+), 21 deletions(-) diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index aebd12291..946fac2e7 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -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; diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 9d83ed0b6..49cb4209b 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -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]; } } @@ -1868,7 +1883,7 @@ static void extract_call_args(CBMExtractCtx *ctx, TSNode args, CBMCall *call) { 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_string_constant(ctx, fname); + ca->value = lookup_url_builder(ctx, fname); } } } @@ -2105,7 +2120,7 @@ static const char *extract_url_or_topic_arg(CBMExtractCtx *ctx, TSNode args) { 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_string_constant(ctx, fname) : NULL; + const char *val = fname ? lookup_url_builder(ctx, fname) : NULL; if (val) { return val; } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 50bc96b69..7a6306870 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1122,18 +1122,19 @@ static const char *builder_template_text(CBMExtractCtx *ctx, TSNode node) { piece = "{}"; if (ts_node_named_child_count(c) > 0) { TSNode expr = ts_node_named_child(c, 0); - const char *ek = ts_node_type(expr); - TSNode name_node = expr; - if (strcmp(ek, "call_expression") == 0) { - name_node = ts_node_child_by_field_name(expr, TS_FIELD("function")); - } + /* `${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] && strcmp(map->names[mi], nm) == 0) { + if (map->values[mi] && map->is_url_builder[mi] == want_builder && + strcmp(map->names[mi], nm) == 0) { piece = map->values[mi]; break; } @@ -1187,9 +1188,15 @@ static const char *url_builder_literal_text(CBMExtractCtx *ctx, TSNode value_nod return text; } -/* Record `name -> url` in the per-file constant map so call-site resolution - * (lookup_string_constant) can see it. A builder that returns two DIFFERENT - * urls is ambiguous: tombstone it with a NULL value so lookups miss. */ +/* 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; @@ -1197,7 +1204,7 @@ static void record_url_builder(CBMExtractCtx *ctx, const char *name, const char CBMStringConstantMap *map = &ctx->string_constants; for (int i = 0; i < map->count; i++) { if (strcmp(map->names[i], name) == 0) { - if (map->values[i] && strcmp(map->values[i], url) != 0) { + if (map->is_url_builder[i] && map->values[i] && strcmp(map->values[i], url) != 0) { map->values[i] = NULL; /* ambiguous builder */ } return; @@ -1206,17 +1213,70 @@ static void record_url_builder(CBMExtractCtx *ctx, const char *name, const char 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 same per-file constant map used for - * module-level consts and let the call-site call_expression branch resolve it. - * Covers `return`-statement bodies and arrow-function expression bodies. */ + * 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) { @@ -1224,8 +1284,8 @@ static void handle_url_builders(CBMExtractCtx *ctx, TSNode node, const WalkState if (ts_node_is_null(body) || strcmp(ts_node_type(body), "statement_block") == 0) { return; } - const char *url = url_builder_literal_text(ctx, body); - if (!url || url[0] != '/' || cbm_classify_string(url, (int)strlen(url)) != CBM_STRREF_URL) { + const char *url = builder_route_url(ctx, body); + if (!url) { return; } TSNode parent = ts_node_parent(node); @@ -1247,8 +1307,12 @@ static void handle_url_builders(CBMExtractCtx *ctx, TSNode node, const WalkState if (ts_node_named_child_count(node) == 0) { return; } - const char *url = url_builder_literal_text(ctx, ts_node_named_child(node, 0)); - if (!url || url[0] != '/' || cbm_classify_string(url, (int)strlen(url)) != CBM_STRREF_URL) { + 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, '.'); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 67c2734ca..e9f44e113 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -3427,6 +3427,112 @@ TEST(extract_ts_url_builder_composed_issue1009) { 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. */ @@ -5845,6 +5951,11 @@ SUITE(extraction) { 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);