@@ -1099,6 +1099,227 @@ static void handle_string_refs(CBMExtractCtx *ctx, TSNode node, const WalkState
10991099 cbm_stringref_push (& ctx -> result -> string_refs , ctx -> arena , ref );
11001100}
11011101
1102+ // --- URL-builder helpers (issue #1009) ---
1103+
1104+ /* Map-aware template flatten for builder bodies: a ${...} substitution that is
1105+ * a bare identifier or a call to an already-recorded name (const or an earlier
1106+ * builder in the same file) inlines that value; anything else becomes "{}".
1107+ * The query string is not part of a route's identity, so the result is
1108+ * truncated at the first '?'. Handles the composed-builder shape
1109+ * `return \`${basePath(id)}?${params}\``. */
1110+ static const char * builder_template_text (CBMExtractCtx * ctx , TSNode node ) {
1111+ enum { BLD_BUF = 512 };
1112+ char buf [BLD_BUF ];
1113+ size_t pos = 0 ;
1114+ uint32_t nc = ts_node_named_child_count (node );
1115+ for (uint32_t i = 0 ; i < nc ; i ++ ) {
1116+ TSNode c = ts_node_named_child (node , i );
1117+ const char * k = ts_node_type (c );
1118+ const char * piece = NULL ;
1119+ if (strcmp (k , "string_fragment" ) == 0 ) {
1120+ piece = cbm_node_text (ctx -> arena , c , ctx -> source );
1121+ } else if (strcmp (k , "template_substitution" ) == 0 ) {
1122+ piece = "{}" ;
1123+ if (ts_node_named_child_count (c ) > 0 ) {
1124+ TSNode expr = ts_node_named_child (c , 0 );
1125+ /* `${basePath(id)}` inlines a builder, `${BASE}` a const. A bare
1126+ * name never resolves to a builder: that reads the function. */
1127+ bool want_builder = strcmp (ts_node_type (expr ), "call_expression" ) == 0 ;
1128+ TSNode name_node =
1129+ want_builder ? ts_node_child_by_field_name (expr , TS_FIELD ("function" )) : expr ;
1130+ if (!ts_node_is_null (name_node ) &&
1131+ strcmp (ts_node_type (name_node ), "identifier" ) == 0 ) {
1132+ char * nm = cbm_node_text (ctx -> arena , name_node , ctx -> source );
1133+ if (nm ) {
1134+ const CBMStringConstantMap * map = & ctx -> string_constants ;
1135+ for (int mi = 0 ; mi < map -> count ; mi ++ ) {
1136+ if (map -> values [mi ] && map -> is_url_builder [mi ] == want_builder &&
1137+ strcmp (map -> names [mi ], nm ) == 0 ) {
1138+ piece = map -> values [mi ];
1139+ break ;
1140+ }
1141+ }
1142+ }
1143+ }
1144+ }
1145+ } else {
1146+ continue ;
1147+ }
1148+ if (!piece ) {
1149+ continue ;
1150+ }
1151+ size_t pl = strlen (piece );
1152+ if (pos + pl >= BLD_BUF ) {
1153+ return NULL ;
1154+ }
1155+ memcpy (buf + pos , piece , pl );
1156+ pos += pl ;
1157+ }
1158+ /* Route identity excludes the query string. */
1159+ for (size_t qi = 0 ; qi < pos ; qi ++ ) {
1160+ if (buf [qi ] == '?' ) {
1161+ pos = qi ;
1162+ break ;
1163+ }
1164+ }
1165+ if (pos == 0 ) {
1166+ return NULL ;
1167+ }
1168+ return cbm_arena_strndup (ctx -> arena , buf , pos );
1169+ }
1170+
1171+ /* Flatten a string-ish node (plain string or template literal) to text. */
1172+ static const char * url_builder_literal_text (CBMExtractCtx * ctx , TSNode value_node ) {
1173+ const char * kind = ts_node_type (value_node );
1174+ if (strcmp (kind , "template_string" ) == 0 ) {
1175+ return builder_template_text (ctx , value_node );
1176+ }
1177+ if (!is_string_node (kind )) {
1178+ return NULL ;
1179+ }
1180+ char * text = cbm_node_text (ctx -> arena , value_node , ctx -> source );
1181+ if (!text || !text [0 ]) {
1182+ return NULL ;
1183+ }
1184+ int len = (int )strlen (text );
1185+ if (len >= CBM_QUOTE_PAIR && (text [0 ] == '"' || text [0 ] == '\'' )) {
1186+ text = cbm_arena_strndup (ctx -> arena , text + SKIP_ONE , (size_t )(len - PAIR_LEN ));
1187+ }
1188+ return text ;
1189+ }
1190+
1191+ /* A route-shaped URL literal: an absolute path that classifies as a URL. */
1192+ static const char * builder_route_url (CBMExtractCtx * ctx , TSNode value_node ) {
1193+ const char * url = url_builder_literal_text (ctx , value_node );
1194+ if (!url || url [0 ] != '/' || cbm_classify_string (url , (int )strlen (url )) != CBM_STRREF_URL ) {
1195+ return NULL ;
1196+ }
1197+ return url ;
1198+ }
1199+
1200+ static void record_url_builder (CBMExtractCtx * ctx , const char * name , const char * url ) {
1201+ if (!name || !name [0 ] || !url ) {
1202+ return ;
1203+ }
1204+ CBMStringConstantMap * map = & ctx -> string_constants ;
1205+ for (int i = 0 ; i < map -> count ; i ++ ) {
1206+ if (strcmp (map -> names [i ], name ) == 0 ) {
1207+ if (map -> is_url_builder [i ] && map -> values [i ] && strcmp (map -> values [i ], url ) != 0 ) {
1208+ map -> values [i ] = NULL ; /* ambiguous builder */
1209+ }
1210+ return ;
1211+ }
1212+ }
1213+ if (map -> count < CBM_MAX_STRING_CONSTANTS ) {
1214+ map -> names [map -> count ] = (char * )name ;
1215+ map -> values [map -> count ] = (char * )url ;
1216+ map -> is_url_builder [map -> count ] = true;
1217+ map -> count ++ ;
1218+ }
1219+ }
1220+
1221+ /* Every `return` this function owns yields a route-shaped URL literal. A body
1222+ * that also returns a computed path (`return computePath(kind)`) would attribute
1223+ * its one literal to call sites that never produce it, so a mixed builder is
1224+ * declined rather than guessed. Returns inside a nested function belong to that
1225+ * function, not to this one. */
1226+ static bool builder_returns_only_urls (CBMExtractCtx * ctx , TSNode func ) {
1227+ TSNode body = ts_node_child_by_field_name (func , TS_FIELD ("body" ));
1228+ if (ts_node_is_null (body )) {
1229+ return false;
1230+ }
1231+ bool only_urls = true;
1232+ TSTreeCursor cursor = ts_tree_cursor_new (body );
1233+ for (;;) {
1234+ TSNode node = ts_tree_cursor_current_node (& cursor );
1235+ if (strcmp (ts_node_type (node ), "return_statement" ) == 0 &&
1236+ ts_node_eq (cbm_find_enclosing_func (node , ctx -> language ), func )) {
1237+ if (ts_node_named_child_count (node ) == 0 ||
1238+ !builder_route_url (ctx , ts_node_named_child (node , 0 ))) {
1239+ only_urls = false;
1240+ break ;
1241+ }
1242+ }
1243+ if (ts_tree_cursor_goto_first_child (& cursor )) {
1244+ continue ;
1245+ }
1246+ if (ts_tree_cursor_goto_next_sibling (& cursor )) {
1247+ continue ;
1248+ }
1249+ bool found = false;
1250+ while (ts_tree_cursor_goto_parent (& cursor )) {
1251+ if (ts_tree_cursor_goto_next_sibling (& cursor )) {
1252+ found = true;
1253+ break ;
1254+ }
1255+ }
1256+ if (!found ) {
1257+ break ;
1258+ }
1259+ }
1260+ ts_tree_cursor_delete (& cursor );
1261+ return only_urls ;
1262+ }
1263+
1264+ /* URL-builder helper pattern (issue #1009): a small function whose return value
1265+ * is a URL-shaped literal, consumed as `client(buildPath(id))`. The literal
1266+ * never appears as a call argument, so first_string_arg resolution cannot see
1267+ * it; record `functionName -> url` in the per-file constant map and let the
1268+ * call-site call_expression branch resolve it. Covers `return`-statement bodies
1269+ * and arrow-function expression bodies.
1270+ *
1271+ * JS/TS only. The predicate accepts any absolute pathname, and `return` plus a
1272+ * string literal is also the shape of every C or Go helper handing back a
1273+ * filesystem path (`/etc/...`, `/proc/self/...`); recording those would mint a
1274+ * Route node and an HTTP_CALLS edge in a language that speaks no HTTP. */
1275+ static void handle_url_builders (CBMExtractCtx * ctx , TSNode node , const WalkState * state ) {
1276+ if (ctx -> language != CBM_LANG_JAVASCRIPT && ctx -> language != CBM_LANG_TYPESCRIPT &&
1277+ ctx -> language != CBM_LANG_TSX ) {
1278+ return ;
1279+ }
1280+ const char * kind = ts_node_type (node );
1281+
1282+ if (strcmp (kind , "arrow_function" ) == 0 ) {
1283+ TSNode body = ts_node_child_by_field_name (node , TS_FIELD ("body" ));
1284+ if (ts_node_is_null (body ) || strcmp (ts_node_type (body ), "statement_block" ) == 0 ) {
1285+ return ;
1286+ }
1287+ const char * url = builder_route_url (ctx , body );
1288+ if (!url ) {
1289+ return ;
1290+ }
1291+ TSNode parent = ts_node_parent (node );
1292+ if (!ts_node_is_null (parent ) && strcmp (ts_node_type (parent ), "variable_declarator" ) == 0 ) {
1293+ TSNode name_node = ts_node_child_by_field_name (parent , TS_FIELD ("name" ));
1294+ if (!ts_node_is_null (name_node )) {
1295+ record_url_builder (ctx , cbm_node_text (ctx -> arena , name_node , ctx -> source ), url );
1296+ }
1297+ }
1298+ return ;
1299+ }
1300+
1301+ if (strcmp (kind , "return_statement" ) != 0 ) {
1302+ return ;
1303+ }
1304+ if (!state -> enclosing_func_qn || state -> enclosing_func_qn == ctx -> module_qn ) {
1305+ return ;
1306+ }
1307+ if (ts_node_named_child_count (node ) == 0 ) {
1308+ return ;
1309+ }
1310+ const char * url = builder_route_url (ctx , ts_node_named_child (node , 0 ));
1311+ if (!url ) {
1312+ return ;
1313+ }
1314+ TSNode func = cbm_find_enclosing_func (node , ctx -> language );
1315+ if (ts_node_is_null (func ) || !builder_returns_only_urls (ctx , func )) {
1316+ return ;
1317+ }
1318+ const char * name = strrchr (state -> enclosing_func_qn , '.' );
1319+ name = name ? name + 1 : state -> enclosing_func_qn ;
1320+ record_url_builder (ctx , name , url );
1321+ }
1322+
11021323// --- YAML nested field extraction (D2) ---
11031324
11041325/* Recursively walk YAML block_mapping_pair nodes, building dotted key paths.
@@ -2160,6 +2381,7 @@ void cbm_extract_unified(CBMExtractCtx *ctx) {
21602381
21612382 handle_string_constants (ctx , node , & state );
21622383 handle_objectscript_type_map (ctx , node , & state );
2384+ handle_url_builders (ctx , node , & state );
21632385 CBMInvocationDescriptor invocation = handle_calls (ctx , node , spec , & state );
21642386 handle_usages (ctx , node , spec , & state );
21652387 handle_throws (ctx , node , spec , & state );
0 commit comments