Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 71 additions & 2 deletions internal/cbm/extract_channels.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -625,15 +631,78 @@ 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);
ts_nstack_push(&stack, ctx->arena, ctx->root);

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--) {
Expand Down
47 changes: 47 additions & 0 deletions tests/test_extraction.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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);
Expand Down
101 changes: 101 additions & 0 deletions tests/test_pipeline.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading