From d6417ada0ec5bb144c0694d076fc504b6fa14e06 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sun, 30 Aug 2026 14:04:18 +0200 Subject: [PATCH] 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); }