diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 17491a9ea..684554455 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -693,7 +693,10 @@ bool cbm_pxc_has_cross_lsp(CBMLanguage lang) { /* Append cross-file results from `src_out` (allocated in a scratch arena * about to be destroyed) into `dst_calls` (lives in cache_entry->arena), - * copying every string field into dst_arena. Skips entries whose + * copying every string field into dst_arena. A manifest-qualified Rust + * cross-crate result supersedes any earlier result for the exact same source + * occurrence: the Cargo member path is stronger evidence than a same-named + * local fallback. Skips entries whose * (kind, caller_qn, callee_qn, source span) is already present — avoids * inflating the array with cross-file duplicates without conflating distinct * same-named occurrences. For an exact duplicate, retain the higher-confidence @@ -715,6 +718,39 @@ static void pxc_append_results(CBMArena *dst_arena, CBMResolvedCallArray *dst_ca cbm_arena_init(&keys); CBMHashTable *seen = cbm_ht_create((uint32_t)(dst_calls->count + src_out->count + 1)); + /* Per-file Rust resolution may already have confidently matched the tail + * of `member::call()` to a same-named local function. Once the cross pass + * proves that `member` is a declared Cargo workspace member, replace only + * records for that exact parser occurrence before constructing the dedup + * table. This preserves distinct same-named calls at other spans while + * preventing the stale local result from out-ranking manifest evidence. */ + for (int j = 0; j < src_out->count; j++) { + const CBMResolvedCall *src = &src_out->items[j]; + if (!src->strategy || strcmp(src->strategy, "lsp_cross_crate") != 0 || !src->caller_qn || + !src->callee_qn || src->site_end_byte <= src->site_start_byte) { + continue; + } + for (int i = 0; i < dst_calls->count; i++) { + CBMResolvedCall *dst = &dst_calls->items[i]; + if (!dst->caller_qn || dst->kind != src->kind || + dst->site_start_byte != src->site_start_byte || + dst->site_end_byte != src->site_end_byte || + dst->source_origin != src->source_origin || + strcmp(dst->caller_qn, src->caller_qn) != 0) { + continue; + } + dst->caller_qn = cbm_arena_strdup(dst_arena, src->caller_qn); + dst->callee_qn = cbm_arena_strdup(dst_arena, src->callee_qn); + dst->strategy = cbm_arena_strdup(dst_arena, src->strategy); + dst->confidence = src->confidence; + dst->reason = src->reason ? cbm_arena_strdup(dst_arena, src->reason) : NULL; + dst->kind = src->kind; + dst->site_start_byte = src->site_start_byte; + dst->site_end_byte = src->site_end_byte; + dst->source_origin = src->source_origin; + } + } + for (int i = 0; i < dst_calls->count; i++) { const CBMResolvedCall *rc = &dst_calls->items[i]; if (rc->caller_qn && rc->callee_qn) { @@ -1191,8 +1227,8 @@ void cbm_pxc_dispatch_file(CBMLanguage lang, CBMFileResult *result, const char * free(filtered); } -static bool pxc_build_rust_manifest(const cbm_pipeline_ctx_t *ctx, CBMArena *marena, - CBMCargoManifest *out_m) { +bool cbm_pxc_build_rust_manifest(const cbm_pipeline_ctx_t *ctx, CBMArena *marena, + CBMCargoManifest *out_m) { if (!ctx || !ctx->repo_path || !marena || !out_m) return false; char path[1024]; @@ -1234,7 +1270,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * bool have_manifest = false; if (have_rust) { cbm_arena_init(&cargo_arena); - have_manifest = pxc_build_rust_manifest(ctx, &cargo_arena, &cargo_manifest); + have_manifest = cbm_pxc_build_rust_manifest(ctx, &cargo_arena, &cargo_manifest); cbm_pxc_set_rust_manifest(have_manifest ? &cargo_manifest : NULL); } diff --git a/src/pipeline/pass_lsp_cross.h b/src/pipeline/pass_lsp_cross.h index 149959bce..3198d79d4 100644 --- a/src/pipeline/pass_lsp_cross.h +++ b/src/pipeline/pass_lsp_cross.h @@ -169,10 +169,14 @@ static inline CBMTypeRegistry *cbm_pxc_registry_for_lang(const CBMCrossLspRegist } } -/* Borrow the (thread-local) Rust Cargo manifest the cross-file LSP pass set for - * cross-crate (#56) routing. The Tier-2 prebuilt Rust resolve reads it so it sees - * exactly what the per-file fallback (cbm_pxc_run_one) would on the same thread. */ +/* Build and borrow the Rust Cargo manifest used for cross-crate (#56) routing. + * The manifest owns strings in manifest_arena; callers keep that arena alive + * until every resolver worker has joined. Each worker must install the shared + * immutable pointer in its own TLS slot before dispatch and clear it afterward. */ struct CBMCargoManifest; +bool cbm_pxc_build_rust_manifest(const cbm_pipeline_ctx_t *ctx, CBMArena *manifest_arena, + struct CBMCargoManifest *out_manifest); +void cbm_pxc_set_rust_manifest(const struct CBMCargoManifest *manifest); const struct CBMCargoManifest *cbm_pxc_get_rust_manifest(void); /* Run the cross-file LSP resolver for non-TS languages. Appends diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 9d61a1e82..716f26995 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -70,6 +70,7 @@ enum { PP_CSHARP_M_PREFIX_LEN = 2 }; #include "pipeline/pipeline_internal.h" #include "pipeline/pass_lsp_cross.h" /* cbm_pxc_* helpers for fused cross-file LSP */ #include "pipeline/lsp_resolve.h" +#include "lsp/rust_cargo.h" #include "helpers.h" /* cbm_kind_in_set_free_cache — per-worker-thread cache teardown */ #include "pipeline/worker_pool.h" #include "foundation/compat.h" @@ -1398,6 +1399,11 @@ typedef struct { * Stored as CBMCrossLspRegistries* (typedef from pass_lsp_cross.h). */ CBMCrossLspRegistries *cross_registries; + /* Parsed once on the coordinator thread and borrowed read-only by resolve + * workers. The pointer is installed into each worker's TLS slot so Rust's + * manifest-aware cross-file resolver sees the same Cargo context. */ + const CBMCargoManifest *rust_manifest; + /* F4: LAZILY-built shared Rust registry (built ONCE, on the first NULL-filter * rust file — the ~all_defs amplifier files). Not eager: repos whose rust files * all filter to subsets never pay the O(all_defs) build + multi-GB RSS. Built @@ -2944,6 +2950,8 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { resolve_ctx_t *rc = ctx_ptr; resolve_worker_state_t *ws = &rc->workers[worker_id]; + cbm_pxc_set_rust_manifest(rc->rust_manifest); + if (!ws->local_edge_buf) { ws->local_edge_buf = cbm_gbuf_new_shared_ids(rc->project_name, rc->repo_path, rc->shared_ids); @@ -3009,6 +3017,8 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { * a mixed-source-root Java↔Kotlin site remains unresolved, so JVM * callers run whenever either kind of site exists. */ bool jvm_cross_lsp = (lang == CBM_LANG_JAVA || lang == CBM_LANG_KOTLIN); + bool rust_workspace_cross_lsp = + (lang == CBM_LANG_RUST && rc->rust_manifest && rc->rust_manifest->member_count > 0); int call_reference_sites = pp_call_reference_site_count(result, lang); int semantic_sites = result->calls.count + call_reference_sites; int qualified_lsp_sites = pp_qualified_lsp_site_count(result); @@ -3016,7 +3026,8 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { bool cross_lsp_eligible = (rc->all_defs && rc->def_count > 0 && cbm_pxc_has_cross_lsp(lang) && (semantic_sites > 0 || pending_lsp_site) && - (jvm_cross_lsp || pending_lsp_site || qualified_lsp_sites < semantic_sites) && + (jvm_cross_lsp || rust_workspace_cross_lsp || pending_lsp_site || + qualified_lsp_sites < semantic_sites) && !is_generated); /* Skip files with nothing else to resolve and no cross-LSP work. */ @@ -3187,6 +3198,7 @@ static void resolve_worker(int worker_id, void *ctx_ptr) { * into dead TLS and are never retired, so a later cross-thread free can * never bring their refcount to zero (leak). Retiring them here releases * each page as its final chunk returns. */ + cbm_pxc_set_rust_manifest(NULL); cbm_destroy_thread_parser(); cbm_slab_destroy_thread(); cbm_service_pattern_cache_end(); @@ -3213,6 +3225,25 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, } memset(workers, 0, (size_t)worker_count * sizeof(resolve_worker_state_t)); + bool have_rust = false; + for (int i = 0; i < file_count; i++) { + if (result_cache[i] && files[i].language == CBM_LANG_RUST) { + have_rust = true; + break; + } + } + CBMArena rust_manifest_arena; + CBMCargoManifest rust_manifest; + bool rust_manifest_arena_live = false; + const CBMCargoManifest *rust_manifest_ptr = NULL; + if (have_rust) { + cbm_arena_init(&rust_manifest_arena); + rust_manifest_arena_live = true; + if (cbm_pxc_build_rust_manifest(ctx, &rust_manifest_arena, &rust_manifest)) { + rust_manifest_ptr = &rust_manifest; + } + } + resolve_ctx_t rc = { .files = files, .file_count = file_count, @@ -3230,6 +3261,7 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, .def_modules = def_modules, .module_def_index = module_def_index, .cross_registries = cross_registries, + .rust_manifest = rust_manifest_ptr, }; atomic_init(&rc.next_file_idx, 0); atomic_init(&rc.lsp_cross_processed, 0); @@ -3255,6 +3287,9 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files, rc.rust_shared_arena_live = false; } cbm_mutex_destroy(&rc.rust_shared_mu); + if (rust_manifest_arena_live) { + cbm_arena_destroy(&rust_manifest_arena); + } /* Sub-phase: Merge all local edge bufs into main gbuf (SEQUENTIAL) */ CBM_PROF_START(t_resolve_merge); diff --git a/tests/repro/repro_issue56.c b/tests/repro/repro_issue56.c index c5cbf596b..414756746 100644 --- a/tests/repro/repro_issue56.c +++ b/tests/repro/repro_issue56.c @@ -1,92 +1,17 @@ /* - * repro_issue56.c — Reproduce-first case for OPEN bug #56. + * repro_issue56.c — production-pipeline regression for Rust cross-crate calls. * - * Bug #56: "Cross-crate call graphs stop at boundaries" (Rust) + * The fixture is a two-member Cargo workspace. crate_b calls + * `crate_a::helper()` and also declares a same-named local helper, so generic + * bare-name resolution cannot accidentally satisfy the assertion. The test + * requires a persisted CALLS edge into crate_a's namespace. * - * ROOT CAUSE (pipeline / Rust LSP path): - * The tree-sitter-only Rust extractor has no access to Cargo metadata - * at extraction time, so when it sees `crate_a::helper()` inside - * crate_b, it records a raw call-site for the path but has no registry - * entry for `crate_a::helper` — only the definitions in the *same file* - * were seeded. The LSP resolver therefore cannot match the call-site to - * a callee QN across the crate boundary, and the resulting - * CBMResolvedCall is either absent or marked with low confidence and - * discarded. When the pipeline writes graph edges for this project, no - * CALLS edge is minted for the cross-crate call — the call graph stops - * at the crate edge. - * - * v0.8.1 added a hybrid-LSP Rust path that "materially improves" this - * (issue comment, maintainer 2026-06-25), but the reporter was asked to - * retest; the issue remains OPEN because no retest confirming resolution - * was provided. The workspace-member wiring test - * (rustlsp_extra_cargo_wires_workspace_member in test_rust_lsp.c) only - * exercises the *single-file LSP* layer with a manually-parsed manifest; - * it does NOT verify that the full production pipeline (rh_index_files → - * cbm_pipeline → graph store) persists a cross-crate CALLS edge for a - * real multi-file Cargo workspace fixture. That gap is what this test - * fills. - * - * FIXTURE: - * A minimal Cargo workspace with two crates: - * - * [workspace Cargo.toml] — workspace root, declares members - * crate_a/Cargo.toml — library crate "crate_a" - * crate_a/src/lib.rs — exposes `pub fn helper() {}` - * crate_b/Cargo.toml — binary crate "crate_b", depends on crate_a - * crate_b/src/main.rs — calls `crate_a::helper()` from `fn run()`; - * also defines a LOCAL `fn helper()` to break - * bare-name uniqueness (see note below) - * - * The only meaningful cross-crate CALLS edge is: - * crate_b::run → crate_a::helper - * - * EXPECTED (correct) behaviour: - * After indexing the workspace through the production MCP pipeline, the - * graph store must contain at least one CALLS edge whose TARGET node's - * qualified_name contains "crate_a" (i.e. routes into the crate_a - * namespace, not into crate_b's local helper). - * - * ACTUAL (buggy) behaviour: - * The pipeline extracts both files, but the cross-crate path - * `crate_a::helper` in crate_b/src/main.rs is not resolved to a graph - * node in crate_a because Cargo workspace member metadata is not - * plumbed into the per-file extraction phase. Result: zero CALLS edges - * to the crate_a namespace. - * - * WHY THIS IS RED ON CURRENT CODE (even post-v0.8.1): - * The rustlsp_extra_cargo_wires_workspace_member unit test exercises only - * the LSP layer (cbm_run_rust_lsp_with_manifest called with a parsed - * CBMCargoManifest) and confirms the resolver *can* route - * `engine::boot()` to `engine.boot` when given the manifest explicitly. - * BUT: the production pipeline's per-file extraction path - * (cbm_extract_file → cbm_run_rust_lsp) does NOT receive a pre-parsed - * workspace manifest — it only gets the individual file's content. - * Additionally, cbm_pxc_has_cross_lsp() returns false for CBM_LANG_RUST - * (pass_lsp_cross.c), so the cross-file LSP pass is never invoked for - * Rust. Therefore a real workspace indexed through index_repository - * produces no CALLS edges crossing into crate_a, and this test is RED. - * - * WHY THE OLD >= 2 COUNT TEST FALSE-PASSED: - * With a unique `helper` name in the project (one definition in crate_a, - * no other `helper` anywhere), the generic pipeline name resolver - * (registry.c, resolve_name_lookup) resolves `crate_a::helper` to the - * sole `helper` candidate by bare-name suffix scoring — WITHOUT needing - * any cross-crate workspace metadata. This produced calls >= 2 (the - * intra-file main→run plus the bare-name-resolved run→helper), making - * the old ASSERT_GTE(calls, 2) GREEN even though the bug was not fixed. - * - * Fix: add a LOCAL `fn helper()` in crate_b/src/main.rs so there are - * now TWO `helper` candidates in the project registry. The generic - * resolver either picks the wrong one (crate_b-local) or abstains - * (ambiguous). Only a correctly crate-qualified resolver routes - * `crate_a::helper` specifically to crate_a's node. The assertion then - * checks the TARGET node's qualified_name contains "crate_a" — a count - * check is no longer sufficient because the local helper also contributes - * a CALLS edge (run_local→helper). - * - * UNCERTAINTY: - * If a future version plumbs workspace metadata or wires Rust lsp_cross - * correctly, this test will go GREEN — that is the intended outcome. + * The direct parallel regression lives in test_parallel.c because the bug was + * specific to worker-local pipeline context: the root Cargo manifest was + * parsed by the sequential cross-LSP driver but was not propagated into + * parallel resolve workers. A confident local result could also suppress the + * manifest-aware cross pass. This repro remains as an end-to-end guard for the + * same graph contract through the production harness. */ #include "test_framework.h" @@ -108,19 +33,8 @@ * CALLS edge's TARGET node has a qualified_name containing "crate_a", * proving the cross-crate boundary was traversed. * - * RED condition: - * No CALLS edge whose target QN contains "crate_a" exists in the store. - * - * This test is RED on current code because: - * 1. cbm_run_rust_lsp is called with NULL manifest (cbm.c:645), so no - * workspace metadata is available at extraction time. - * 2. cbm_pxc_has_cross_lsp returns false for CBM_LANG_RUST - * (pass_lsp_cross.c:281), so the cross-file LSP pass never runs for - * Rust and cannot seed crate_a defs into crate_b's resolver context. - * 3. With two `helper` candidates (crate_a and crate_b-local), the - * generic resolver's qualified_suffix_match fails (neither QN ends - * with ".crate_a.helper") and bare-name scoring picks the crate_b- - * local one or abstains, never routing to crate_a. + * Regression condition: no CALLS edge whose target QN contains "crate_a" + * exists in the store. */ TEST(repro_issue56_cross_crate_calls) { /* diff --git a/tests/test_parallel.c b/tests/test_parallel.c index c33d26690..1a1c42ce3 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -351,6 +351,12 @@ static cbm_gbuf_t *run_parallel_with_extract_opts_and_mutator( harness_ctx_free_tables(&ctx); cbm_registry_free(reg); + /* cbm_parallel_extract installs a process-global package map whose + * production owner is the surrounding pipeline lifecycle. This direct + * pass harness owns that lifecycle itself, so mirror production teardown + * before returning the graph to the test. */ + cbm_pkgmap_free(cbm_pipeline_get_pkgmap()); + cbm_pipeline_set_pkgmap(NULL); return gbuf; } @@ -925,6 +931,25 @@ static bool callable_has_call_target_fragment(const cbm_gbuf_t *gbuf, const char return false; } +static const cbm_gbuf_edge_t *find_call_edge_to_target_fragment(const cbm_gbuf_t *gbuf, + const char *source_tail, + const char *target_fragment) { + const cbm_gbuf_node_t *source = find_unique_callable_node_by_tail(gbuf, source_tail); + if (!source || !target_fragment) + return NULL; + const cbm_gbuf_edge_t **edges = NULL; + int count = 0; + if (cbm_gbuf_find_edges_by_source_type(gbuf, source->id, "CALLS", &edges, &count) != 0) + return NULL; + for (int i = 0; i < count; i++) { + const cbm_gbuf_node_t *target = + edges[i] ? cbm_gbuf_find_by_id(gbuf, edges[i]->target_id) : NULL; + if (target && target->qualified_name && strstr(target->qualified_name, target_fragment)) + return edges[i]; + } + return NULL; +} + static int count_calls_edges_to_tail(const cbm_gbuf_t *gbuf, const char *target_tail) { const cbm_gbuf_node_t *target = find_unique_callable_node_by_tail(gbuf, target_tail); if (!target) @@ -2534,6 +2559,138 @@ TEST(parallel_kotlin_nonbinary_operator_carriers_reach_graph) { PASS(); } +/* Build the smallest real Cargo workspace that exercises the direct parallel + * resolve API. crate_a is a declared workspace member, while unlisted is a + * graph-visible decoy with the same helper name. That decoy is intentional: + * without manifest-aware routing, a bare-name match cannot accidentally make + * the test green. The optional crate_b-local helper pins the second failure + * mode where a confident in-file resolution used to suppress cross-LSP. */ +static cbm_gbuf_t *run_issue56_parallel_workspace(bool local_decoy) { + static const char workspace_toml[] = + "[workspace]\n" + "members = [\"crate_a\", \"crate_b\"]\n" + "resolver = \"2\"\n"; + static const char crate_a_toml[] = + "[package]\nname = \"crate_a\"\nversion = \"0.1.0\"\nedition = \"2021\"\n"; + static const char crate_b_toml[] = + "[package]\nname = \"crate_b\"\nversion = \"0.1.0\"\nedition = \"2021\"\n" + "\n[dependencies]\ncrate_a = { path = \"../crate_a\" }\n"; + static const char crate_a_source[] = "pub fn helper() {}\n"; + static const char unlisted_source[] = "pub fn helper() {}\n"; + static const char caller_without_local[] = + "fn run() { crate_a::helper(); }\n" + "fn main() { run(); }\n"; + static const char caller_with_local[] = + "fn helper() {}\n" + "fn run() { crate_a::helper(); }\n" + "fn main() { run(); }\n"; + + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_issue56_parallel_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) + return NULL; + + char crate_a[512]; + char crate_a_src[512]; + char crate_b[512]; + char crate_b_src[512]; + char unlisted[512]; + char unlisted_src[512]; + snprintf(crate_a, sizeof(crate_a), "%s/crate_a", tmpdir); + snprintf(crate_a_src, sizeof(crate_a_src), "%s/src", crate_a); + snprintf(crate_b, sizeof(crate_b), "%s/crate_b", tmpdir); + snprintf(crate_b_src, sizeof(crate_b_src), "%s/src", crate_b); + snprintf(unlisted, sizeof(unlisted), "%s/unlisted", tmpdir); + snprintf(unlisted_src, sizeof(unlisted_src), "%s/src", unlisted); + if (cbm_mkdir(crate_a) != 0 || cbm_mkdir(crate_a_src) != 0 || cbm_mkdir(crate_b) != 0 || + cbm_mkdir(crate_b_src) != 0 || cbm_mkdir(unlisted) != 0 || cbm_mkdir(unlisted_src) != 0) { + th_rmtree(tmpdir); + return NULL; + } + + char root_manifest[512]; + char crate_a_manifest[512]; + char crate_b_manifest[512]; + char crate_a_file[512]; + char crate_b_file[512]; + char unlisted_file[512]; + snprintf(root_manifest, sizeof(root_manifest), "%s/Cargo.toml", tmpdir); + snprintf(crate_a_manifest, sizeof(crate_a_manifest), "%s/Cargo.toml", crate_a); + snprintf(crate_b_manifest, sizeof(crate_b_manifest), "%s/Cargo.toml", crate_b); + snprintf(crate_a_file, sizeof(crate_a_file), "%s/lib.rs", crate_a_src); + snprintf(crate_b_file, sizeof(crate_b_file), "%s/main.rs", crate_b_src); + snprintf(unlisted_file, sizeof(unlisted_file), "%s/lib.rs", unlisted_src); + if (th_write_file(root_manifest, workspace_toml) != 0 || + th_write_file(crate_a_manifest, crate_a_toml) != 0 || + th_write_file(crate_b_manifest, crate_b_toml) != 0 || + th_write_file(crate_a_file, crate_a_source) != 0 || + th_write_file(crate_b_file, local_decoy ? caller_with_local : caller_without_local) != 0 || + th_write_file(unlisted_file, unlisted_source) != 0) { + th_rmtree(tmpdir); + return NULL; + } + + cbm_discover_opts_t opts = {.mode = CBM_MODE_FULL}; + cbm_file_info_t *files = NULL; + int file_count = 0; + if (cbm_discover(tmpdir, &opts, &files, &file_count) != 0 || file_count < 3) { + cbm_discover_free(files, file_count); + th_rmtree(tmpdir); + return NULL; + } + cbm_gbuf_t *gbuf = + run_parallel("cbm_issue56_parallel", tmpdir, files, file_count, 2 /* real workers */); + cbm_discover_free(files, file_count); + th_rmtree(tmpdir); + return gbuf; +} + +TEST(parallel_rust_cross_crate_worker_receives_workspace_manifest) { + cbm_gbuf_t *gbuf = run_issue56_parallel_workspace(false); + ASSERT_NOT_NULL(gbuf); + + const cbm_gbuf_edge_t *correct = + find_call_edge_to_target_fragment(gbuf, "main.run", ".crate_a."); + const bool correct_found = correct != NULL; + const bool unlisted = + callable_has_call_target_fragment(gbuf, "main.run", ".unlisted."); + const bool manifest_strategy = + correct && correct->properties_json && strstr(correct->properties_json, "lsp_cross_crate"); + if (!correct || unlisted || !manifest_strategy) { + printf(" issue56 manifest diagnostic: correct=%d unlisted=%d strategy=%d\n", + correct_found, unlisted, manifest_strategy); + } + cbm_gbuf_free(gbuf); + + ASSERT_TRUE(correct_found); + ASSERT_FALSE(unlisted); + ASSERT_TRUE(manifest_strategy); + PASS(); +} + +TEST(parallel_rust_cross_crate_manifest_beats_confident_local_resolution) { + cbm_gbuf_t *gbuf = run_issue56_parallel_workspace(true); + ASSERT_NOT_NULL(gbuf); + + const cbm_gbuf_edge_t *correct = + find_call_edge_to_target_fragment(gbuf, "main.run", ".crate_a."); + const bool correct_found = correct != NULL; + const bool wrong_local = + callable_has_call_target_fragment(gbuf, "main.run", ".crate_b."); + const bool manifest_strategy = + correct && correct->properties_json && strstr(correct->properties_json, "lsp_cross_crate"); + if (!correct || wrong_local || !manifest_strategy) { + printf(" issue56 local-shadow diagnostic: correct=%d wrong_local=%d strategy=%d\n", + correct_found, wrong_local, manifest_strategy); + } + cbm_gbuf_free(gbuf); + + ASSERT_TRUE(correct_found); + ASSERT_FALSE(wrong_local); + ASSERT_TRUE(manifest_strategy); + PASS(); +} + /* A known external macro semantic owns its parser occurrence even when the * external target is not materialized as a graph node. It must not fall back * to a same-named local function in either pipeline. */ @@ -3926,6 +4083,8 @@ SUITE(parallel) { RUN_TEST(parallel_tsx_import_namespace_exact_parity); RUN_TEST(parallel_kotlin_external_protocol_does_not_use_project_class_method_tail); RUN_TEST(parallel_kotlin_nonbinary_operator_carriers_reach_graph); + RUN_TEST(parallel_rust_cross_crate_worker_receives_workspace_manifest); + RUN_TEST(parallel_rust_cross_crate_manifest_beats_confident_local_resolution); RUN_TEST(parallel_rust_known_macro_does_not_fallback_to_local_function); RUN_TEST(parallel_rust_proc_macros_are_decorates_and_usage_only); RUN_TEST(parallel_c_preprocessed_coordinate_collision_preserves_hidden_target);