From 793e0b6127db3c8333b1dc29faf0b35167e3ceda Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Fri, 25 Sep 2026 18:37:16 +0200 Subject: [PATCH] fix(rust): never let a std-typed receiver bind a same-named project method (#2053) The Rust LSP types `root: &Path` and dispatches `root.join(..)` to std.path.Path.join, a registered seed method with no graph node. Both call resolvers treated "LSP row without a gbuf target" like "no LSP row" and fell back to the textual registry, which bound the call to the only project method named `join` (EvidenceTier::join via unique_name). Every std/seeded-crate call whose leaf name a project method shares was fabricated this way. cbm_pipeline_rust_external_target() (lsp_resolve.h) recognises a Rust row whose strategy emits a registered target (method/trait/deref/bound dispatch, direct, ufcs, constructor) and whose QN lies outside the project prefix. Project defs are registered under project-prefixed QNs, so such a target is a std/core/alloc or seeded-crate symbol by construction. pass_calls.c and pass_parallel.c skip the registry fallback for those rows (in lockstep), and the parallel route-method fallback no longer turns a skipped `map.get(k)` into a source->source self-call. Project-prefixed rows, unknown-receiver rows and every other language are unchanged. The call-expression receiver (`root.to_path_buf().join(..)`) needed type facts: Path::join/to_path_buf/with_extension/with_file_name now return PathBuf, PathBuf::as_path returns Path, and PathBuf derefs to Path. Measured on BurntSushi/ripgrep @ 3fce3b5 (fresh index, two runs each, byte-identical): CALLS 5371 -> 5014; 357 removed (307 suffix_match, 45 unique_name, 5 field_type_hint), 0 added, no lsp_* edge touched. Removed samples are all std receivers (String::new -> State.new, msg.contains -> ByteSet.contains, alts.push on a Vec -> TSeq.push). Signed-off-by: Martin Vogel --- internal/cbm/lsp/generated/rust_stdlib_data.c | 26 ++++- src/pipeline/lsp_resolve.h | 41 +++++++ src/pipeline/pass_calls.c | 14 ++- src/pipeline/pass_parallel.c | 17 ++- tests/test_parallel.c | 35 ++++++ tests/test_pipeline.c | 107 ++++++++++++++++++ 6 files changed, 230 insertions(+), 10 deletions(-) diff --git a/internal/cbm/lsp/generated/rust_stdlib_data.c b/internal/cbm/lsp/generated/rust_stdlib_data.c index a2efb46041..c35309da4e 100644 --- a/internal/cbm/lsp/generated/rust_stdlib_data.c +++ b/internal/cbm/lsp/generated/rust_stdlib_data.c @@ -631,7 +631,18 @@ void cbm_rust_stdlib_register(CBMTypeRegistry* reg, CBMArena* arena) { /* ── std::path ─────────────────────────────────────────────── */ ADD_TYPE("std.path.Path", "Path", false); - ADD_TYPE("std.path.PathBuf", "PathBuf", false); + { + /* PathBuf derefs to Path (#2053): `buf.join(..)` dispatches through + * rust_deref_step's DerefTarget entry to std.path.Path.join instead of + * leaving the receiver's method unknown. */ + static const char *pathbuf_deref[] = {"DerefTarget:std.path.Path", NULL}; + CBMRegisteredType _rt; + memset(&_rt, 0, sizeof(_rt)); + _rt.qualified_name = "std.path.PathBuf"; + _rt.short_name = "PathBuf"; + _rt.embedded_types = pathbuf_deref; + cbm_registry_add_type(reg, _rt); + } ADD_TYPE("std.path.Component", "Component", false); ADD_TYPE("std.path.Components", "Components", false); ADD_TYPE("std.path.Iter", "Iter", false); @@ -639,6 +650,9 @@ void cbm_rust_stdlib_register(CBMTypeRegistry* reg, CBMArena* arena) { { const char* T = "std.path.Path"; + /* Owned-path producers return PathBuf (#2053), so a chained call + * (`root.to_path_buf().join(..)`) keeps a typed receiver. */ + const CBMType* t_pathbuf = cbm_type_named(arena, "std.path.PathBuf"); ADD_FUNC(T, "new", "std.path.Path.new", cbm_type_unknown()); ADD_FUNC(T, "exists", "std.path.Path.exists", t_bool); ADD_FUNC(T, "try_exists", "std.path.Path.try_exists", cbm_type_unknown()); @@ -656,16 +670,16 @@ void cbm_rust_stdlib_register(CBMTypeRegistry* reg, CBMArena* arena) { ADD_FUNC(T, "parent", "std.path.Path.parent", cbm_type_unknown()); ADD_FUNC(T, "components", "std.path.Path.components", cbm_type_unknown()); ADD_FUNC(T, "iter", "std.path.Path.iter", cbm_type_unknown()); - ADD_FUNC(T, "join", "std.path.Path.join", cbm_type_unknown()); + ADD_FUNC(T, "join", "std.path.Path.join", t_pathbuf); ADD_FUNC(T, "display", "std.path.Path.display", cbm_type_unknown()); ADD_FUNC(T, "canonicalize", "std.path.Path.canonicalize", cbm_type_unknown()); - ADD_FUNC(T, "to_path_buf", "std.path.Path.to_path_buf", cbm_type_unknown()); + ADD_FUNC(T, "to_path_buf", "std.path.Path.to_path_buf", t_pathbuf); ADD_FUNC(T, "to_str", "std.path.Path.to_str", cbm_type_unknown()); ADD_FUNC(T, "to_string_lossy", "std.path.Path.to_string_lossy", cbm_type_unknown()); ADD_FUNC(T, "metadata", "std.path.Path.metadata", cbm_type_unknown()); ADD_FUNC(T, "read_dir", "std.path.Path.read_dir", cbm_type_unknown()); - ADD_FUNC(T, "with_extension", "std.path.Path.with_extension", cbm_type_unknown()); - ADD_FUNC(T, "with_file_name", "std.path.Path.with_file_name", cbm_type_unknown()); + ADD_FUNC(T, "with_extension", "std.path.Path.with_extension", t_pathbuf); + ADD_FUNC(T, "with_file_name", "std.path.Path.with_file_name", t_pathbuf); } { const char* T = "std.path.PathBuf"; @@ -676,7 +690,7 @@ void cbm_rust_stdlib_register(CBMTypeRegistry* reg, CBMArena* arena) { ADD_FUNC(T, "pop", "std.path.PathBuf.pop", t_bool); ADD_FUNC(T, "set_file_name", "std.path.PathBuf.set_file_name",t_unit); ADD_FUNC(T, "set_extension", "std.path.PathBuf.set_extension",t_bool); - ADD_FUNC(T, "as_path", "std.path.PathBuf.as_path", cbm_type_unknown()); + ADD_FUNC(T, "as_path", "std.path.PathBuf.as_path", cbm_type_named(arena, "std.path.Path")); ADD_FUNC(T, "into_os_string", "std.path.PathBuf.into_os_string",cbm_type_unknown()); ADD_FUNC(T, "clear", "std.path.PathBuf.clear", t_unit); } diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 96f08e84e0..666f9fd552 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -537,6 +537,47 @@ static inline bool cbm_pipeline_kotlin_external_target(CBMLanguage lang, const c strncmp(callee_qn, "javax.", strlen("javax.")) == 0; } +/* Rust (#2053): true when the Rust LSP positively resolved a call to a + * registered symbol that lives OUTSIDE the project — a std/core/alloc seed + * method (`root.join()` on `root: &Path` -> std.path.Path.join) or a seeded + * crate API. Such a row has no graph node, and before this gate the callers + * then fell back to the textual registry, which bound the call to whatever + * project method shares the leaf name (EvidenceTier::join, unique_name). + * + * The evidence is the strategy plus the QN's namespace. The listed strategies + * emit the QN of a function the LSP registry actually holds; project defs are + * registered under their project-prefixed QNs, so a registered QN outside the + * project prefix is an embedded seed by construction. A project-prefixed QN + * that merely failed the gbuf lookup is NOT external and keeps the #1085 + * registry fallback, as does every unresolved/unknown-receiver row (those + * never reach the callers: they sit below CBM_LSP_CONFIDENCE_FLOOR). + * Rust-only: suppressors stay per-language. Pure; unit-tested. */ +static inline bool cbm_pipeline_rust_external_target(CBMLanguage lang, const char *strategy, + const char *callee_qn, + const char *project_name) { + if (lang != CBM_LANG_RUST || !strategy || !callee_qn || !callee_qn[0] || !project_name || + !project_name[0]) { + return false; + } + static const char *const registered_target_strategies[] = { + "lsp_method_dispatch", "lsp_trait_dispatch", "lsp_deref_dispatch", "lsp_bound_dispatch", + "lsp_direct", "lsp_ufcs", "lsp_constructor", + }; + bool registered_target = false; + for (size_t i = 0; + i < sizeof(registered_target_strategies) / sizeof(registered_target_strategies[0]); i++) { + if (strcmp(strategy, registered_target_strategies[i]) == 0) { + registered_target = true; + break; + } + } + if (!registered_target) { + return false; + } + size_t proj_len = strlen(project_name); + return !(strncmp(callee_qn, project_name, proj_len) == 0 && callee_qn[proj_len] == '.'); +} + /* Resolvers may report one graph target both project-relative and already * project-prefixed. Raw string inequality is not ambiguity when both spellings * resolve to the same materialized node; conversely, if both nodes exist they diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 84958954cf..42dfafbc5b 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -544,8 +544,18 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, } } - cbm_resolution_t res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn, - imp_keys, imp_vals, imp_count); + /* #2053: a Rust call the LSP placed on an EXTERNAL symbol (std's + * Path::join, a seeded crate API) is resolved — it just has no graph node. + * The textual registry would bind it to a same-named project method + * instead, so skip it and let the empty-resolution service fallbacks below + * classify the call. MUST match pass_parallel.c. */ + bool rust_external = lsp && cbm_pipeline_rust_external_target( + lang, lsp->strategy, lsp->callee_qn, ctx->project_name); + cbm_resolution_t res = {0}; + if (!rust_external) { + res = cbm_registry_resolve(ctx->registry, call->callee_name, module_qn, imp_keys, imp_vals, + imp_count); + } if (!res.qualified_name || res.qualified_name[0] == '\0') { /* Resolution is empty when the callee belongs to an EXTERNAL client * library whose source is not in the indexed tree (e.g. `requests.get`, diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index e85ad015f1..18e23d4c43 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2895,7 +2895,15 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * semantic candidates are deliberately excluded: they require an * exact LSP target and must fail closed rather than accepting a textual * registry match. */ - if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution) { + /* #2053: a Rust call the LSP placed on an EXTERNAL symbol (std's + * Path::join, a seeded crate API) is resolved — it just has no graph + * node. The textual registry would bind it to a same-named project + * method instead, so it must not run for such a row. Mirrors + * pass_calls.c; the service fallbacks below still see the call. */ + bool rust_external = lsp && cbm_pipeline_rust_external_target( + lang, lsp->strategy, lsp->callee_qn, rc->project_name); + if ((!res.qualified_name || !res.qualified_name[0]) && !call->requires_lsp_resolution && + !rust_external) { res = cbm_registry_resolve(rc->registry, call->callee_name, module_qn, imp_keys, imp_vals, imp_count); } @@ -2989,9 +2997,14 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB cbm_resolution_t fake_res = {.qualified_name = call->callee_name, .confidence = PP_HALF_CONF, .strategy = "callee_suffix"}; + /* #2053: an LSP-external Rust call (`map.get(k)` on a std + * HashMap) reaches this branch only because its registry + * fallback was skipped. Without a route path the plain-CALLS + * fall-through would bind source -> source, a fabricated + * self-call, so it keeps only the route/service edges. */ emit_service_edge(ws->local_edge_buf, source_node, source_node, call, &fake_res, module_qn, rc->registry, rc->main_gbuf, imp_keys, imp_vals, - imp_count, false); + imp_count, rust_external); } else if (cbm_service_pattern_is_global_fetch(call->callee_name)) { /* Native `fetch()` (#856): only the global API once resolution * has failed to find a local/imported `fetch`. Call the low-level diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 85df7d55d1..e53e266ad0 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -3121,6 +3121,40 @@ TEST(parallel_java_kotlin_lsp_override_cross_file_emits_lsp_strategy_edges) { PASS(); } +/* #2053 contract for cbm_pipeline_rust_external_target: only a Rust row whose + * strategy names a registered target, and whose QN lies outside the project + * prefix, counts as external. The end-to-end probes live in test_pipeline.c + * (pipeline_rust_std_receiver_never_binds_project_method*). */ +TEST(parallel_rust_external_target_contract) { + const char *proj = "proj"; + /* External: registered std / seeded-crate targets. */ + ASSERT_TRUE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_method_dispatch", + "std.path.Path.join", proj)); + ASSERT_TRUE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_deref_dispatch", + "std.path.Path.join", proj)); + ASSERT_TRUE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_constructor", + "core.sync.atomic.AtomicUsize.new", proj)); + /* A prefix that is not a whole segment is still outside the project. */ + ASSERT_TRUE( + cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_direct", "projx.helper", proj)); + /* Project-prefixed targets keep the registry fallback. */ + ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_method_dispatch", + "proj.src.lib.EvidenceTier.join", proj)); + /* Synthesized / non-registered strategies are not evidence. */ + ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_prelude_trait", + "std.path.PathBuf.clone", proj)); + ASSERT_FALSE( + cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_unresolved", "root.join", proj)); + /* Per-language: no other language is affected. */ + ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_GO, "lsp_method_dispatch", + "std.path.Path.join", proj)); + /* Defensive NULL/empty inputs. */ + ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, NULL, "std.x", proj)); + ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_direct", "std.x", NULL)); + ASSERT_FALSE(cbm_pipeline_rust_external_target(CBM_LANG_RUST, "lsp_direct", "", proj)); + PASS(); +} + /* Gate guard for the JVM-only unique-tail fallbacks (lsp_resolve.h). * * The tail fallbacks join LSP overrides across QN drift by unique @@ -4426,6 +4460,7 @@ SUITE(parallel) { RUN_TEST(parallel_go_cross_package_field_chain_resolves); RUN_TEST(parallel_cross_file_reread_preserves_unretained_edges); RUN_TEST(parallel_java_kotlin_lsp_override_cross_file_emits_lsp_strategy_edges); + RUN_TEST(parallel_rust_external_target_contract); RUN_TEST(parallel_lsp_tail_match_fallbacks_gated_to_jvm); RUN_TEST(parallel_calls_parity); RUN_TEST(parallel_defines_parity); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d32ab3538b..b3a9db8f27 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5966,6 +5966,111 @@ TEST(pipeline_go_rw_usage_never_cross_into_c_parallel) { PASS(); } +/* Fixture for the #2053 Rust std-receiver probes (sequential and parallel + * twins). The Rust LSP types `root: &Path` and dispatches `root.join(..)` to + * std's Path::join — a symbol with no graph node. Before the fix the pipeline + * then fell back to the textual registry, which bound the call to the only + * project method named `join` (EvidenceTier::join, unique_name). The + * call-expression receiver (`root.to_path_buf().join(..)`) is the same class + * one hop later: it needs to_path_buf's return type and PathBuf's Deref to + * Path. POSITIVE tripwires: the typed project call keeps its LSP edge, and a + * closure-parameter receiver the LSP cannot type still reaches the registry, + * so the fix removes only calls the LSP positively placed outside the + * project. `m.get(k)` on a std HashMap guards the parallel route fallback, + * which must not turn the skipped call into a self-call. pad_files > 0 + * crosses the parallel-pipeline threshold. */ +static void write_rust_std_receiver_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "Cargo.toml", + "[package]\nname = \"stdrecv\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"); + write_temp_file(tmp, "src/lib.rs", + "use std::collections::HashMap;\n" + "use std::path::{Path, PathBuf};\n" + "\n" + "pub struct EvidenceTier(pub u8);\n" + "impl EvidenceTier {\n" + " pub fn join(&self, other: &EvidenceTier) -> EvidenceTier {\n" + " EvidenceTier(self.0.max(other.0))\n" + " }\n" + "}\n" + "\n" + "pub struct Gauge(pub u8);\n" + "impl Gauge {\n" + " pub fn recalibrate_gauge(&self) -> u8 {\n" + " self.0\n" + " }\n" + "}\n" + "\n" + "pub fn real_caller() -> EvidenceTier {\n" + " EvidenceTier(1).join(&EvidenceTier(2))\n" + "}\n" + "\n" + "pub fn stdlib_receiver(root: &Path) -> PathBuf {\n" + " root.join(\"subdir\")\n" + "}\n" + "\n" + "pub fn call_expr_receiver(root: &Path) -> PathBuf {\n" + " root.to_path_buf().join(\"nested\")\n" + "}\n" + "\n" + "pub fn untyped_receiver(gauges: &[Gauge]) -> u8 {\n" + " gauges.iter().map(|g| g.recalibrate_gauge()).sum()\n" + "}\n" + "\n" + "pub fn std_map_lookup(m: &HashMap, k: &str) -> Option {\n" + " m.get(k).copied()\n" + "}\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() -> i32 {\n %d\n}\n", i, i); + write_temp_file(tmp, name, body); + } +} + +static int run_rust_std_receiver_probe(int pad_files, const char *tag) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_rs_%s_XXXXXX", tag); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_rust_std_receiver_fixture(tmp, pad_files); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/rs_std_recv.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); + + /* NEGATIVE: std's Path::join / PathBuf::join are not project symbols. */ + ASSERT_EQ(named_edge_count(s, project, "CALLS", "stdlib_receiver", "join"), 0); + ASSERT_EQ(named_edge_count(s, project, "CALLS", "call_expr_receiver", "join"), 0); + /* NEGATIVE: a std `map.get(k)` with no route path is no self-call (the + * parallel empty-resolution route fallback must not bind source->source). */ + ASSERT_EQ(named_edge_count(s, project, "CALLS", "std_map_lookup", "std_map_lookup"), 0); + /* POSITIVE: the typed project call keeps its edge. */ + ASSERT_EQ(named_edge_count(s, project, "CALLS", "real_caller", "join"), 1); + /* POSITIVE: an untyped receiver still reaches the registry fallback. */ + ASSERT_EQ(named_edge_count(s, project, "CALLS", "untyped_receiver", "recalibrate_gauge"), 1); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +TEST(pipeline_rust_std_receiver_never_binds_project_method) { + return run_rust_std_receiver_probe(0, "seq"); +} + +TEST(pipeline_rust_std_receiver_never_binds_project_method_parallel) { + return run_rust_std_receiver_probe(52, "par"); +} + 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 @@ -15120,6 +15225,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_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); RUN_TEST(pipeline_go_bare_ref_never_binds_field_parallel); RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges);