Skip to content

Commit bafe4f3

Browse files
committed
fix: preserve Cargo context in parallel Rust resolution
Signed-off-by: Martin Vogel <[email protected]>
1 parent 909051c commit bafe4f3

5 files changed

Lines changed: 249 additions & 107 deletions

File tree

‎src/pipeline/pass_lsp_cross.c‎

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -693,7 +693,10 @@ bool cbm_pxc_has_cross_lsp(CBMLanguage lang) {
693693

694694
/* Append cross-file results from `src_out` (allocated in a scratch arena
695695
* about to be destroyed) into `dst_calls` (lives in cache_entry->arena),
696-
* copying every string field into dst_arena. Skips entries whose
696+
* copying every string field into dst_arena. A manifest-qualified Rust
697+
* cross-crate result supersedes any earlier result for the exact same source
698+
* occurrence: the Cargo member path is stronger evidence than a same-named
699+
* local fallback. Skips entries whose
697700
* (kind, caller_qn, callee_qn, source span) is already present — avoids
698701
* inflating the array with cross-file duplicates without conflating distinct
699702
* 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
715718
cbm_arena_init(&keys);
716719
CBMHashTable *seen = cbm_ht_create((uint32_t)(dst_calls->count + src_out->count + 1));
717720

721+
/* Per-file Rust resolution may already have confidently matched the tail
722+
* of `member::call()` to a same-named local function. Once the cross pass
723+
* proves that `member` is a declared Cargo workspace member, replace only
724+
* records for that exact parser occurrence before constructing the dedup
725+
* table. This preserves distinct same-named calls at other spans while
726+
* preventing the stale local result from out-ranking manifest evidence. */
727+
for (int j = 0; j < src_out->count; j++) {
728+
const CBMResolvedCall *src = &src_out->items[j];
729+
if (!src->strategy || strcmp(src->strategy, "lsp_cross_crate") != 0 || !src->caller_qn ||
730+
!src->callee_qn || src->site_end_byte <= src->site_start_byte) {
731+
continue;
732+
}
733+
for (int i = 0; i < dst_calls->count; i++) {
734+
CBMResolvedCall *dst = &dst_calls->items[i];
735+
if (!dst->caller_qn || dst->kind != src->kind ||
736+
dst->site_start_byte != src->site_start_byte ||
737+
dst->site_end_byte != src->site_end_byte ||
738+
dst->source_origin != src->source_origin ||
739+
strcmp(dst->caller_qn, src->caller_qn) != 0) {
740+
continue;
741+
}
742+
dst->caller_qn = cbm_arena_strdup(dst_arena, src->caller_qn);
743+
dst->callee_qn = cbm_arena_strdup(dst_arena, src->callee_qn);
744+
dst->strategy = cbm_arena_strdup(dst_arena, src->strategy);
745+
dst->confidence = src->confidence;
746+
dst->reason = src->reason ? cbm_arena_strdup(dst_arena, src->reason) : NULL;
747+
dst->kind = src->kind;
748+
dst->site_start_byte = src->site_start_byte;
749+
dst->site_end_byte = src->site_end_byte;
750+
dst->source_origin = src->source_origin;
751+
}
752+
}
753+
718754
for (int i = 0; i < dst_calls->count; i++) {
719755
const CBMResolvedCall *rc = &dst_calls->items[i];
720756
if (rc->caller_qn && rc->callee_qn) {
@@ -1191,8 +1227,8 @@ void cbm_pxc_dispatch_file(CBMLanguage lang, CBMFileResult *result, const char *
11911227
free(filtered);
11921228
}
11931229

1194-
static bool pxc_build_rust_manifest(const cbm_pipeline_ctx_t *ctx, CBMArena *marena,
1195-
CBMCargoManifest *out_m) {
1230+
bool cbm_pxc_build_rust_manifest(const cbm_pipeline_ctx_t *ctx, CBMArena *marena,
1231+
CBMCargoManifest *out_m) {
11961232
if (!ctx || !ctx->repo_path || !marena || !out_m)
11971233
return false;
11981234
char path[1024];
@@ -1234,7 +1270,7 @@ int cbm_pipeline_pass_lsp_cross(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *
12341270
bool have_manifest = false;
12351271
if (have_rust) {
12361272
cbm_arena_init(&cargo_arena);
1237-
have_manifest = pxc_build_rust_manifest(ctx, &cargo_arena, &cargo_manifest);
1273+
have_manifest = cbm_pxc_build_rust_manifest(ctx, &cargo_arena, &cargo_manifest);
12381274
cbm_pxc_set_rust_manifest(have_manifest ? &cargo_manifest : NULL);
12391275
}
12401276

‎src/pipeline/pass_lsp_cross.h‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -169,10 +169,14 @@ static inline CBMTypeRegistry *cbm_pxc_registry_for_lang(const CBMCrossLspRegist
169169
}
170170
}
171171

172-
/* Borrow the (thread-local) Rust Cargo manifest the cross-file LSP pass set for
173-
* cross-crate (#56) routing. The Tier-2 prebuilt Rust resolve reads it so it sees
174-
* exactly what the per-file fallback (cbm_pxc_run_one) would on the same thread. */
172+
/* Build and borrow the Rust Cargo manifest used for cross-crate (#56) routing.
173+
* The manifest owns strings in manifest_arena; callers keep that arena alive
174+
* until every resolver worker has joined. Each worker must install the shared
175+
* immutable pointer in its own TLS slot before dispatch and clear it afterward. */
175176
struct CBMCargoManifest;
177+
bool cbm_pxc_build_rust_manifest(const cbm_pipeline_ctx_t *ctx, CBMArena *manifest_arena,
178+
struct CBMCargoManifest *out_manifest);
179+
void cbm_pxc_set_rust_manifest(const struct CBMCargoManifest *manifest);
176180
const struct CBMCargoManifest *cbm_pxc_get_rust_manifest(void);
177181

178182
/* Run the cross-file LSP resolver for non-TS languages. Appends

‎src/pipeline/pass_parallel.c‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ enum { PP_CSHARP_M_PREFIX_LEN = 2 };
7070
#include "pipeline/pipeline_internal.h"
7171
#include "pipeline/pass_lsp_cross.h" /* cbm_pxc_* helpers for fused cross-file LSP */
7272
#include "pipeline/lsp_resolve.h"
73+
#include "lsp/rust_cargo.h"
7374
#include "helpers.h" /* cbm_kind_in_set_free_cache — per-worker-thread cache teardown */
7475
#include "pipeline/worker_pool.h"
7576
#include "foundation/compat.h"
@@ -1398,6 +1399,11 @@ typedef struct {
13981399
* Stored as CBMCrossLspRegistries* (typedef from pass_lsp_cross.h). */
13991400
CBMCrossLspRegistries *cross_registries;
14001401

1402+
/* Parsed once on the coordinator thread and borrowed read-only by resolve
1403+
* workers. The pointer is installed into each worker's TLS slot so Rust's
1404+
* manifest-aware cross-file resolver sees the same Cargo context. */
1405+
const CBMCargoManifest *rust_manifest;
1406+
14011407
/* F4: LAZILY-built shared Rust registry (built ONCE, on the first NULL-filter
14021408
* rust file — the ~all_defs amplifier files). Not eager: repos whose rust files
14031409
* 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) {
29442950
resolve_ctx_t *rc = ctx_ptr;
29452951
resolve_worker_state_t *ws = &rc->workers[worker_id];
29462952

2953+
cbm_pxc_set_rust_manifest(rc->rust_manifest);
2954+
29472955
if (!ws->local_edge_buf) {
29482956
ws->local_edge_buf =
29492957
cbm_gbuf_new_shared_ids(rc->project_name, rc->repo_path, rc->shared_ids);
@@ -3009,14 +3017,17 @@ static void resolve_worker(int worker_id, void *ctx_ptr) {
30093017
* a mixed-source-root Java↔Kotlin site remains unresolved, so JVM
30103018
* callers run whenever either kind of site exists. */
30113019
bool jvm_cross_lsp = (lang == CBM_LANG_JAVA || lang == CBM_LANG_KOTLIN);
3020+
bool rust_workspace_cross_lsp =
3021+
(lang == CBM_LANG_RUST && rc->rust_manifest && rc->rust_manifest->member_count > 0);
30123022
int call_reference_sites = pp_call_reference_site_count(result, lang);
30133023
int semantic_sites = result->calls.count + call_reference_sites;
30143024
int qualified_lsp_sites = pp_qualified_lsp_site_count(result);
30153025
bool pending_lsp_site = pp_has_pending_lsp_site(result);
30163026
bool cross_lsp_eligible =
30173027
(rc->all_defs && rc->def_count > 0 && cbm_pxc_has_cross_lsp(lang) &&
30183028
(semantic_sites > 0 || pending_lsp_site) &&
3019-
(jvm_cross_lsp || pending_lsp_site || qualified_lsp_sites < semantic_sites) &&
3029+
(jvm_cross_lsp || rust_workspace_cross_lsp || pending_lsp_site ||
3030+
qualified_lsp_sites < semantic_sites) &&
30203031
!is_generated);
30213032

30223033
/* 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) {
31873198
* into dead TLS and are never retired, so a later cross-thread free can
31883199
* never bring their refcount to zero (leak). Retiring them here releases
31893200
* each page as its final chunk returns. */
3201+
cbm_pxc_set_rust_manifest(NULL);
31903202
cbm_destroy_thread_parser();
31913203
cbm_slab_destroy_thread();
31923204
cbm_service_pattern_cache_end();
@@ -3213,6 +3225,25 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
32133225
}
32143226
memset(workers, 0, (size_t)worker_count * sizeof(resolve_worker_state_t));
32153227

3228+
bool have_rust = false;
3229+
for (int i = 0; i < file_count; i++) {
3230+
if (result_cache[i] && files[i].language == CBM_LANG_RUST) {
3231+
have_rust = true;
3232+
break;
3233+
}
3234+
}
3235+
CBMArena rust_manifest_arena;
3236+
CBMCargoManifest rust_manifest;
3237+
bool rust_manifest_arena_live = false;
3238+
const CBMCargoManifest *rust_manifest_ptr = NULL;
3239+
if (have_rust) {
3240+
cbm_arena_init(&rust_manifest_arena);
3241+
rust_manifest_arena_live = true;
3242+
if (cbm_pxc_build_rust_manifest(ctx, &rust_manifest_arena, &rust_manifest)) {
3243+
rust_manifest_ptr = &rust_manifest;
3244+
}
3245+
}
3246+
32163247
resolve_ctx_t rc = {
32173248
.files = files,
32183249
.file_count = file_count,
@@ -3230,6 +3261,7 @@ int cbm_parallel_resolve(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t *files,
32303261
.def_modules = def_modules,
32313262
.module_def_index = module_def_index,
32323263
.cross_registries = cross_registries,
3264+
.rust_manifest = rust_manifest_ptr,
32333265
};
32343266
atomic_init(&rc.next_file_idx, 0);
32353267
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,
32553287
rc.rust_shared_arena_live = false;
32563288
}
32573289
cbm_mutex_destroy(&rc.rust_shared_mu);
3290+
if (rust_manifest_arena_live) {
3291+
cbm_arena_destroy(&rust_manifest_arena);
3292+
}
32583293

32593294
/* Sub-phase: Merge all local edge bufs into main gbuf (SEQUENTIAL) */
32603295
CBM_PROF_START(t_resolve_merge);

‎tests/repro/repro_issue56.c‎

Lines changed: 13 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,17 @@
11
/*
2-
* repro_issue56.c — Reproduce-first case for OPEN bug #56.
2+
* repro_issue56.c — production-pipeline regression for Rust cross-crate calls.
33
*
4-
* Bug #56: "Cross-crate call graphs stop at boundaries" (Rust)
4+
* The fixture is a two-member Cargo workspace. crate_b calls
5+
* `crate_a::helper()` and also declares a same-named local helper, so generic
6+
* bare-name resolution cannot accidentally satisfy the assertion. The test
7+
* requires a persisted CALLS edge into crate_a's namespace.
58
*
6-
* ROOT CAUSE (pipeline / Rust LSP path):
7-
* The tree-sitter-only Rust extractor has no access to Cargo metadata
8-
* at extraction time, so when it sees `crate_a::helper()` inside
9-
* crate_b, it records a raw call-site for the path but has no registry
10-
* entry for `crate_a::helper` — only the definitions in the *same file*
11-
* were seeded. The LSP resolver therefore cannot match the call-site to
12-
* a callee QN across the crate boundary, and the resulting
13-
* CBMResolvedCall is either absent or marked with low confidence and
14-
* discarded. When the pipeline writes graph edges for this project, no
15-
* CALLS edge is minted for the cross-crate call — the call graph stops
16-
* at the crate edge.
17-
*
18-
* v0.8.1 added a hybrid-LSP Rust path that "materially improves" this
19-
* (issue comment, maintainer 2026-06-25), but the reporter was asked to
20-
* retest; the issue remains OPEN because no retest confirming resolution
21-
* was provided. The workspace-member wiring test
22-
* (rustlsp_extra_cargo_wires_workspace_member in test_rust_lsp.c) only
23-
* exercises the *single-file LSP* layer with a manually-parsed manifest;
24-
* it does NOT verify that the full production pipeline (rh_index_files →
25-
* cbm_pipeline → graph store) persists a cross-crate CALLS edge for a
26-
* real multi-file Cargo workspace fixture. That gap is what this test
27-
* fills.
28-
*
29-
* FIXTURE:
30-
* A minimal Cargo workspace with two crates:
31-
*
32-
* [workspace Cargo.toml] — workspace root, declares members
33-
* crate_a/Cargo.toml — library crate "crate_a"
34-
* crate_a/src/lib.rs — exposes `pub fn helper() {}`
35-
* crate_b/Cargo.toml — binary crate "crate_b", depends on crate_a
36-
* crate_b/src/main.rs — calls `crate_a::helper()` from `fn run()`;
37-
* also defines a LOCAL `fn helper()` to break
38-
* bare-name uniqueness (see note below)
39-
*
40-
* The only meaningful cross-crate CALLS edge is:
41-
* crate_b::run → crate_a::helper
42-
*
43-
* EXPECTED (correct) behaviour:
44-
* After indexing the workspace through the production MCP pipeline, the
45-
* graph store must contain at least one CALLS edge whose TARGET node's
46-
* qualified_name contains "crate_a" (i.e. routes into the crate_a
47-
* namespace, not into crate_b's local helper).
48-
*
49-
* ACTUAL (buggy) behaviour:
50-
* The pipeline extracts both files, but the cross-crate path
51-
* `crate_a::helper` in crate_b/src/main.rs is not resolved to a graph
52-
* node in crate_a because Cargo workspace member metadata is not
53-
* plumbed into the per-file extraction phase. Result: zero CALLS edges
54-
* to the crate_a namespace.
55-
*
56-
* WHY THIS IS RED ON CURRENT CODE (even post-v0.8.1):
57-
* The rustlsp_extra_cargo_wires_workspace_member unit test exercises only
58-
* the LSP layer (cbm_run_rust_lsp_with_manifest called with a parsed
59-
* CBMCargoManifest) and confirms the resolver *can* route
60-
* `engine::boot()` to `engine.boot` when given the manifest explicitly.
61-
* BUT: the production pipeline's per-file extraction path
62-
* (cbm_extract_file → cbm_run_rust_lsp) does NOT receive a pre-parsed
63-
* workspace manifest — it only gets the individual file's content.
64-
* Additionally, cbm_pxc_has_cross_lsp() returns false for CBM_LANG_RUST
65-
* (pass_lsp_cross.c), so the cross-file LSP pass is never invoked for
66-
* Rust. Therefore a real workspace indexed through index_repository
67-
* produces no CALLS edges crossing into crate_a, and this test is RED.
68-
*
69-
* WHY THE OLD >= 2 COUNT TEST FALSE-PASSED:
70-
* With a unique `helper` name in the project (one definition in crate_a,
71-
* no other `helper` anywhere), the generic pipeline name resolver
72-
* (registry.c, resolve_name_lookup) resolves `crate_a::helper` to the
73-
* sole `helper` candidate by bare-name suffix scoring — WITHOUT needing
74-
* any cross-crate workspace metadata. This produced calls >= 2 (the
75-
* intra-file main→run plus the bare-name-resolved run→helper), making
76-
* the old ASSERT_GTE(calls, 2) GREEN even though the bug was not fixed.
77-
*
78-
* Fix: add a LOCAL `fn helper()` in crate_b/src/main.rs so there are
79-
* now TWO `helper` candidates in the project registry. The generic
80-
* resolver either picks the wrong one (crate_b-local) or abstains
81-
* (ambiguous). Only a correctly crate-qualified resolver routes
82-
* `crate_a::helper` specifically to crate_a's node. The assertion then
83-
* checks the TARGET node's qualified_name contains "crate_a" — a count
84-
* check is no longer sufficient because the local helper also contributes
85-
* a CALLS edge (run_local→helper).
86-
*
87-
* UNCERTAINTY:
88-
* If a future version plumbs workspace metadata or wires Rust lsp_cross
89-
* correctly, this test will go GREEN — that is the intended outcome.
9+
* The direct parallel regression lives in test_parallel.c because the bug was
10+
* specific to worker-local pipeline context: the root Cargo manifest was
11+
* parsed by the sequential cross-LSP driver but was not propagated into
12+
* parallel resolve workers. A confident local result could also suppress the
13+
* manifest-aware cross pass. This repro remains as an end-to-end guard for the
14+
* same graph contract through the production harness.
9015
*/
9116

9217
#include "test_framework.h"
@@ -108,19 +33,8 @@
10833
* CALLS edge's TARGET node has a qualified_name containing "crate_a",
10934
* proving the cross-crate boundary was traversed.
11035
*
111-
* RED condition:
112-
* No CALLS edge whose target QN contains "crate_a" exists in the store.
113-
*
114-
* This test is RED on current code because:
115-
* 1. cbm_run_rust_lsp is called with NULL manifest (cbm.c:645), so no
116-
* workspace metadata is available at extraction time.
117-
* 2. cbm_pxc_has_cross_lsp returns false for CBM_LANG_RUST
118-
* (pass_lsp_cross.c:281), so the cross-file LSP pass never runs for
119-
* Rust and cannot seed crate_a defs into crate_b's resolver context.
120-
* 3. With two `helper` candidates (crate_a and crate_b-local), the
121-
* generic resolver's qualified_suffix_match fails (neither QN ends
122-
* with ".crate_a.helper") and bare-name scoring picks the crate_b-
123-
* local one or abstains, never routing to crate_a.
36+
* Regression condition: no CALLS edge whose target QN contains "crate_a"
37+
* exists in the store.
12438
*/
12539
TEST(repro_issue56_cross_crate_calls) {
12640
/*

0 commit comments

Comments
 (0)