diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 5d1abc215..7b6312314 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -241,6 +241,9 @@ typedef struct { const char **return_types; // NULL-terminated array (NULL if none) const char *route_path; // HTTP route path from decorator (e.g., "/api/users") or NULL const char *route_method; // HTTP method from decorator (e.g., "POST") or NULL + // Path a client interface method requests (Retrofit @GET("v1/x")) or NULL; + // route_method is then its verb. + const char *client_route_path; int complexity; // cyclomatic complexity int cognitive; // cognitive complexity (nesting-weighted) int loop_count; // number of loop constructs in the body diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 76571c1c7..ab990b7ed 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3593,6 +3593,83 @@ static void extract_route_from_decorators(CBMArena *a, TSNode func_node, const c extract_route_from_annotations(a, func_node, source, spec, out_path, out_method); } +/* The string literal an annotation argument consists of, positional or as + * `value = "..."`. A concatenation, a constant or a template has no statically + * known value, so it yields a null node rather than one of its fragments. */ +static TSNode annotation_arg_literal(TSNode arg) { + TSNode null_node = {0}; + const char *kind = ts_node_type(arg); + if (strcmp(kind, "element_value_pair") == 0 || strcmp(kind, "value_argument") == 0) { + uint32_t nc = ts_node_named_child_count(arg); + if (nc == 0) { + return null_node; + } + arg = ts_node_named_child(arg, nc - SKIP_ONE); + } + if (!is_route_string_kind(ts_node_type(arg))) { + return null_node; + } + for (uint32_t i = 0; i < ts_node_named_child_count(arg); i++) { + const char *ck = ts_node_type(ts_node_named_child(arg, i)); + if (strcmp(ck, "interpolated_identifier") == 0 || + strcmp(ck, "interpolated_expression") == 0 || strcmp(ck, "string_interpolation") == 0) { + return null_node; + } + } + return arg; +} + +/* Retrofit declares a client's requests as @GET("v1/orders") / @POST(...) on an + * interface method. JAX-RS uses the same bare verb names, but its verbs are + * marker annotations (the path is a sibling @Path), so a verb that carries an + * argument is a Retrofit request, not a served endpoint. Returns true for such + * a method; *out_path is its path, or NULL when the argument is not a literal. + * Retrofit resolves a relative path against the base URL, so it is rooted the + * way JAX-RS paths are; a full URL is kept as written. */ +static bool retrofit_client_route(CBMArena *a, TSNode func_node, const char *source, + const CBMLangSpec *spec, const char **out_path, + const char **out_method) { + *out_path = NULL; + *out_method = NULL; + TSNode wrappers[MAX_ATTR_WRAPPERS]; + int wn = find_jvm_modifiers(func_node, spec->language, wrappers, MAX_ATTR_WRAPPERS_MINUS_1); + wrappers[wn++] = func_node; + for (int w = 0; w < wn; w++) { + uint32_t cc = ts_node_child_count(wrappers[w]); + for (uint32_t ci = 0; ci < cc; ci++) { + TSNode child = ts_node_child(wrappers[w], ci); + if (!cbm_kind_in_set(child, spec->decorator_node_types)) { + continue; + } + TSNode name_node = annotation_name_node(child); + if (ts_node_is_null(name_node)) { + continue; + } + char *name = cbm_node_text(a, name_node, source); + const char *method = annotation_route_method(name); + if (!method || strcmp(method, name) != 0) { + continue; + } + TSNode args = annotation_args_node(child); + if (ts_node_is_null(args) || ts_node_named_child_count(args) == 0) { + continue; + } + *out_method = method; + TSNode lit = annotation_arg_literal(ts_node_named_child(args, 0)); + char *text = ts_node_is_null(lit) ? NULL : cbm_node_text(a, lit, source); + size_t len = text ? strlen(text) : 0; + if (len > PAIR_CHARS && (text[0] == '"' || text[0] == '\'')) { + char *path = cbm_arena_strndup(a, text + SKIP_CHAR, len - PAIR_CHARS); + *out_path = (!path || path[0] == '/' || strstr(path, "://")) + ? path + : cbm_arena_sprintf(a, "/%s", path); + } + return true; + } + } + return false; +} + static const char *join_route_paths(CBMArena *a, const char *prefix, const char *path) { if (!path || !path[0]) { return prefix; @@ -9306,6 +9383,14 @@ static void push_method_def(CBMExtractCtx *ctx, TSNode child, TSNode class_node, def.decorators = extract_decorators(a, child, ctx->source, ctx->language, spec); extract_route_from_decorators(a, child, ctx->source, spec, &def.route_path, &def.route_method); + const char *client_path = NULL; + const char *client_method = NULL; + if ((ctx->language == CBM_LANG_JAVA || ctx->language == CBM_LANG_KOTLIN) && + retrofit_client_route(a, child, ctx->source, spec, &client_path, &client_method)) { + def.route_path = NULL; + def.client_route_path = client_path; + def.route_method = client_path ? client_method : NULL; + } if (def.route_path && (ctx->language == CBM_LANG_JAVA || ctx->language == CBM_LANG_KOTLIN || ctx->language == CBM_LANG_SCALA)) { const char *prefix = spring_class_route_prefix(a, class_node, ctx->source, spec); diff --git a/internal/cbm/result_compact.c b/internal/cbm/result_compact.c index 46bf6eae1..b0994690e 100644 --- a/internal/cbm/result_compact.c +++ b/internal/cbm/result_compact.c @@ -268,6 +268,7 @@ static void cr_walk_def(cr_ctx_t *c, CBMDefinition *d) { cr_list(c, &d->return_types); cr_str(c, &d->route_path); cr_str(c, &d->route_method); + cr_str(c, &d->client_route_path); cr_blob(c, (const void **)&d->fingerprint, d->fingerprint_k > 0 ? (size_t)d->fingerprint_k * sizeof(uint32_t) : 0); cr_str(c, &d->structural_profile); diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 7c7aa438f..ca885604c 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -322,6 +322,7 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) append_json_str_array(buf, bufsize, &pos, "param_types", def->param_types); append_json_string(buf, bufsize, &pos, "route_path", def->route_path); append_json_string(buf, bufsize, &pos, "route_method", def->route_method); + append_json_string(buf, bufsize, &pos, "client_route_path", def->client_route_path); append_json_string(buf, bufsize, &pos, "http_client", def->http_client); append_json_string(buf, bufsize, &pos, "http_base_url", def->http_base_url); /* A document definition's facts (an ADR's id, status, date, ...): key, value pairs. */ @@ -402,6 +403,7 @@ static char *pd_props_buf(const CBMDefinition *def, char *stack, size_t *size) { pd_arr_field_len("param_types", def->param_types) + pd_str_field_len("route_path", def->route_path) + pd_str_field_len("route_method", def->route_method) + + pd_str_field_len("client_route_path", def->client_route_path) + pd_str_field_len("http_client", def->http_client) + pd_str_field_len("http_base_url", def->http_base_url); for (const char **kv = def->extra_props; kv && kv[0] && kv[SKIP_ONE]; kv += PAIR_LEN) { diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e02d68c67..df98068fd 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -552,6 +552,7 @@ static void build_def_props(char *buf, size_t bufsize, const CBMDefinition *def) append_json_str_array(buf, bufsize, &pos, "param_types", def->param_types); append_json_string(buf, bufsize, &pos, "route_path", def->route_path); append_json_string(buf, bufsize, &pos, "route_method", def->route_method); + append_json_string(buf, bufsize, &pos, "client_route_path", def->client_route_path); append_json_string(buf, bufsize, &pos, "http_client", def->http_client); append_json_string(buf, bufsize, &pos, "http_base_url", def->http_base_url); /* A document definition's facts (an ADR's id, status, date, ...): key, value pairs. */ @@ -780,6 +781,7 @@ static char *pp_props_buf(const CBMDefinition *def, char *stack, size_t *size) { pp_arr_field_len("param_types", def->param_types) + pp_str_field_len("route_path", def->route_path) + pp_str_field_len("route_method", def->route_method) + + pp_str_field_len("client_route_path", def->client_route_path) + pp_str_field_len("http_client", def->http_client) + pp_str_field_len("http_base_url", def->http_base_url); for (const char **kv = def->extra_props; kv && kv[0] && kv[SKIP_ONE]; kv += PAIR_LEN) { diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 23dff8150..02d0478fd 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -397,8 +397,36 @@ static void match_infra_routes(cbm_gbuf_t *gb) { } } +/* A client interface method's declared request (Retrofit): an HTTP_CALLS edge + * to the Route, with the url_path/method properties a call site's edge carries, + * so the endpoint is matched to its server like any other outbound call. */ +static void emit_client_route_call(cbm_gbuf_t *gb, int64_t func_id, const char *path, + const char *method) { + const char *rm = method ? method : "ANY"; + char route_qn[CBM_ROUTE_QN_SIZE]; + char cpath[CBM_SZ_256]; + snprintf(route_qn, sizeof(route_qn), "__route__%s__%s", rm, + cbm_route_canon_path(path, cpath, sizeof(cpath))); + char esc_m[CBM_SZ_128]; + cbm_json_escape(esc_m, sizeof(esc_m), rm); + char rprops[CBM_SZ_256]; + (void)snprintf(rprops, sizeof(rprops), "{\"method\":\"%s\"}", esc_m); + int64_t route_id = cbm_gbuf_upsert_node(gb, "Route", path, route_qn, "", 0, 0, rprops); + char esc_p[CBM_SZ_512]; + cbm_json_escape(esc_p, sizeof(esc_p), path); + char eprops[CBM_SZ_1K]; + (void)snprintf(eprops, sizeof(eprops), + "{\"callee\":\"%s\",\"url_path\":\"%s\",\"method\":\"%s\"}", esc_m, esc_p, + esc_m); + cbm_gbuf_insert_edge(gb, func_id, route_id, "HTTP_CALLS", eprops); +} + void cbm_pipeline_emit_def_route(cbm_gbuf_t *gb, int64_t func_id, const CBMDefinition *def, const char *rel) { + if (def->client_route_path && def->client_route_path[0]) { + emit_client_route_call(gb, func_id, def->client_route_path, def->route_method); + return; + } if (!def->route_path || def->route_path[0] == '\0') { return; } diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index e492c2b8d..7ea1ff81a 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -2592,9 +2592,10 @@ static void log_result_census(const char *tag, CBMFileResult **cache, int file_c } str_def_fp += def->fingerprint ? (size_t)def->fingerprint_k * sizeof(uint32_t) : 0; str_def_misc += census_len(def->route_path) + census_len(def->route_method) + - census_len(def->impl_trait) + census_len(def->http_base_url) + - census_list(def->extra_props) + census_len(def->decl_namespace) + - census_len(def->variant_key) + census_len(def->alias_target); + census_len(def->client_route_path) + census_len(def->impl_trait) + + census_len(def->http_base_url) + census_list(def->extra_props) + + census_len(def->decl_namespace) + census_len(def->variant_key) + + census_len(def->alias_target); } for (int c = 0; c < r->calls.count; c++) { const CBMCall *call = &r->calls.items[c]; diff --git a/tests/test_edge_types_probe.c b/tests/test_edge_types_probe.c index 4bba54612..8d900af72 100644 --- a/tests/test_edge_types_probe.c +++ b/tests/test_edge_types_probe.c @@ -1140,8 +1140,9 @@ static int et_qn_has_tail(const char *qn, const char *tail) { /* route_qns (optional, NULL-terminated): Route qualified_names that must * exist, so a test asserting that a route gets NO handler cannot pass merely * because the route itself was never extracted. */ -static int et_handles_exact_routes(const EtFile *files, int nfiles, const EtHandles *want, - int parallel, const char *const *route_qns) { +static int et_edges_exact_routes(const EtFile *files, int nfiles, const char *edge, + const EtHandles *want, int parallel, + const char *const *route_qns) { EtProj lp; cbm_store_t *store = parallel ? et_index_parallel(&lp, files, nfiles) : et_index_files(&lp, files, nfiles); @@ -1153,7 +1154,7 @@ static int et_handles_exact_routes(const EtFile *files, int nfiles, const EtHand cbm_edge_t *edges = NULL; int n = 0; int ok = store != NULL && - cbm_store_find_edges_by_type(store, lp.project, "HANDLES", &edges, &n) == + cbm_store_find_edges_by_type(store, lp.project, edge, &edges, &n) == CBM_STORE_OK && n == wanted; for (int i = 0; store && i < n; i++) { @@ -1171,7 +1172,7 @@ static int et_handles_exact_routes(const EtFile *files, int nfiles, const EtHand } if (!matched) { ok = 0; - fprintf(stderr, " [ET-HANDLES] unexpected %s -> %s\n", + fprintf(stderr, " [ET-%s] unexpected %s -> %s\n", edge, have_src && src.qualified_name ? src.qualified_name : "", have_tgt && tgt.name ? tgt.name : ""); } @@ -1181,7 +1182,7 @@ static int et_handles_exact_routes(const EtFile *files, int nfiles, const EtHand for (int wi = 0; wi < wanted; wi++) { if (!found[wi]) { ok = 0; - fprintf(stderr, " [ET-HANDLES] missing %s -> %s\n", want[wi].handler_tail, + fprintf(stderr, " [ET-%s] missing %s -> %s\n", edge, want[wi].handler_tail, want[wi].route); } } @@ -1190,13 +1191,13 @@ static int et_handles_exact_routes(const EtFile *files, int nfiles, const EtHand if (!store || cbm_store_find_node_by_qn(store, lp.project, route_qns[ri], &route) != CBM_STORE_OK) { ok = 0; - fprintf(stderr, " [ET-HANDLES] missing Route %s\n", route_qns[ri]); + fprintf(stderr, " [ET-%s] missing Route %s\n", edge, route_qns[ri]); } else { cbm_node_free_fields(&route); } } if (!ok) { - fprintf(stderr, " [ET-HANDLES] FAIL (%s path) expected=%d actual=%d\n", + fprintf(stderr, " [ET-%s] FAIL (%s path) expected=%d actual=%d\n", edge, parallel ? "parallel" : "sequential", wanted, n); } if (edges) cbm_store_free_edges(edges, n); @@ -1204,10 +1205,117 @@ static int et_handles_exact_routes(const EtFile *files, int nfiles, const EtHand return ok; } +static int et_handles_exact_routes(const EtFile *files, int nfiles, const EtHandles *want, + int parallel, const char *const *route_qns) { + return et_edges_exact_routes(files, nfiles, "HANDLES", want, parallel, route_qns); +} + static int et_handles_exact(const EtFile *files, int nfiles, const EtHandles *want, int parallel) { return et_handles_exact_routes(files, nfiles, want, parallel, NULL); } +/* Retrofit (#2723): an interface method's @GET("...") declares a request the + * client makes. It was minted as a served Route with a HANDLES edge, and every + * path without a leading slash collapsed onto "/". The relative path is rooted + * (Retrofit resolves it against the base URL); a path from a constant or a + * template has no static value and yields no edge rather than a "/" Route. */ +static const EtFile et_retrofit_kotlin[] = { + {"src/main/kotlin/com/acme/client/OrderApi.kt", + "package com.acme.client\n\n" + "import retrofit2.http.Body\n" + "import retrofit2.http.GET\n" + "import retrofit2.http.POST\n" + "import retrofit2.http.Path\n\n" + "private const val ORDERS = \"/v1/orders\"\n\n" + "interface OrderApi {\n" + " @GET(\"/v1/orders/{id}\")\n" + " suspend fun getOrder(@Path(\"id\") id: String): String\n\n" + " @POST(\"v1/orders\")\n" + " suspend fun createOrder(@Body body: String): String\n\n" + " @GET(ORDERS)\n" + " suspend fun listOrders(): String\n\n" + " @GET(\"$ORDERS/{id}/status\")\n" + " suspend fun getOrderStatus(@Path(\"id\") id: String): String\n" + "}\n"}}; + +static const EtHandles et_retrofit_kotlin_calls[] = { + {"OrderApi.getOrder", "/v1/orders/{id}"}, {"OrderApi.createOrder", "/v1/orders"}, {NULL, NULL}}; + +static const EtHandles et_no_edges[] = {{NULL, NULL}}; + +TEST(http_calls_retrofit_kotlin_interface_issue2723) { + static const char *const qns[] = {"__route__GET__/v1/orders/{}", "__route__POST__/v1/orders", + NULL}; + for (int parallel = 0; parallel <= 1; parallel++) { + ASSERT_TRUE(et_edges_exact_routes(et_retrofit_kotlin, 1, "HTTP_CALLS", + et_retrofit_kotlin_calls, parallel, qns)); + ASSERT_TRUE(et_handles_exact(et_retrofit_kotlin, 1, et_no_edges, parallel)); + } + PASS(); +} + +/* Java Retrofit: the named `value =` form and a full URL are read; a + * concatenation is not, so none of its string fragments becomes a path. */ +TEST(http_calls_retrofit_java_interface_issue2723) { + static const EtFile f[] = { + {"src/main/java/com/acme/jclient/OrderApi.java", + "package com.acme.jclient;\n\n" + "import retrofit2.Call;\n" + "import retrofit2.http.Body;\n" + "import retrofit2.http.DELETE;\n" + "import retrofit2.http.GET;\n" + "import retrofit2.http.POST;\n" + "import retrofit2.http.Path;\n\n" + "public interface OrderApi {\n" + " String BASE = \"v1\";\n\n" + " @GET(\"v1/orders/{id}\")\n" + " Call getOrder(@Path(\"id\") String id);\n\n" + " @POST(value = \"v1/orders\")\n" + " Call createOrder(@Body String body);\n\n" + " @GET(\"https://status.acme.com/v1/health\")\n" + " Call health();\n\n" + " @DELETE(BASE + \"/orders/{id}\")\n" + " Call deleteOrder(@Path(\"id\") String id);\n" + "}\n"}}; + static const EtHandles calls[] = {{"OrderApi.getOrder", "/v1/orders/{id}"}, + {"OrderApi.createOrder", "/v1/orders"}, + {"OrderApi.health", "https://status.acme.com/v1/health"}, + {NULL, NULL}}; + for (int parallel = 0; parallel <= 1; parallel++) { + ASSERT_TRUE(et_edges_exact_routes(f, 1, "HTTP_CALLS", calls, parallel, NULL)); + ASSERT_TRUE(et_handles_exact(f, 1, et_no_edges, parallel)); + } + PASS(); +} + +/* Control: a JAX-RS interface keeps HANDLES. Its @GET is a marker and the path + * is a sibling @Path, so the Retrofit reading (a verb with an argument) does + * not depend on the declaring type being an interface. */ +TEST(handles_jaxrs_interface_not_retrofit_issue2723) { + static const EtFile f[] = { + {"src/main/java/com/acme/api/OrderResource.java", + "package com.acme.api;\n\n" + "import jakarta.ws.rs.GET;\n" + "import jakarta.ws.rs.POST;\n" + "import jakarta.ws.rs.Path;\n\n" + "@Path(\"/v1/orders\")\n" + "public interface OrderResource {\n" + " @GET\n" + " @Path(\"/{id}\")\n" + " String get(String id);\n\n" + " @POST\n" + " String create(String body);\n" + "}\n"}}; + static const EtHandles handles[] = {{"OrderResource.get", "/v1/orders/{id}"}, + {"OrderResource.create", "/v1/orders"}, + {NULL, NULL}}; + for (int parallel = 0; parallel <= 1; parallel++) { + ASSERT_TRUE(et_handles_exact(f, 1, handles, parallel)); + ASSERT_TRUE(et_edges_exact_routes(f, 1, "HTTP_CALLS", et_no_edges, parallel, NULL)); + } + PASS(); +} + /* The exact DATA_FLOWS set, caller -> handler by QN tail. route_qns (NULL-terminated) must * exist, so a test that expects NO flow on a path cannot pass because the caller's or the * handler's Route was never created; handles is the exact HANDLES count. */ @@ -2871,6 +2979,9 @@ SUITE(edge_types_probe) { RUN_TEST(handles_jaxrs_java_relative_path); RUN_TEST(handles_spring_java_relative_string_not_route); RUN_TEST(handles_jaxrs_scala); + RUN_TEST(http_calls_retrofit_kotlin_interface_issue2723); + RUN_TEST(http_calls_retrofit_java_interface_issue2723); + RUN_TEST(handles_jaxrs_interface_not_retrofit_issue2723); RUN_TEST(handles_aspnet_csharp); RUN_TEST(handles_aspnet_attribute_routes_issue1692); RUN_TEST(handles_aspnet_attribute_routes_issue1041); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index b8bff190a..4b890aa19 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4919,6 +4919,77 @@ TEST(incremental_dockerfile_edit_keeps_build_edges) { PASS(); } +/* Editing a Retrofit interface keeps its requests as HTTP_CALLS (#2723): the + * incremental routes re-extract the file and must not mint HANDLES for it. + * `legacy`: through the full-load route, else the closure-repair route. Returns "full=calls/handles + * inc=calls/handles", or NULL; *route: the route taken. */ +static char *incremental_retrofit_keep(bool legacy, cbm_incremental_route_t *route) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_retrofitkeep_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + return NULL; + } + write_temp_file(tmp, "src/OrderApi.kt", + "package com.acme\n\nimport retrofit2.http.GET\nimport retrofit2.http.POST\n\n" + "interface OrderApi {\n" + " @GET(\"/v1/orders/{id}\")\n suspend fun getOrder(id: String): String\n" + " @POST(\"v1/orders\")\n suspend fun createOrder(body: String): String\n" + "}\n"); + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/retrofitkeep.db", tmp); + static const char calls[] = "SELECT COUNT(*) FROM edges e JOIN nodes r ON r.id=e.target_id " + "WHERE e.type='HTTP_CALLS' AND r.label='Route' " + "AND r.qualified_name IN ('__route__GET__/v1/orders/{}'," + "'__route__POST__/v1/orders')"; + static const char handles[] = "SELECT COUNT(*) FROM edges WHERE type='HANDLES'"; + int full_calls = -1; + int full_handles = -1; + if (k8s_inc_index(tmp, db_path) == 0) { + full_calls = k8s_inc_count(db_path, calls); + full_handles = k8s_inc_count(db_path, handles); + } + write_temp_file(tmp, "src/OrderApi.kt", + "package com.acme\n\nimport retrofit2.http.GET\nimport retrofit2.http.POST\n\n" + "// edited\ninterface OrderApi {\n" + " @GET(\"/v1/orders/{id}\")\n suspend fun getOrder(id: String): String\n" + " @POST(\"v1/orders\")\n suspend fun createOrder(body: String): String\n" + "}\n"); +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + if (legacy) { + cbm_pipeline_incremental_test_force_legacy_partial_once(); + } +#else + (void)legacy; +#endif + char *out = NULL; + if (k8s_inc_index(tmp, db_path) == 0) { + char buf[64]; + snprintf(buf, sizeof(buf), "full=%d/%d inc=%d/%d", full_calls, full_handles, + k8s_inc_count(db_path, calls), k8s_inc_count(db_path, handles)); + out = strdup(buf); + } + *route = cbm_pipeline_incremental_test_last_route(); + th_rmtree(tmp); + return out; +} + +TEST(incremental_edit_keeps_retrofit_http_calls_issue2723) { + cbm_incremental_route_t route = CBM_INCREMENTAL_ROUTE_NONE; + char *got = incremental_retrofit_keep(false, &route); + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR); + ASSERT_NOT_NULL(got); + ASSERT_STR_EQ(got, "full=2/0 inc=2/0"); + free(got); +#if defined(CBM_INCREMENTAL_TEST_API) && CBM_INCREMENTAL_TEST_API + got = incremental_retrofit_keep(true, &route); + ASSERT_EQ(route, CBM_INCREMENTAL_ROUTE_LEGACY_PARTIAL); + ASSERT_NOT_NULL(got); + ASSERT_STR_EQ(got, "full=2/0 inc=2/0"); + free(got); +#endif + PASS(); +} + /* A compose file's edit replaces its build edges: the renamed image keeps * no edge (nor node), the new one has its edge. `legacy`: through the * full-load route, else the closure-repair route. Returns "old=N new=M" @@ -31946,6 +32017,7 @@ SUITE(pipeline_semantic_manifest_repro) { RUN_TEST(incremental_script_request_to_compose_service_keeps_host); RUN_TEST(incremental_deleted_dockerfile_drops_its_image); RUN_TEST(incremental_dockerfile_edit_keeps_build_edges); + RUN_TEST(incremental_edit_keeps_retrofit_http_calls_issue2723); RUN_TEST(incremental_compose_edit_replaces_build_edges); RUN_TEST(incremental_env_edit_replaces_scoped_envvars); RUN_TEST(incremental_image_split_change_rebuilds);