diff --git a/internal/cbm/extract_channels.c b/internal/cbm/extract_channels.c index 9cedbd45d..05e145fb6 100644 --- a/internal/cbm/extract_channels.c +++ b/internal/cbm/extract_channels.c @@ -196,7 +196,13 @@ static const char *enclosing_function_qn(CBMExtractCtx *ctx, TSNode node) { if (!ts_node_is_null(name_node)) { char *name = cbm_node_text(ctx->arena, name_node, ctx->source); if (name && name[0]) { - return name; + /* #1930: return a resolvable QN — def QNs are + * module-qualified, so a bare name never matched any node + * and every channel edge silently degraded to the file + * node through find_channel_source's fallback. A miss + * (e.g. a class-nested member) still falls back exactly + * as before. */ + return cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, name); } } return NULL; @@ -625,6 +631,64 @@ static void go_process_call(CBMExtractCtx *ctx, TSNode call) { push_channel(ctx, channel_name, "websocket", direction, call); } +/* ── #1930: Go native channels ─────────────────────────────────────── + * `x <- v` and `<-x` are channel operations by GRAMMAR — no type inference is + * needed for precision, unlike the WebSocket name-heuristics above. The + * channel's v1 identity is its package-qualified tail identifier (the struct + * field or variable the operation touches): `s.out <- ev` in package p and + * `<-w.out` in another file of p join on `p…out`, which is exactly the + * producer/consumer topology trace_path could not cross before. Deliberately + * deferred: element types on the node, `go` statements (CROSS_ASYNC), and + * `for range ch` receives (range needs the operand's TYPE to know it is a + * channel — a name-shape guess here would be the #1932 anti-pattern). */ +static const char *go_chan_expr_name(CBMExtractCtx *ctx, TSNode expr) { + const char *kind = ts_node_type(expr); + if (strcmp(kind, "parenthesized_expression") == 0 && ts_node_named_child_count(expr) == 1) { + return go_chan_expr_name(ctx, ts_node_named_child(expr, 0)); + } + if (strcmp(kind, "identifier") == 0) { + return cbm_node_text(ctx->arena, expr, ctx->source); + } + if (strcmp(kind, "selector_expression") == 0) { + TSNode field = ts_node_child_by_field_name(expr, "field", 5); + if (!ts_node_is_null(field)) { + return cbm_node_text(ctx->arena, field, ctx->source); + } + } + return NULL; +} + +static void go_push_native_channel(CBMExtractCtx *ctx, TSNode site, TSNode chan_expr, + CBMChannelDirection direction) { + const char *tail = go_chan_expr_name(ctx, chan_expr); + if (!tail || !tail[0] || strcmp(tail, "_") == 0) { + return; + } + /* Package-qualify so same-named channels in different packages stay + * distinct while cross-file uses within one package join. */ + const char *qualified = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->module_qn, tail); + push_channel(ctx, qualified, "gochan", direction, site); +} + +static void go_process_native_send(CBMExtractCtx *ctx, TSNode node) { + TSNode chan_expr = ts_node_child_by_field_name(node, "channel", 7); + if (!ts_node_is_null(chan_expr)) { + go_push_native_channel(ctx, node, chan_expr, CBM_CHANNEL_EMIT); + } +} + +static void go_process_native_receive(CBMExtractCtx *ctx, TSNode node) { + TSNode op = ts_node_child_by_field_name(node, "operator", 8); + if (ts_node_is_null(op) || ts_node_end_byte(op) - ts_node_start_byte(op) != 2 || + strncmp(ctx->source + ts_node_start_byte(op), "<-", 2) != 0) { + return; + } + TSNode operand = ts_node_child_by_field_name(node, "operand", 7); + if (!ts_node_is_null(operand)) { + go_push_native_channel(ctx, node, operand, CBM_CHANNEL_LISTEN); + } +} + static void extract_channels_go(CBMExtractCtx *ctx) { TSNodeStack stack; ts_nstack_init(&stack, ctx->arena, CHAN_STACK_CAP); @@ -632,8 +696,13 @@ static void extract_channels_go(CBMExtractCtx *ctx) { while (stack.count > 0) { TSNode node = ts_nstack_pop(&stack); - if (strcmp(ts_node_type(node), "call_expression") == 0) { + const char *kind = ts_node_type(node); + if (strcmp(kind, "call_expression") == 0) { go_process_call(ctx, node); + } else if (strcmp(kind, "send_statement") == 0) { + go_process_native_send(ctx, node); + } else if (strcmp(kind, "unary_expression") == 0) { + go_process_native_receive(ctx, node); } uint32_t count = ts_node_child_count(node); for (int i = (int)count - SKIP_ONE; i >= 0; i--) { diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5b7e6a8f7..b88cc6835 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -3015,6 +3015,52 @@ TEST(go_imports) { PASS(); } +TEST(extract_go_native_channels) { + /* #1930: `x <- v` and `<-x` are channel operations by grammar — record + * them as gochan Channel emits/listens, package-qualified by tail + * identifier. Arithmetic unary minus must not be mistaken for a receive. */ + CBMFileResult *r = extract("package pipe\n" + "\n" + "type Stage struct {\n" + "\tout chan int\n" + "}\n" + "\n" + "func (s *Stage) Push(v int) {\n" + "\ts.out <- v\n" + "}\n" + "\n" + "func (s *Stage) Pull() int {\n" + "\treturn <-s.out\n" + "}\n" + "\n" + "func Neg(v int) int { return -v }\n", + CBM_LANG_GO, "t", "pipe.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + int emits = 0; + int listens = 0; + for (int i = 0; i < r->channels.count; i++) { + const CBMChannel *ch = &r->channels.items[i]; + ASSERT_NOT_NULL(ch->transport); + if (strcmp(ch->transport, "gochan") != 0) { + continue; + } + ASSERT_NOT_NULL(ch->channel_name); + size_t len = strlen(ch->channel_name); + ASSERT_TRUE(len > 4 && strcmp(ch->channel_name + len - 4, ".out") == 0); + ASSERT_NOT_NULL(ch->enclosing_func_qn); + if (ch->direction == CBM_CHANNEL_EMIT) { + emits++; + } else { + listens++; + } + } + ASSERT_EQ(emits, 1); + ASSERT_EQ(listens, 1); + cbm_free_result(r); + PASS(); +} + TEST(java_imports) { CBMFileResult *r = extract( "import java.util.List;\nimport java.util.ArrayList;\nimport static java.lang.Math.PI;\n" @@ -6699,6 +6745,7 @@ SUITE(extraction) { RUN_TEST(python_imports); RUN_TEST(js_imports); RUN_TEST(go_imports); + RUN_TEST(extract_go_native_channels); RUN_TEST(java_imports); RUN_TEST(rust_imports); RUN_TEST(c_imports); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index a45541e57..0decfa2e7 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4688,6 +4688,106 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) { PASS(); } +/* #1930: does an edge of this type run from the named function to the named + * Channel node? */ +static bool channel_edge_exists(cbm_store_t *s, const char *project, const char *func_name, + const char *channel_name, const char *edge_type) { + cbm_node_t *srcs = NULL; + cbm_node_t *tgts = NULL; + int sc = 0; + int tc = 0; + cbm_store_find_nodes_by_name(s, project, func_name, &srcs, &sc); + cbm_store_find_nodes_by_name(s, project, channel_name, &tgts, &tc); + bool found = false; + for (int i = 0; i < sc && !found; i++) { + cbm_edge_t *edges = NULL; + int ec = 0; + cbm_store_find_edges_by_source_type(s, srcs[i].id, edge_type, &edges, &ec); + for (int j = 0; j < ec && !found; j++) { + for (int k = 0; k < tc; k++) { + if (edges[j].target_id == tgts[k].id) { + found = true; + break; + } + } + } + if (edges) { + cbm_store_free_edges(edges, ec); + } + } + if (srcs) { + cbm_store_free_nodes(srcs, sc); + } + if (tgts) { + cbm_store_free_nodes(tgts, tc); + } + return found; +} + +TEST(pipeline_go_native_channel_topology) { + /* #1930: the producer/consumer topology of a Go channel pipeline — a send + * in one file, a select-receive in another file of the same package — + * must materialize as one gochan Channel node with EMITS/LISTENS_ON + * edges, so trace_path can cross the channel. RED on main: zero gochan + * Channel nodes exist. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_chan_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "go.mod", "module example.com/fxchan\n\ngo 1.22\n"); + write_temp_file(tmp, "state/state.go", + "package state\n" + "\n" + "var events = make(chan int, 8)\n" + "\n" + "func Produce(v int) {\n" + "\tevents <- v\n" + "}\n"); + write_temp_file(tmp, "state/drain.go", + "package state\n" + "\n" + "func Drain() int {\n" + "\tselect {\n" + "\tcase v := <-events:\n" + "\t\treturn v\n" + "\tdefault:\n" + "\t\treturn 0\n" + "\t}\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_chan.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); + + /* One package-qualified channel node, transport gochan. */ + char chan_name[512]; + snprintf(chan_name, sizeof(chan_name), "%s.state.events", project); + cbm_node_t *chans = NULL; + int cc = 0; + cbm_store_find_nodes_by_name(s, project, chan_name, &chans, &cc); + ASSERT_EQ(cc, 1); + ASSERT_TRUE(chans[0].label && strcmp(chans[0].label, "Channel") == 0); + ASSERT_NOT_NULL(chans[0].properties_json); + ASSERT_NOT_NULL(strstr(chans[0].properties_json, "\"transport\":\"gochan\"")); + cbm_store_free_nodes(chans, cc); + + /* Producer and consumer link the SAME node across files. */ + ASSERT_TRUE(channel_edge_exists(s, project, "Produce", chan_name, "EMITS")); + ASSERT_TRUE(channel_edge_exists(s, project, "Drain", chan_name, "LISTENS_ON")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Count nodes with the given exact name in the project (e.g. a Route path). */ static int count_nodes_named(cbm_store_t *s, const char *project, const char *name) { cbm_node_t *ns = NULL; @@ -12805,6 +12905,7 @@ SUITE(pipeline) { #endif RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_go_native_channel_topology); RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges); RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier);