From 09c4885e11ba8df308146a23c6582e11d88b12d7 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 00:05:47 +0200 Subject: [PATCH 1/7] fix(extract): a Rust generic impl's call scope drops its type arguments The def side names a Rust impl method after the implementing type with its generic arguments stripped (`impl Gen` -> `Gen`, extract_rust_impl). The call-scope QN in extract_unified.c used the impl's type text verbatim, `Gen` / `Life<'a>`. So every method of a generic or lifetime-parameterised impl had a scope QN that no node carries, and every call in its body was sourced from the File node instead of the method. Base census, 9f85f104 par (lane L7's #763 measurement): 87,855 Rust CALLS start at a File/Module node, 84,331 of them (96%) on a line inside a function span. Go is 0%. Strip the generic arguments on the call side exactly as the def side does. Plain impls, trait impls, free functions and inline modules were already consistent (checked on a fixture with the base binary). Test: extract_rust_generic_impl_call_scope_matches_def (plain, type-generic and lifetime-generic impls; def QN == call scope). Refs #763 Signed-off-by: Martin Vogel --- internal/cbm/extract_unified.c | 14 ++++++++ tests/test_extraction.c | 58 ++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 96e6ac865..ce7be6d29 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1159,6 +1159,20 @@ static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const WalkS if (!name || !name[0]) { return NULL; } + /* Rust: the impl's type text carries its generic arguments (`impl Gen`, + * `impl<'a> Parser<'a>`), which the def side strips before naming the type + * (extract_defs.c extract_rust_impl: `Buffer` -> `Buffer`). Strip them the + * same way, or every method of a generic impl gets a scope QN no node carries + * and all its calls fall back to the File node. */ + if (ctx->language == CBM_LANG_RUST && strcmp(ts_node_type(node), "impl_item") == 0) { + char *lt = strchr(name, '<'); + if (lt) { + *lt = '\0'; + } + if (!name[0]) { + return NULL; + } + } /* Nested class: prefix with the enclosing class QN (Outer.Inner) so this * scope QN matches the def-side class QN (extract_defs.c compute_class_qn / diff --git a/tests/test_extraction.c b/tests/test_extraction.c index e39d40a8a..09a8dd208 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -6886,6 +6886,63 @@ TEST(extract_perl_method_call_flags_is_method) { PASS(); } +/* The def QN of `name` and the enclosing-function QN of the call `callee` inside + * it, from one extraction result. */ +static void rust_def_and_scope(const CBMFileResult *r, const char *name, const char *callee, + const char **def_qn, const char **scope_qn) { + *def_qn = NULL; + *scope_qn = NULL; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->name && d->qualified_name && strcmp(d->name, name) == 0) { + *def_qn = d->qualified_name; + } + } + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + if (c->callee_name && strcmp(c->callee_name, callee) == 0 && c->enclosing_func_qn && + strstr(c->enclosing_func_qn, name)) { + *scope_qn = c->enclosing_func_qn; + } + } +} + +/* F-R4: a method of a generic impl (`impl Gen`, `impl<'a> Life<'a>`) got a + * call scope that kept the generic arguments ("...Gen.in_generic") while the + * def side strips them ("...Gen.in_generic"), so no node carried the scope and + * every call in the body fell back to the File node (base census: 84,331 Rust + * CALLS sourced from a File although inside a function, 96% of all File-sourced + * Rust CALLS). The def QN and the call scope must agree. */ +TEST(extract_rust_generic_impl_call_scope_matches_def) { + CBMFileResult *r = extract("pub struct Plain;\n" + "impl Plain {\n" + " pub fn in_plain(&self) -> u32 { helper() }\n" + "}\n" + "pub struct Gen { v: T }\n" + "impl Gen {\n" + " pub fn in_generic(&self) -> u32 { helper() }\n" + "}\n" + "pub struct Life<'a> { s: &'a str }\n" + "impl<'a> Life<'a> {\n" + " pub fn in_lifetime(&self) -> u32 { helper() }\n" + "}\n" + "pub fn helper() -> u32 { 1 }\n", + CBM_LANG_RUST, "t", "src/lib.rs"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + static const char *const methods[] = {"in_plain", "in_generic", "in_lifetime", NULL}; + for (int m = 0; methods[m]; m++) { + const char *def_qn = NULL; + const char *scope_qn = NULL; + rust_def_and_scope(r, methods[m], "helper", &def_qn, &scope_qn); + ASSERT_NOT_NULL(def_qn); + ASSERT_NOT_NULL(scope_qn); + ASSERT_STR_EQ(scope_qn, def_qn); + } + cbm_free_result(r); + PASS(); +} + /* Languages OUTSIDE the is_method flag set (only Perl and TS/JS/TSX set it) must * be unaffected: a Go method call never sets is_method. */ TEST(extract_flag_exempt_method_call_not_flagged_is_method) { @@ -12220,6 +12277,7 @@ SUITE(extraction) { RUN_TEST(extract_perl_config_string_not_a_callee); RUN_TEST(extract_perl_builtin_call_is_function_not_method); RUN_TEST(extract_perl_method_call_flags_is_method); + RUN_TEST(extract_rust_generic_impl_call_scope_matches_def); RUN_TEST(extract_flag_exempt_method_call_not_flagged_is_method); RUN_TEST(extract_python_member_call_flags_is_method); RUN_TEST(extract_python_bare_call_flags_locally_bound_callee); From d40f8e6be19c876167fc8d6cb794649403f98bdb Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 00:06:42 +0200 Subject: [PATCH 2/7] test(pipeline): generic Rust impl methods source their own calls (both resolvers) Sequential and parallel pipeline twin of extract_rust_generic_impl_call_scope_matches_def: the calls in a type-generic and a lifetime-generic impl method are sourced from the method, not from the File node. Refs #763 Signed-off-by: Martin Vogel --- tests/test_pipeline.c | 75 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index b52513ace..b3df58e70 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8757,6 +8757,79 @@ TEST(pipeline_go_rw_usage_never_cross_into_c) { PASS(); } +/* F-R4 (#763, Rust): calls inside a method of a generic impl (`impl Gen`, + * `impl<'a> Life<'a>`) were sourced from the File node: the call scope kept the + * generic arguments the def side strips. Each method must be the source of its + * own call. pad_files > 0 forces the parallel resolver. */ +static int rust_generic_impl_attribution_case(int pad_files) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_rust_gimpl_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "Cargo.toml", "[package]\nname = \"fx\"\nversion = \"0.1.0\"\n"); + write_temp_file(tmp, "src/lib.rs", + "pub struct Gen { v: T }\n" + "impl Gen {\n" + " pub fn in_generic(&self) -> u32 { helper_a() }\n" + "}\n" + "pub struct Life<'a> { s: &'a str }\n" + "impl<'a> Life<'a> {\n" + " pub fn in_lifetime(&self) -> u32 { helper_b() }\n" + "}\n" + "pub fn helper_a() -> u32 { 1 }\n" + "pub fn helper_b() -> u32 { 2 }\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "src/filler%d.rs", i); + snprintf(body, sizeof(body), "pub fn filler%d() -> u32 {\n %d\n}\n", i, i); + write_temp_file(tmp, name, body); + } + + char *saved = NULL; + if (pad_files > 0) { + char *old_workers = getenv("CBM_WORKERS"); + saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + } + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/rust_gimpl.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); + + ASSERT_TRUE(cross_file_call_exists(s, project, "in_generic", "helper_a")); + ASSERT_TRUE(cross_file_call_exists(s, project, "in_lifetime", "helper_b")); + /* ... and not from the file. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "lib.rs", "helper_a")); + ASSERT_FALSE(cross_file_call_exists(s, project, "lib.rs", "helper_b")); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (pad_files > 0) { + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + } + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_rust_generic_impl_method_sources_its_calls) { + return rust_generic_impl_attribution_case(0); +} + +TEST(pipeline_rust_generic_impl_method_sources_its_calls_parallel) { + return rust_generic_impl_attribution_case(52); +} + TEST(pipeline_go_rw_usage_never_cross_into_c_parallel) { /* Parallel twin of the test above: USAGE and WRITES/READS each have a * second, independent resolver in pass_parallel.c (resolve_file_usages / @@ -23615,6 +23688,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_html_embedded_member_call_stays_unbound); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); + RUN_TEST(pipeline_rust_generic_impl_method_sources_its_calls); + RUN_TEST(pipeline_rust_generic_impl_method_sources_its_calls_parallel); RUN_TEST(pipeline_rust_std_receiver_never_binds_project_method); RUN_TEST(pipeline_rust_std_receiver_never_binds_project_method_parallel); RUN_TEST(pipeline_go_bare_ref_never_binds_field); From f75af00a8b01db44d84fdeebaf493f400662d74d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 03:03:38 +0200 Subject: [PATCH 3/7] fix(rust-lsp): a generic impl's self type drops its type arguments The extractor fix above names a generic impl's methods `Gen.in_generic` on both the def and the call-scope side. The Rust LSP still built its self type from the impl's raw text, `Gen`. That broke three things: - its caller QN (`Gen.in_generic`) no longer joined the pipeline's call scope, so every LSP row inside a generic or lifetime impl was dropped for a registry guess. On 9 Rust repos that turned 1,927 lsp_* edges into suffix_match/unique_name; - `self.` and `Self::` dispatch never found the impl's own methods; `Self::assoc_g()` had no edge at all, on main as well; - the return-type harvest never matched those methods, so chained calls on them stayed untyped. Strip the generic arguments from the impl's self type in both places. Text that starts with `<` (a qualified path) is left alone. Signed-off-by: Martin Vogel --- internal/cbm/lsp/rust_lsp.c | 18 ++++++++++++++++++ tests/test_pipeline.c | 37 ++++++++++++++++++++++++++----------- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/internal/cbm/lsp/rust_lsp.c b/internal/cbm/lsp/rust_lsp.c index 44680cb69..fda3111de 100644 --- a/internal/cbm/lsp/rust_lsp.c +++ b/internal/cbm/lsp/rust_lsp.c @@ -306,6 +306,22 @@ static void rust_strip_turbofish(char *path) { *write = '\0'; } +/* In place: `Gen` -> `Gen`, `foo::Bar<'a, T>` -> `foo::Bar` (#763). An + * impl's self type keeps its generic arguments in the source text, but every + * def-side QN (a method's receiver, the extractor's call scope) strips them. + * Unstripped, the LSP's caller QN, `self`/`Self` dispatch and the return-type + * harvest never matched a generic impl's methods. Text starting with `<` (a + * qualified path) is left alone. Safe on NULL. */ +static void rust_strip_impl_type_args(char *type_text) { + if (!type_text) { + return; + } + char *lt = strchr(type_text, '<'); + if (lt && lt > type_text) { + *lt = '\0'; + } +} + /* Look up a `use` alias and return its fully-qualified module path, * or NULL if absent. The returned pointer aliases into the use map. */ static const char *rust_resolve_use(RustLSPContext *ctx, const char *local_name) { @@ -5245,6 +5261,7 @@ static void rust_process_impl(RustLSPContext *ctx, TSNode impl_node) { char *type_text = rust_node_text(ctx, type_node); if (!type_text) return; + rust_strip_impl_type_args(type_text); /* Detect blanket impl: `impl ForeignTrait for T { ... }` * where type_text is a name that appears in the impl's type @@ -5999,6 +6016,7 @@ void cbm_rust_build_local_registry(CBMArena *arena, CBMTypeRegistry *reg, CBMFil if (ts_node_is_null(type_node) || ts_node_is_null(body)) continue; char *type_name = cbm_node_text(arena, type_node, source); + rust_strip_impl_type_args(type_name); if (!type_name || !type_name[0]) continue; const char *type_qn = cbm_arena_sprintf(arena, "%s.%s", module_qn, type_name); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 0111318a2..085fa55dd 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8879,17 +8879,21 @@ static int rust_generic_impl_attribution_case(int pad_files) { FAIL("tmpdir"); } write_temp_file(tmp, "Cargo.toml", "[package]\nname = \"fx\"\nversion = \"0.1.0\"\n"); - write_temp_file(tmp, "src/lib.rs", - "pub struct Gen { v: T }\n" - "impl Gen {\n" - " pub fn in_generic(&self) -> u32 { helper_a() }\n" - "}\n" - "pub struct Life<'a> { s: &'a str }\n" - "impl<'a> Life<'a> {\n" - " pub fn in_lifetime(&self) -> u32 { helper_b() }\n" - "}\n" - "pub fn helper_a() -> u32 { 1 }\n" - "pub fn helper_b() -> u32 { 2 }\n"); + write_temp_file( + tmp, "src/lib.rs", + "pub struct Gen { v: T }\n" + "impl Gen {\n" + " pub fn in_generic(&self) -> u32 { helper_a() + self.other_g() + Self::assoc_g() }\n" + " pub fn other_g(&self) -> u32 { 3 }\n" + " pub fn assoc_g() -> u32 { 4 }\n" + "}\n" + "pub struct Life<'a> { s: &'a str }\n" + "impl<'a> Life<'a> {\n" + " pub fn in_lifetime(&self) -> u32 { helper_b() + self.other_l() }\n" + " pub fn other_l(&self) -> u32 { 5 }\n" + "}\n" + "pub fn helper_a() -> u32 { 1 }\n" + "pub fn helper_b() -> u32 { 2 }\n"); for (int i = 0; i < pad_files; i++) { char name[64]; char body[128]; @@ -8918,6 +8922,17 @@ static int rust_generic_impl_attribution_case(int pad_files) { /* ... and not from the file. */ ASSERT_FALSE(cross_file_call_exists(s, project, "lib.rs", "helper_a")); ASSERT_FALSE(cross_file_call_exists(s, project, "lib.rs", "helper_b")); + /* The LSP side names the impl's self type without its arguments too: its + * rows join these callers (lsp_*, not a registry fallback), and `self.` / + * `Self::` dispatch inside a generic impl resolves (Self::assoc_g had no + * edge at all). */ + ASSERT_TRUE( + cross_file_call_has_strategy(s, project, "in_generic", "helper_a", "\"strategy\":\"lsp_")); + ASSERT_TRUE( + cross_file_call_has_strategy(s, project, "in_generic", "other_g", "\"strategy\":\"lsp_")); + ASSERT_TRUE(cross_file_call_exists(s, project, "in_generic", "assoc_g")); + ASSERT_TRUE( + cross_file_call_has_strategy(s, project, "in_lifetime", "other_l", "\"strategy\":\"lsp_")); cbm_store_close(s); cbm_pipeline_free(p); From fed5cbf9cc0d11affde9030f640c697df3edd6dc Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 16:49:36 +0200 Subject: [PATCH 4/7] fix(extract): test the empty Rust impl name before the cut cppcheck 2.20 reports the post-cut `if (!name[0])` as always false (knownConditionTrueFalse): it cannot see that writing through `lt` may empty `name`. The name is non-empty here, so the cut empties it only when `<` is the first character. Test exactly that before the cut. The behaviour is unchanged. Signed-off-by: Martin Vogel --- internal/cbm/extract_unified.c | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index ce7be6d29..b96e60a91 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1166,12 +1166,12 @@ static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const WalkS * and all its calls fall back to the File node. */ if (ctx->language == CBM_LANG_RUST && strcmp(ts_node_type(node), "impl_item") == 0) { char *lt = strchr(name, '<'); + if (lt == name) { + return NULL; /* ``-style self type: no name to scope by */ + } if (lt) { *lt = '\0'; } - if (!name[0]) { - return NULL; - } } /* Nested class: prefix with the enclosing class QN (Outer.Inner) so this From f04df4a887edc8fa26c14573ca26373cccd06767 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 00:10:49 +0200 Subject: [PATCH 5/7] fix(pipeline): DATA_FLOWS link every caller and handler of a route create_data_flows copied a route's callers (HTTP_CALLS + ASYNC_CALLS) into a 64-slot buffer and its INFRA_MAPS handlers into a 32-slot one, each keeping the first entries in edge-insertion order. In a parallel index that order is worker interleaving, so which DATA_FLOWS existed changed between two indexes of the same tree with the same binary (thingsboard, 2026-10-17), and every caller past the 64th lost its flow outright. The pass now walks the graph buffer's own edge arrays and creates the full caller x handler product per route. A (caller, handler) pair that is reached twice keeps cbm_gbuf_insert_edge's order-free property winner, so the flow set and its properties no longer depend on the order edges were inserted in. Signed-off-by: Martin Vogel --- src/pipeline/pass_route_nodes.c | 141 +++++++++++++++----------------- 1 file changed, 65 insertions(+), 76 deletions(-) diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 9f0c57ddb..326f9e6f0 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -677,12 +677,6 @@ static void finish_data_flow_props(char *props, size_t propsz, size_t pos, } } -typedef struct { - int64_t source_id; - const char *props; - const char *edge_type; -} caller_edge_ref_t; - static bool http_call_edge_has_valid_route(const cbm_gbuf_edge_t *edge) { char url_buf[CBM_SZ_512]; const char *url = json_extract(edge->properties_json, "url_path", url_buf, sizeof(url_buf)); @@ -747,84 +741,81 @@ static int try_create_data_flow(cbm_gbuf_t *gb, int64_t caller_id, int64_t handl return SKIP_ONE; } -/* Collect extra handler IDs reachable via INFRA_MAPS chain from a route. */ -static int collect_infra_handlers(cbm_gbuf_t *gb, int64_t route_id, int64_t *out, int max_out) { - const cbm_gbuf_edge_t **infra_edges = NULL; - int infra_count = 0; - cbm_gbuf_find_edges_by_source_type(gb, route_id, "INFRA_MAPS", &infra_edges, &infra_count); +/* One route's DATA_FLOWS context: its direct handlers (handler -[HANDLES]-> route) and its + * INFRA_MAPS hops (route -[INFRA_MAPS]-> endpoint, whose handlers are reached via the endpoint). + * The edge arrays are the graph buffer's own index arrays. Inserting DATA_FLOWS never appends to a + * HANDLES, INFRA_MAPS, HTTP_CALLS or ASYNC_CALLS array, so they stay valid while flows are added. */ +typedef struct { + cbm_gbuf_t *gb; + const cbm_gbuf_node_t *route; + const cbm_gbuf_edge_t **handles; + int handles_count; + const cbm_gbuf_edge_t **infra; + int infra_count; + int flows; + int skipped; +} route_flows_t; - int n = 0; - for (int ie = 0; ie < infra_count; ie++) { - const cbm_gbuf_edge_t **ep_handles = NULL; - int ep_hcount = 0; - cbm_gbuf_find_edges_by_target_type(gb, infra_edges[ie]->target_id, "HANDLES", &ep_handles, - &ep_hcount); - for (int eh = 0; eh < ep_hcount && n < max_out; eh++) { - out[n++] = ep_handles[eh]->source_id; - } +static void route_flow_add(route_flows_t *rf, const cbm_gbuf_edge_t *caller, int64_t handler_id, + const char *edge_type, bool via_infra) { + int rc = try_create_data_flow(rf->gb, caller->source_id, handler_id, rf->route, edge_type, + caller->properties_json, via_infra); + if (rc > 0) { + rf->flows++; + } else if (rc < 0) { + rf->skipped++; } - return n; } -/* Collect caller edges (HTTP_CALLS + ASYNC_CALLS) targeting a route. */ -static int collect_caller_edges(cbm_gbuf_t *gb, int64_t route_id, caller_edge_ref_t *out, - int max_out) { - int n = 0; +/* DATA_FLOWS from one caller edge to every handler of the route, direct and via infra. */ +static void caller_data_flows(route_flows_t *rf, const cbm_gbuf_edge_t *caller, + const char *edge_type) { + for (int hi = 0; hi < rf->handles_count; hi++) { + route_flow_add(rf, caller, rf->handles[hi]->source_id, edge_type, false); + } + for (int ie = 0; ie < rf->infra_count; ie++) { + const cbm_gbuf_edge_t **ep_handles = NULL; + int ep_hcount = 0; + cbm_gbuf_find_edges_by_target_type(rf->gb, rf->infra[ie]->target_id, "HANDLES", + &ep_handles, &ep_hcount); + for (int eh = 0; eh < ep_hcount; eh++) { + route_flow_add(rf, caller, ep_handles[eh]->source_id, edge_type, true); + } + } +} + +/* Create DATA_FLOWS between EVERY caller (HTTP_CALLS + ASYNC_CALLS) and EVERY handler of one + * route node. + * + * Callers were copied into a 64-slot buffer and infra handlers into a 32-slot one, each keeping the + * first entries in edge-insertion order -- i.e. in parallel worker interleaving. Which DATA_FLOWS + * existed therefore changed between two indexes of the same tree with the same binary + * (thingsboard, 2026-10-17), and every caller past the 64th lost its flow outright. The flow set + * is now the full caller x handler product. A (caller, handler) pair reached twice -- through two + * routes, or directly and via infra -- keeps the order-free property winner of + * cbm_gbuf_insert_edge. */ +static void create_route_data_flows(cbm_gbuf_t *gb, const cbm_gbuf_node_t *route, int *flows, + int *skipped) { + route_flows_t rf = {.gb = gb, .route = route}; + cbm_gbuf_find_edges_by_target_type(gb, route->id, "HANDLES", &rf.handles, &rf.handles_count); + cbm_gbuf_find_edges_by_source_type(gb, route->id, "INFRA_MAPS", &rf.infra, &rf.infra_count); + const cbm_gbuf_edge_t **http_edges = NULL; int http_count = 0; - cbm_gbuf_find_edges_by_target_type(gb, route_id, "HTTP_CALLS", &http_edges, &http_count); - for (int i = 0; i < http_count && n < max_out; i++) { - if (!http_call_edge_has_valid_route(http_edges[i])) { - continue; + cbm_gbuf_find_edges_by_target_type(gb, route->id, "HTTP_CALLS", &http_edges, &http_count); + for (int i = 0; i < http_count; i++) { + if (http_call_edge_has_valid_route(http_edges[i])) { + caller_data_flows(&rf, http_edges[i], "HTTP_CALLS"); } - out[n].source_id = http_edges[i]->source_id; - out[n].props = http_edges[i]->properties_json; - out[n].edge_type = "HTTP_CALLS"; - n++; } const cbm_gbuf_edge_t **async_edges = NULL; int async_count = 0; - cbm_gbuf_find_edges_by_target_type(gb, route_id, "ASYNC_CALLS", &async_edges, &async_count); - for (int i = 0; i < async_count && n < max_out; i++) { - out[n].source_id = async_edges[i]->source_id; - out[n].props = async_edges[i]->properties_json; - out[n].edge_type = "ASYNC_CALLS"; - n++; - } - return n; -} - -/* Create DATA_FLOWS between callers and handlers for one route node. */ -static void create_route_data_flows(cbm_gbuf_t *gb, const cbm_gbuf_node_t *route, - const caller_edge_ref_t *callers, int n_callers, int *flows, - int *skipped) { - const cbm_gbuf_edge_t **handles_edges = NULL; - int handles_count = 0; - cbm_gbuf_find_edges_by_target_type(gb, route->id, "HANDLES", &handles_edges, &handles_count); - - int64_t extra_handlers[CBM_SZ_32]; - int n_extra = collect_infra_handlers(gb, route->id, extra_handlers, CBM_SZ_32); - - for (int ci = 0; ci < n_callers; ci++) { - for (int hi = 0; hi < handles_count; hi++) { - int rc = try_create_data_flow(gb, callers[ci].source_id, handles_edges[hi]->source_id, - route, callers[ci].edge_type, callers[ci].props, false); - if (rc > 0) { - (*flows)++; - } else if (rc < 0) { - (*skipped)++; - } - } - for (int xi = 0; xi < n_extra; xi++) { - int rc = try_create_data_flow(gb, callers[ci].source_id, extra_handlers[xi], route, - callers[ci].edge_type, callers[ci].props, true); - if (rc > 0) { - (*flows)++; - } else if (rc < 0) { - (*skipped)++; - } - } + cbm_gbuf_find_edges_by_target_type(gb, route->id, "ASYNC_CALLS", &async_edges, &async_count); + for (int i = 0; i < async_count; i++) { + caller_data_flows(&rf, async_edges[i], "ASYNC_CALLS"); } + *flows += rf.flows; + *skipped += rf.skipped; } /* Find the enclosing proto service for a function by file + line range. */ @@ -931,9 +922,7 @@ static void create_data_flows(cbm_gbuf_t *gb) { int skipped = 0; for (int ri = 0; ri < route_count; ri++) { - caller_edge_ref_t caller_edges[CBM_SZ_64]; - int n_callers = collect_caller_edges(gb, routes[ri]->id, caller_edges, CBM_SZ_64); - create_route_data_flows(gb, routes[ri], caller_edges, n_callers, &flows, &skipped); + create_route_data_flows(gb, routes[ri], &flows, &skipped); } if (flows > 0 || skipped > 0) { From 2a57f1dc08ad2c6afc86ef4a1cb77cdd708410bc Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 18:31:05 +0200 Subject: [PATCH 6/7] style: clang-format the DATA_FLOWS walk Signed-off-by: Martin Vogel --- src/pipeline/pass_route_nodes.c | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/pipeline/pass_route_nodes.c b/src/pipeline/pass_route_nodes.c index 326f9e6f0..4225f43c0 100644 --- a/src/pipeline/pass_route_nodes.c +++ b/src/pipeline/pass_route_nodes.c @@ -743,8 +743,9 @@ static int try_create_data_flow(cbm_gbuf_t *gb, int64_t caller_id, int64_t handl /* One route's DATA_FLOWS context: its direct handlers (handler -[HANDLES]-> route) and its * INFRA_MAPS hops (route -[INFRA_MAPS]-> endpoint, whose handlers are reached via the endpoint). - * The edge arrays are the graph buffer's own index arrays. Inserting DATA_FLOWS never appends to a - * HANDLES, INFRA_MAPS, HTTP_CALLS or ASYNC_CALLS array, so they stay valid while flows are added. */ + * The edge arrays are the graph buffer's own index arrays. Inserting DATA_FLOWS never appends to + * a HANDLES, INFRA_MAPS, HTTP_CALLS or ASYNC_CALLS array, so they stay valid while flows are added + * (only edge deletion rebuilds the indexes). */ typedef struct { cbm_gbuf_t *gb; const cbm_gbuf_node_t *route; @@ -776,8 +777,8 @@ static void caller_data_flows(route_flows_t *rf, const cbm_gbuf_edge_t *caller, for (int ie = 0; ie < rf->infra_count; ie++) { const cbm_gbuf_edge_t **ep_handles = NULL; int ep_hcount = 0; - cbm_gbuf_find_edges_by_target_type(rf->gb, rf->infra[ie]->target_id, "HANDLES", - &ep_handles, &ep_hcount); + cbm_gbuf_find_edges_by_target_type(rf->gb, rf->infra[ie]->target_id, "HANDLES", &ep_handles, + &ep_hcount); for (int eh = 0; eh < ep_hcount; eh++) { route_flow_add(rf, caller, ep_handles[eh]->source_id, edge_type, true); } From 7c5112953f8a7714cc72023747848f73c4e11354 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 18:31:05 +0200 Subject: [PATCH 7/7] test(pipeline): DATA_FLOWS reach every caller and infra handler of a route A route with 70 callers must link all 70 to its handler, a route whose INFRA_MAPS endpoint has 40 handlers must link its caller to all 40, and the flow set must not change when the callers' HTTP_CALLS edges are inserted in reverse order. Before the fix the first 64 callers and the first 32 infra handlers in insertion order were kept, so all three fail. Signed-off-by: Martin Vogel --- tests/test_infrascan.c | 95 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/tests/test_infrascan.c b/tests/test_infrascan.c index e744d621e..33c272198 100644 --- a/tests/test_infrascan.c +++ b/tests/test_infrascan.c @@ -385,6 +385,98 @@ TEST(infrascan_http_calls_join_matching_handler_route) { PASS(); } +/* FD16: a route's DATA_FLOWS were built from the first 64 callers and the first 32 infra handlers + * in edge-insertion order (parallel worker interleaving), so the flow set changed between two + * indexes of the same tree and every caller past the 64th lost its flow. */ +enum { FD16_CALLERS = 70, FD16_INFRA_HANDLERS = 40 }; + +static int64_t fd16_route_with_callers(cbm_gbuf_t *gb, int64_t *callers, bool reverse) { + int64_t route = cbm_gbuf_upsert_node(gb, "Route", "/api/orders", "__route__GET__/api/orders", + "", 0, 0, "{\"method\":\"GET\"}"); + int64_t handler = cbm_gbuf_upsert_node(gb, "Function", "get_orders", "test.get_orders", + "server.py", 1, 3, "{}"); + cbm_gbuf_insert_edge(gb, handler, route, "HANDLES", "{\"handler\":\"test.get_orders\"}"); + for (int i = 0; i < FD16_CALLERS; i++) { + char name[32]; + char qn[48]; + snprintf(name, sizeof(name), "client_%02d", i); + snprintf(qn, sizeof(qn), "test.client_%02d", i); + callers[i] = + cbm_gbuf_upsert_node(gb, "Function", name, qn, "client.py", i + 1, i + 1, "{}"); + } + for (int k = 0; k < FD16_CALLERS; k++) { + int i = reverse ? FD16_CALLERS - 1 - k : k; + cbm_gbuf_insert_edge(gb, callers[i], route, "HTTP_CALLS", + "{\"callee\":\"requests.get\",\"url_path\":\"/api/orders\"," + "\"method\":\"GET\"}"); + } + return handler; +} + +TEST(infrascan_data_flows_link_every_caller_of_a_route) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_fd16_callers"); + ASSERT_NOT_NULL(gb); + int64_t callers[FD16_CALLERS]; + int64_t handler = fd16_route_with_callers(gb, callers, false); + cbm_pipeline_create_route_nodes(gb); + for (int i = 0; i < FD16_CALLERS; i++) { + ASSERT_TRUE(has_data_flow(gb, callers[i], handler)); + } + cbm_gbuf_free(gb); + PASS(); +} + +TEST(infrascan_data_flows_do_not_depend_on_caller_insertion_order) { + cbm_gbuf_t *fwd = cbm_gbuf_new("test", "/tmp/cbm_infrascan_fd16_fwd"); + cbm_gbuf_t *rev = cbm_gbuf_new("test", "/tmp/cbm_infrascan_fd16_rev"); + ASSERT_NOT_NULL(fwd); + ASSERT_NOT_NULL(rev); + int64_t fwd_callers[FD16_CALLERS]; + int64_t rev_callers[FD16_CALLERS]; + int64_t fwd_handler = fd16_route_with_callers(fwd, fwd_callers, false); + int64_t rev_handler = fd16_route_with_callers(rev, rev_callers, true); + cbm_pipeline_create_route_nodes(fwd); + cbm_pipeline_create_route_nodes(rev); + for (int i = 0; i < FD16_CALLERS; i++) { + ASSERT_EQ(has_data_flow(fwd, fwd_callers[i], fwd_handler), + has_data_flow(rev, rev_callers[i], rev_handler)); + } + cbm_gbuf_free(fwd); + cbm_gbuf_free(rev); + PASS(); +} + +TEST(infrascan_data_flows_reach_every_infra_handler) { + cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp/cbm_infrascan_fd16_infra"); + ASSERT_NOT_NULL(gb); + int64_t route = cbm_gbuf_upsert_node(gb, "Route", "/api/orders", "__route__GET__/api/orders", + "", 0, 0, "{\"method\":\"GET\"}"); + int64_t endpoint = cbm_gbuf_upsert_node(gb, "Route", "/orders", "__route__GET__/orders", "", 0, + 0, "{\"method\":\"GET\"}"); + int64_t client = + cbm_gbuf_upsert_node(gb, "Function", "client", "test.client", "client.py", 1, 3, "{}"); + cbm_gbuf_insert_edge(gb, route, endpoint, "INFRA_MAPS", "{}"); + cbm_gbuf_insert_edge(gb, client, route, "HTTP_CALLS", + "{\"callee\":\"requests.get\",\"url_path\":\"/api/orders\"," + "\"method\":\"GET\"}"); + int64_t handlers[FD16_INFRA_HANDLERS]; + for (int i = 0; i < FD16_INFRA_HANDLERS; i++) { + char name[32]; + char qn[48]; + snprintf(name, sizeof(name), "orders_%02d", i); + snprintf(qn, sizeof(qn), "test.orders_%02d", i); + handlers[i] = + cbm_gbuf_upsert_node(gb, "Function", name, qn, "server.py", i + 1, i + 1, "{}"); + cbm_gbuf_insert_edge(gb, handlers[i], endpoint, "HANDLES", "{}"); + } + cbm_pipeline_create_route_nodes(gb); + for (int i = 0; i < FD16_INFRA_HANDLERS; i++) { + ASSERT_TRUE(has_data_flow(gb, client, handlers[i])); + } + cbm_gbuf_free(gb); + PASS(); +} + SUITE(infrascan) { RUN_TEST(infrascan_http_route_literal_guard_rejects_filesystem_paths); RUN_TEST(infrascan_http_route_literal_guard_rejects_document_extensions); @@ -393,4 +485,7 @@ SUITE(infrascan) { RUN_TEST(infrascan_service_pattern_match_keeps_real_library_qns); RUN_TEST(infrascan_route_nodes_skip_bad_http_url_paths); RUN_TEST(infrascan_http_calls_join_matching_handler_route); + RUN_TEST(infrascan_data_flows_link_every_caller_of_a_route); + RUN_TEST(infrascan_data_flows_do_not_depend_on_caller_insertion_order); + RUN_TEST(infrascan_data_flows_reach_every_infra_handler); }