From d6417ada0ec5bb144c0694d076fc504b6fa14e06 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sun, 30 Aug 2026 14:04:18 +0200 Subject: [PATCH 1/5] fix(pipeline): guard USAGE/WRITES/READS against cross-language binds USAGE, WRITES and READS edges resolve through the same short-name registry as CALLS but never consulted the #725 cross-language guard. On a Go tree with eBPF C probes every Go identifier spelled like a C one produced a reference edge into the C file: 31.5% of all WRITES on the originally measured repo crossed the Go->C boundary, led by dozens of Go test locals named event writing a C probe's automatic variable. Add cbm_suppress_cross_language_ref() - the reference-edge analog of cbm_suppress_cross_language_suffix_match - and consult it on BOTH resolvers of each edge type: the sequential pass (pass_usages.c resolve_usage_edges registry-fallback branch, resolve_rw_edges) and their parallel twins (pass_parallel.c resolve_file_usages, resolve_file_rw). The sequential-only version of this change left 344 Go->C WRITES alive on a ~1150-file repo because large repos resolve through pass_parallel.c - the field census caught it, and the parallel-twin test now pins it. Unlike the CALLS guard the predicate takes no strategy parameter: a reference edge carries no import-closure evidence, so every registry strategy is a bare-name guess across a boundary. LSP-backed semantic references resolve before the fallback and are unaffected. JS/TS stay one family, and C/C++ count as one family too (.h maps to CBM_LANG_CPP, so a .c file referencing its own header is not a boundary). Field-validated on the ~1150-file Go+C repo: Go->C/C++ WRITES 835->0, USAGE 1545->0; C->Go 15/90->0; 6432 reference edges dropped in total, every one cross-language (Go->.json 3034, Go->.hpp/.h 1612, Go->.c 746, Go->.sh/.yaml/.yml 443, ...) and none same-language. CALLS and IMPORTS totals are byte-identical to main. Fixes #1928 Signed-off-by: Ilya Brykau --- src/pipeline/pass_parallel.c | 16 +++- src/pipeline/pass_usages.c | 16 +++- src/pipeline/pipeline.h | 8 ++ src/pipeline/registry.c | 38 +++++++++ tests/test_pipeline.c | 152 ++++++++++++++++++++++++++++++++++- tests/test_registry.c | 29 +++++++ 6 files changed, 252 insertions(+), 7 deletions(-) diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 1eeb55f83..a642fb142 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2654,6 +2654,12 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, continue; } tgt = cbm_gbuf_find_by_qn(rc->main_gbuf, res.qualified_name); + /* #1928: the registry fallback is a bare-name guess — never let it + * bind a reference across a language boundary. Mirrors the + * sequential twin (pass_usages.c). */ + if (tgt && cbm_suppress_cross_language_ref(lang, tgt->file_path)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -2713,7 +2719,7 @@ static void resolve_file_throws(resolve_ctx_t *rc, resolve_worker_state_t *ws, /* Resolve reads/writes for one file. */ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFileResult *result, const char *rel, const char *module_qn, const char **imp_keys, - const char **imp_vals, int imp_count) { + const char **imp_vals, int imp_count, CBMLanguage lang) { for (int r = 0; r < result->rw.count; r++) { CBMReadWrite *rw = &result->rw.items[r]; if (!rw->var_name) { @@ -2733,6 +2739,12 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi if (!tgt || src->id == tgt->id) { continue; } + /* #1928: every resolution here is a bare-name registry guess — never + * let it bind a read/write across a language boundary. Mirrors the + * sequential twin (pass_usages.c). */ + if (cbm_suppress_cross_language_ref(lang, tgt->file_path)) { + continue; + } const char *etype = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}"); } @@ -3179,7 +3191,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { /* ── READS / WRITES ────────────────────────────────────── */ _ph_t0 = extract_now_ns(); - resolve_file_rw(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count); + resolve_file_rw(rc, ws, result, rel, module_qn, imp_keys, imp_vals, imp_count, lang); atomic_fetch_add_explicit(&rc->time_ns_rw, extract_now_ns() - _ph_t0, memory_order_relaxed); /* ── INHERITS + DECORATES + IMPLEMENTS ──────────────────── */ diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index 31219ba68..755273b5f 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -210,6 +210,12 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res continue; } tgt = cbm_gbuf_find_by_qn(ctx->gbuf, res.qualified_name); + /* #1928: the registry fallback is a bare-name guess — never let it + * bind a reference across a language boundary (the LSP-backed + * semantic branch above is not affected). */ + if (tgt && cbm_suppress_cross_language_ref(lang, tgt->file_path)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -270,7 +276,7 @@ static int resolve_throw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res /* Resolve READS/WRITES edges for one file's extracted read/write accesses. */ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result, const char *rel, const char *module_qn, const char **imp_keys, const char **imp_vals, - int imp_count) { + int imp_count, CBMLanguage lang) { int resolved = 0; for (int r = 0; r < result->rw.count; r++) { CBMReadWrite *rw = &result->rw.items[r]; @@ -293,6 +299,11 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result if (!tgt || src->id == tgt->id) { continue; } + /* #1928: every resolution here is a bare-name registry guess — never + * let it bind a read/write across a language boundary. */ + if (cbm_suppress_cross_language_ref(lang, tgt->file_path)) { + continue; + } const char *edge_type = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}"); @@ -359,7 +370,8 @@ int cbm_pipeline_pass_usages(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *fil imp_count, files[i].language); throw_resolved += resolve_throw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count); - rw_resolved += resolve_rw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count); + rw_resolved += resolve_rw_edges(ctx, result, rel, module_qn, imp_keys, imp_vals, imp_count, + files[i].language); free(module_qn); free_import_map(imp_keys, imp_vals, imp_count); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 4b1d15563..c85e6ff32 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -288,6 +288,14 @@ bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *st bool cbm_suppress_cross_language_suffix_match(CBMLanguage caller_lang, const char *target_file_path, const char *strategy); +/* #1928: USAGE/WRITES/READS analog of the CALLS guard above. Reference edges + * resolved by the short-name registry carry no import-closure evidence, so a + * cross-language binding is a bare-name collision for EVERY strategy — drop + * it whenever the caller's language and the target file's language disagree + * (JS/TS family members and the C/C++ header family excepted). Pure; + * unit-tested in test_registry.c. */ +bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target_file_path); + /* Get the label of a qualified name, or NULL if not found. */ const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 5126bcbfe..da6e69b04 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -469,6 +469,13 @@ static bool js_ts_family(CBMLanguage lang) { lang == CBM_LANG_ARKTS; } +/* C and C++ are one family for cross-language checks: .h maps to CBM_LANG_CPP + * in the extension table, so a .c file referencing a symbol declared in its + * own header would otherwise read as a language boundary. */ +static bool c_cpp_family(CBMLanguage lang) { + return lang == CBM_LANG_C || lang == CBM_LANG_CPP; +} + static const char *path_basename(const char *path) { if (!path || !path[0]) { return path; @@ -508,6 +515,37 @@ bool cbm_suppress_cross_language_suffix_match(CBMLanguage caller_lang, const cha return true; } +bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target_file_path) { + /* #1928: USAGE / WRITES / READS analog of the CALLS guard above. A + * variable or field reference resolved by the short-name registry must + * not cross a language boundary: unlike CALLS, a reference edge carries + * no import-closure evidence at all — a Go test's local `event` and an + * eBPF C probe's automatic `event` share nothing but the spelling, so + * EVERY registry strategy is a bare-name guess here and none is exempt. + * LSP-backed semantic references resolve before the registry fallback + * and never reach this predicate, which is where a genuine cross-language + * binding (a future cgo resolver) would live. The JS/TS family keeps its + * exemption (.js/.ts/.d.ts pairs legitimately share symbols), and C/C++ + * count as one family (.h maps to CBM_LANG_CPP). */ + if (caller_lang == CBM_LANG_COUNT || !target_file_path || !target_file_path[0]) { + return false; + } + CBMLanguage target_lang = cbm_language_for_filename(path_basename(target_file_path)); + if (target_lang == CBM_LANG_COUNT) { + return false; + } + if (caller_lang == target_lang) { + return false; + } + if (js_ts_family(caller_lang) && js_ts_family(target_lang)) { + return false; + } + if (c_cpp_family(caller_lang) && c_cpp_family(target_lang)) { + return false; + } + return true; +} + /* ── Lifecycle ──────────────────────────────────────────────────── */ cbm_registry_t *cbm_registry_new(void) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index a45541e57..acc5d8879 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -824,8 +824,8 @@ TEST(pipeline_calls_resolution) { /* True iff a CALLS edge exists from a node named src_name to a node named * tgt_name. Used to assert cross-file call resolution survives a reindex. */ -static bool cross_file_call_exists(cbm_store_t *s, const char *project, const char *src_name, - const char *tgt_name) { +static bool cross_file_edge_exists(cbm_store_t *s, const char *project, const char *src_name, + const char *tgt_name, const char *edge_type) { cbm_node_t *srcs = NULL; cbm_node_t *tgts = NULL; int sc = 0; @@ -836,7 +836,7 @@ static bool cross_file_call_exists(cbm_store_t *s, const char *project, const ch 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, "CALLS", &edges, &ec); + 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) { @@ -858,6 +858,11 @@ static bool cross_file_call_exists(cbm_store_t *s, const char *project, const ch return found; } +static bool cross_file_call_exists(cbm_store_t *s, const char *project, const char *src_name, + const char *tgt_name) { + return cross_file_edge_exists(s, project, src_name, tgt_name, "CALLS"); +} + /* True iff the exact named CALLS edge exists and its serialized strategy * contains `strategy_fragment`. Parallel synthetic-carrier regressions use * this on a separate ordinary-call control: it proves the cross-file LSP ran @@ -4688,6 +4693,145 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) { PASS(); } +/* Fixture for the #1928 cross-language reference-guard probes (sequential and + * parallel twins). pad_files > 0 adds filler files to push the index over the + * parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per + * path and both must consult the guard. */ +static void write_go_c_ref_guard_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "go.mod", "module example.com/fxguard\n\ngo 1.22\n"); + /* The C probe: a local named `event` and a file-scope function `handle`, + * the two shapes Go identifiers collide with. */ + write_temp_file(tmp, "probe/probe.c", + "static int total_events = 0;\n" + "\n" + "static int handle(void) {\n" + " int event = 0;\n" + " total_events += event;\n" + " return event;\n" + "}\n"); + /* Go: a local write named like the C local, and a value use named like + * the C function. Neither can touch anything in a C translation unit. */ + write_temp_file(tmp, "app/app.go", + "package app\n" + "\n" + "func TrackEvent() int {\n" + "\tevent := 1\n" + "\treturn event\n" + "}\n" + "\n" + "func UsesHandle() int {\n" + "\th := handle\n" + "\t_ = h\n" + "\treturn 4\n" + "}\n" + "\n" + "func WriteTotal() {\n" + "\ttotal_events := 5\n" + "\t_ = total_events\n" + "}\n"); + /* Same-language control: a Go package-level var written from another + * file must keep its WRITES edge. */ + write_temp_file(tmp, "state/vars.go", + "package state\n" + "\n" + "var Counter int\n"); + write_temp_file(tmp, "state/state.go", + "package state\n" + "\n" + "func Bump() {\n" + "\tCounter = 2\n" + "}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +TEST(pipeline_go_rw_usage_never_cross_into_c) { + /* #1928: USAGE and WRITES/READS resolve through the same short-name + * registry as CALLS but never consulted the #725 cross-language guard. + * On a Go tree with eBPF C probes every Go identifier spelled like a C + * one produced an edge into the C file — 31.5% of all WRITES on the + * measured repo crossed the Go/C boundary. Go code cannot write a C + * automatic variable, so every edge in that class is false. This is the + * SEQUENTIAL-path twin; the parallel twin follows. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_rw_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_c_ref_guard_fixture(tmp, 0); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_rw.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); + + /* Reproduce-first: RED before the fix — the Go local write binds the C + * probe's global, and the Go value use binds the C `handle`. */ + ASSERT_FALSE(cross_file_edge_exists(s, project, "TrackEvent", "event", "WRITES")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "TrackEvent", "event", "READS")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "WRITES")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "READS")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "USAGE")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "WriteTotal", "total_events", "WRITES")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "WriteTotal", "total_events", "USAGE")); + /* Same-language reference edges survive the guard. */ + ASSERT_TRUE(cross_file_edge_exists(s, project, "Bump", "Counter", "WRITES")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +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 / + * resolve_file_rw) that must consult the same guard — the field census + * that motivated this caught the sequential-only fix leaving 344 Go→C + * WRITES alive on a ~1150-file repo. >= 50 files forces the parallel + * pipeline. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_rwp_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_c_ref_guard_fixture(tmp, 52); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_rwp.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_FALSE(cross_file_edge_exists(s, project, "TrackEvent", "event", "WRITES")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "TrackEvent", "event", "READS")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "WRITES")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "READS")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "UsesHandle", "handle", "USAGE")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "WriteTotal", "total_events", "WRITES")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "WriteTotal", "total_events", "USAGE")); + ASSERT_TRUE(cross_file_edge_exists(s, project, "Bump", "Counter", "WRITES")); + + 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 +12949,8 @@ 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_rw_usage_never_cross_into_c); + RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); 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); diff --git a/tests/test_registry.c b/tests/test_registry.c index ff81a50ee..b4de91280 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -811,6 +811,34 @@ TEST(cross_language_suffix_match_drops_py_vs_js) { PASS(); } +TEST(cross_language_ref_drops_go_vs_c) { + /* #1928: the USAGE/WRITES/READS analog of #725. Reference edges carry no + * import-closure evidence, so EVERY registry strategy is a bare-name + * guess and no strategy-level carve-out applies — the predicate takes no + * strategy at all. */ + ASSERT_TRUE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "bpf/probe.c")); + ASSERT_TRUE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "driver/mock.hpp")); + ASSERT_TRUE(cbm_suppress_cross_language_ref(CBM_LANG_C, "pkg/events/event.go")); + ASSERT_TRUE(cbm_suppress_cross_language_ref(CBM_LANG_PYTHON, "web/src/pages/Editor.js")); + /* Same language → keep. */ + ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "pkg/state/state.go")); + /* C and C++ are one family: .h maps to CBM_LANG_CPP, and a .c file + * referencing its own header's declarations is not a boundary. */ + ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_C, "bpf/probe.h")); + ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_CPP, "driver/compat.c")); + /* …but Go into a header is still a boundary. */ + ASSERT_TRUE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "bpf/probe.h")); + /* JS/TS/TSX/ArkTS are one family. */ + ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_JAVASCRIPT, "lib/util.ts")); + ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_TYPESCRIPT, "ui/Panel.tsx")); + /* Unknown caller/target language or no path → nothing to judge → keep. */ + ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_COUNT, "store.py")); + ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_GO, NULL)); + ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "")); + ASSERT_FALSE(cbm_suppress_cross_language_ref(CBM_LANG_GO, "Makefile.inc.unknownext")); + PASS(); +} + TEST(dynamic_suppress_drops_weak_method_matches) { /* #592/#606/#1276: a member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -945,6 +973,7 @@ SUITE(registry) { RUN_TEST(perl_suppress_drops_weak_builtin_and_method_matches); RUN_TEST(perl_suppress_keeps_high_confidence_and_genuine_calls); RUN_TEST(cross_language_suffix_match_drops_py_vs_js); + RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); } From 47116b8e198808399a791cb5130646951677ba23 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sun, 30 Aug 2026 12:57:41 +0200 Subject: [PATCH 2/5] fix(extract): descend into Go struct field_declaration_list Signed-off-by: Ilya Brykau --- internal/cbm/extract_defs.c | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 15f96ba17..7bb6ec955 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -4780,6 +4780,20 @@ static TSNode find_class_member_body(TSNode class_node, CBMLanguage lang) { return ts_node_is_null(declarations) ? body : declarations; } +/* Go structs keep their field_declaration nodes one level below the body that + * find_class_body() returns: type_spec's `type` child is a struct_type whose + * only named child is a field_declaration_list. Interfaces need no such step -- + * interface_type holds its method specs directly, which is why interface members + * extracted correctly while every struct field was silently skipped. Normalize + * here, the same way the Java enum_body_declarations step above does. */ +static TSNode go_normalize_struct_body(TSNode body) { + if (ts_node_is_null(body) || strcmp(ts_node_type(body), "struct_type") != 0) { + return body; + } + TSNode list = cbm_find_child_by_kind(body, "field_declaration_list"); + return ts_node_is_null(list) ? body : list; +} + // Dart: resolve method name from method_signature/function_signature. static TSNode resolve_dart_method_name(TSNode child, const char *ck) { if (strcmp(ck, "method_signature") == 0) { @@ -6656,6 +6670,9 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch } TSNode body = find_class_member_body(class_node, ctx->language); + if (ctx->language == CBM_LANG_GO) { + body = go_normalize_struct_body(body); + } if (ts_node_is_null(body)) { return; } From cb7cb444126f8ec4542ecf5daef8188470e48327 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sun, 30 Aug 2026 13:04:24 +0200 Subject: [PATCH 3/5] fix(extract): skip Go blank-identifier struct fields Signed-off-by: Ilya Brykau --- internal/cbm/extract_defs.c | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 7bb6ec955..2e359af67 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -6962,6 +6962,13 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch continue; } + /* Go: `_` is the blank identifier, used for explicit struct padding in + * generated code. It is not a referenceable field, and emitting it gives + * every `_` in the repository a same-named node to collide with. */ + if (ctx->language == CBM_LANG_GO && strcmp(name, "_") == 0) { + continue; + } + const char *field_qn = cbm_arena_sprintf(a, "%s.%s", class_qn, name); CBMDefinition def; From e07a12b6690980cddc64cab8990ddd3831d88997 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sun, 30 Aug 2026 14:26:11 +0200 Subject: [PATCH 4/5] test(extract): pin Go struct field extraction and blank-identifier skip Reproduce-first probe for the two fixes on this branch: RED without the descend fix (count_defs_with_label(r, "Field") == 0, expected 3), GREEN with it. Asserts the three named fields with their declared types in return_type, the absence of the blank identifier, and that interface members keep extracting exactly as before. Signed-off-by: Ilya Brykau --- tests/test_extraction.c | 54 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5b7e6a8f7..5b8d16f61 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -3015,6 +3015,59 @@ TEST(go_imports) { PASS(); } +/* #1935: Go struct fields were never extracted — find_class_body() returns the + * struct_type node, whose only named child is a field_declaration_list, so the + * member loop matched nothing and every field was silently skipped (0 Field + * nodes for ~5055 declarations on the measured repo). Interfaces hold their + * method specs directly and always worked. The blank identifier `_` is struct + * padding, not a referenceable field, and must stay out (241 collision edges + * on two generated structs otherwise). */ +TEST(extract_go_struct_fields_have_nodes) { + CBMFileResult *r = extract("package fxf\n\n" + "type Config struct {\n" + "\tName string\n" + "\tTimeout int\n" + "\tNested *Config\n" + "\t_ [8]byte\n" + "}\n\n" + "type Reader interface {\n" + "\tRead(p []byte) (int, error)\n" + "\tClose() error\n" + "}\n", + CBM_LANG_GO, "t", "cfg.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + /* RED before the descend fix: count is 0. The blank identifier must not + * bring it to 4. */ + ASSERT_EQ(count_defs_with_label(r, "Field"), 3); + ASSERT_TRUE(has_def(r, "Field", "Name")); + ASSERT_TRUE(has_def(r, "Field", "Timeout")); + ASSERT_TRUE(has_def(r, "Field", "Nested")); + ASSERT_FALSE(has_def(r, "Field", "_")); + /* Each field carries its declared type in return_type. */ + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (!d->label || strcmp(d->label, "Field") != 0) { + continue; + } + ASSERT_NOT_NULL(d->return_type); + if (strcmp(d->name, "Name") == 0) { + ASSERT_TRUE(strcmp(d->return_type, "string") == 0); + } + if (strcmp(d->name, "Timeout") == 0) { + ASSERT_TRUE(strcmp(d->return_type, "int") == 0); + } + if (strcmp(d->name, "Nested") == 0) { + ASSERT_TRUE(strcmp(d->return_type, "*Config") == 0); + } + } + /* Interface members keep extracting exactly as before. */ + ASSERT_TRUE(has_def(r, "Method", "Read")); + ASSERT_TRUE(has_def(r, "Method", "Close")); + 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 +6752,7 @@ SUITE(extraction) { RUN_TEST(python_imports); RUN_TEST(js_imports); RUN_TEST(go_imports); + RUN_TEST(extract_go_struct_fields_have_nodes); RUN_TEST(java_imports); RUN_TEST(rust_imports); RUN_TEST(c_imports); From c175ca184d9466d4bc06d1c8da7b835627c912df Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sun, 30 Aug 2026 15:21:53 +0200 Subject: [PATCH 5/5] fix(pipeline): a bare Go reference never binds a struct Field The READS/WRITES resolvers and the USAGE registry fallback hand bare reference text to the short-name registry, which contains Field nodes - so once Go struct fields exist (#1935), every Go local err := ... binds whichever struct field is named err, project-wide: 21308 USAGE and 5191 WRITES onto Go fields on the measured repo, top target a test struct's field T collecting 3013 edges. In Go that binding is impossible by construction: a field is only reachable through a selector expression (x.f), and selector references resolve on the LSP path - every Field-targeted reference edge in the census carried dot-less text. Add cbm_go_suppress_bare_field_ref() next to the #1928 predicate and consult it at the same four sites (both READS/WRITES resolvers, both USAGE registry fallbacks): drop the bind when the file is Go, the target label is Field, and the reference text has no '.'. Go-gated because C#/Java/C++/Python method bodies legitimately reference their own members bare (cp_reads_writes_cs_static_field pins that shape). Field-validated on the #1940 stack: USAGE onto Go fields 21308 -> 0, WRITES 5191 -> 0; the only remaining field-targeted edges are 2466 CALLS, which are #1906/#1907's selector-guard territory. Reproduce- first pipeline probes (sequential + parallel twins) were RED on the stack without this commit. Fixes #1942 Signed-off-by: Ilya Brykau --- src/pipeline/pass_parallel.c | 9 +++ src/pipeline/pass_usages.c | 9 +++ src/pipeline/pipeline.h | 8 +++ src/pipeline/registry.c | 18 ++++++ tests/test_pipeline.c | 104 +++++++++++++++++++++++++++++++++++ tests/test_registry.c | 21 +++++++ 6 files changed, 169 insertions(+) diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index a642fb142..13e395a04 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2660,6 +2660,11 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (tgt && cbm_suppress_cross_language_ref(lang, tgt->file_path)) { continue; } + /* #1942: a bare Go reference can never denote a struct field. */ + if (tgt && + cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, usage->ref_name, tgt->label)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -2745,6 +2750,10 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi if (cbm_suppress_cross_language_ref(lang, tgt->file_path)) { continue; } + /* #1942: a bare Go reference can never denote a struct field. */ + if (cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, rw->var_name, tgt->label)) { + continue; + } const char *etype = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}"); } diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index 755273b5f..690b45ad9 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -216,6 +216,11 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res if (tgt && cbm_suppress_cross_language_ref(lang, tgt->file_path)) { continue; } + /* #1942: a bare Go reference can never denote a struct field. */ + if (tgt && + cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, usage->ref_name, tgt->label)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -304,6 +309,10 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result if (cbm_suppress_cross_language_ref(lang, tgt->file_path)) { continue; } + /* #1942: a bare Go reference can never denote a struct field. */ + if (cbm_go_suppress_bare_field_ref(lang == CBM_LANG_GO, rw->var_name, tgt->label)) { + continue; + } const char *edge_type = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}"); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index c85e6ff32..d174bb6d6 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -296,6 +296,14 @@ bool cbm_suppress_cross_language_suffix_match(CBMLanguage caller_lang, const cha * unit-tested in test_registry.c. */ bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target_file_path); +/* #1942: a bare (dot-less) Go reference can never denote a struct field — + * field access is always a selector expression, and selector references + * resolve on the LSP path. Drops a READS/WRITES/USAGE bind whose target is a + * Field when the reference text carries no '.'. Go only: other OO languages + * legitimately reference their own members bare inside method bodies. Pure; + * unit-tested in test_registry.c. */ +bool cbm_go_suppress_bare_field_ref(bool is_go, const char *ref_name, const char *target_label); + /* Get the label of a qualified name, or NULL if not found. */ const char *cbm_registry_label_of(const cbm_registry_t *r, const char *qn); diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index da6e69b04..eefdb7596 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -546,6 +546,24 @@ bool cbm_suppress_cross_language_ref(CBMLanguage caller_lang, const char *target return true; } +bool cbm_go_suppress_bare_field_ref(bool is_go, const char *ref_name, const char *target_label) { + /* #1942: a bare (dot-less) Go reference can never denote a struct field — + * field access is always a selector expression (x.f), and selector + * references resolve through the LSP join, never through the bare-name + * registry fallback. Every Field-targeted reference edge in the field + * census carried dot-less text, so dropping the bind loses nothing real. + * Go-gated: a C#/Java/C++/Python method body legitimately references its + * own members bare (cp_reads_writes_cs_static_field pins that shape as + * required), so a global veto would break those languages. */ + if (!is_go || !ref_name || !ref_name[0] || !target_label) { + return false; + } + if (strcmp(target_label, "Field") != 0) { + return false; + } + return strchr(ref_name, '.') == NULL; +} + /* ── Lifecycle ──────────────────────────────────────────────────── */ cbm_registry_t *cbm_registry_new(void) { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index acc5d8879..8e27a5d5d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4832,6 +4832,108 @@ TEST(pipeline_go_rw_usage_never_cross_into_c_parallel) { PASS(); } +static int count_nodes_named(cbm_store_t *s, const char *project, const char *name); + +/* Fixture for the #1942 bare-reference-vs-Field probes: a Go struct field + * named like the commonest local (`err`), and a function whose local of the + * same name must NOT bind it — in Go a field is only reachable through a + * selector expression, never a bare identifier. Needs Go Field extraction + * (#1935) to have anything to falsely bind. */ +static void write_go_bare_field_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "go.mod", "module example.com/fxbare\n\ngo 1.22\n"); + write_temp_file(tmp, "state/state.go", + "package state\n" + "\n" + "type Tracker struct {\n" + "\terr error\n" + "\tn int\n" + "}\n"); + write_temp_file(tmp, "app/app.go", + "package app\n" + "\n" + "import \"errors\"\n" + "\n" + "func Run() error {\n" + "\terr := errors.New(\"x\")\n" + "\treturn err\n" + "}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +TEST(pipeline_go_bare_ref_never_binds_field) { + /* #1942: the READS/WRITES resolvers and the USAGE registry fallback hand + * bare reference text to the short-name registry, which contains Field + * nodes — so every Go local `err := …` bound whichever struct field was + * named err (21308 USAGE / 5191 WRITES onto Go fields on the measured + * repo, top target a test struct's field collecting 3013 edges). + * Sequential-path twin; the parallel twin follows. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_bare_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_bare_field_fixture(tmp, 0); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_bare.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); + + /* The field must exist for the probe to mean anything (#1935's fix). */ + ASSERT_TRUE(count_nodes_named(s, project, "err") >= 1); + /* Reproduce-first: RED before the fix — the bare local binds the field. */ + ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "WRITES")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "READS")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "USAGE")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_go_bare_ref_never_binds_field_parallel) { + /* Parallel twin: resolve_file_rw / resolve_file_usages are independent + * resolvers and must consult the same predicate (#1928's lesson). */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_barep_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_bare_field_fixture(tmp, 52); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_barep.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(count_nodes_named(s, project, "err") >= 1); + ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "WRITES")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "READS")); + ASSERT_FALSE(cross_file_edge_exists(s, project, "Run", "err", "USAGE")); + + 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; @@ -12951,6 +13053,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); + RUN_TEST(pipeline_go_bare_ref_never_binds_field); + RUN_TEST(pipeline_go_bare_ref_never_binds_field_parallel); 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); diff --git a/tests/test_registry.c b/tests/test_registry.c index b4de91280..3e1d604af 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -839,6 +839,26 @@ TEST(cross_language_ref_drops_go_vs_c) { PASS(); } +TEST(go_bare_ref_never_binds_field) { + /* #1942: a bare (dot-less) Go reference can never denote a struct field — + * field access is always a selector expression. */ + ASSERT_TRUE(cbm_go_suppress_bare_field_ref(true, "err", "Field")); + ASSERT_TRUE(cbm_go_suppress_bare_field_ref(true, "config", "Field")); + /* A selector-shaped reference may bind a field. */ + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, "t.err", "Field")); + /* Bare references to non-fields are untouched. */ + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, "err", "Variable")); + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, "err", "Function")); + /* Other languages reference their own members bare inside methods — + * never suppressed (cp_reads_writes_cs_static_field pins the C# shape). */ + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(false, "_count", "Field")); + /* Degenerate inputs → nothing to judge. */ + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, NULL, "Field")); + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, "", "Field")); + ASSERT_FALSE(cbm_go_suppress_bare_field_ref(true, "err", NULL)); + PASS(); +} + TEST(dynamic_suppress_drops_weak_method_matches) { /* #592/#606/#1276: a member call whose receiver the LSP could not type, that * landed via a WEAK short-name strategy, is generic-resolver noise → drop. @@ -974,6 +994,7 @@ SUITE(registry) { RUN_TEST(perl_suppress_keeps_high_confidence_and_genuine_calls); RUN_TEST(cross_language_suffix_match_drops_py_vs_js); RUN_TEST(cross_language_ref_drops_go_vs_c); + RUN_TEST(go_bare_ref_never_binds_field); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); }