diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 15f96ba17..c25419d3a 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -6660,6 +6660,18 @@ static void extract_class_fields(CBMExtractCtx *ctx, TSNode class_node, const ch return; } + // Go: struct_type wraps fields in a field_declaration_list whose named + // children are the actual field_declaration nodes. The generic scan below + // matches direct children of `body` only, so unwrap the list — otherwise + // every Go struct registers with zero fields and cross-package field-chain + // calls (h.svc.Handle) can never resolve. + if (ctx->language == CBM_LANG_GO && strcmp(ts_node_type(body), "struct_type") == 0) { + TSNode fdl = cbm_find_child_by_kind(body, "field_declaration_list"); + if (!ts_node_is_null(fdl)) { + body = fdl; + } + } + CBMArena *a = ctx->arena; uint32_t count = ts_node_named_child_count(body); for (uint32_t i = 0; i < count; i++) { diff --git a/internal/cbm/lsp/go_lsp.c b/internal/cbm/lsp/go_lsp.c index 7dd1c9c78..b62757059 100644 --- a/internal/cbm/lsp/go_lsp.c +++ b/internal/cbm/lsp/go_lsp.c @@ -4,6 +4,24 @@ #include #include #include +#include + +/* CBM_DISPATCH_TRACE diagnostics sink. The Go resolve worker's stderr is + * swallowed by the daemon supervisor, so dispatch traces route to a file + * instead: CBM_TRACE_FILE, default /tmp/cbm_dispatch_trace.txt (append). */ +static FILE *cbm_dispatch_trace_sink(void) { + static FILE *sink = NULL; + static _Atomic int tried = 0; + if (atomic_exchange_explicit(&tried, 1, memory_order_relaxed) == 0) { + const char *d = getenv("CBM_DISPATCH_TRACE"); + if (d && d[0]) { + const char *path = getenv("CBM_TRACE_FILE"); + if (!path || !path[0]) path = "/tmp/cbm_dispatch_trace.txt"; + sink = fopen(path, "a"); + } + } + return sink; +} // Forward declarations static void resolve_calls_in_node_inner(GoLSPContext* ctx, TSNode node); @@ -866,11 +884,53 @@ const CBMType* go_eval_builtin_call(GoLSPContext* ctx, const char* name, TSNode // --- go_lookup_field: struct field lookup with embedding recursion --- +// --- Import-alias re-qualification ------------------------------------ +// +// parse_field_defs_into_type qualifies struct field type texts as +// ".". When the author wrote the text through an import +// alias ("Svc:svc.Svc" in module test.main), that yields a QN +// ("test.main.svc.Svc") that exists nowhere in the project-wide registry — +// the real QN is ".Svc" and only the calling file's import map +// can say so. On an exact-QN miss this rewrites the alias segment through +// the file's imports; returns NULL when no alias segment is involved. + +static const char *go_requalify_via_imports(GoLSPContext *ctx, const char *type_qn) { + if (!ctx || !type_qn || !type_qn[0] || ctx->import_count <= 0) return NULL; + for (int j = 0; j < ctx->import_count; j++) { + const char *alias = ctx->import_local_names[j]; + const char *alias_qn = ctx->import_package_qns[j]; + if (!alias || !alias[0] || !alias_qn || strchr(alias, '.')) continue; + size_t alias_len = strlen(alias); + /* Last occurrence of a "alias" segment in type_qn. */ + const char *hit = NULL; + for (const char *p = type_qn;;) { + p = strstr(p, "."); + if (!p) break; + p++; + if (strncmp(p, alias, alias_len) == 0 && p[alias_len] == '.') { + hit = p; + p += alias_len; + } + } + if (hit) { + const char *rest = hit + alias_len + 1; /* past "." */ + return cbm_arena_sprintf(ctx->arena, "%s.%s", alias_qn, rest); + } + } + return NULL; +} + static const CBMType* go_lookup_field(GoLSPContext* ctx, const char* type_qn, const char* field_name, int depth) { if (!type_qn || !field_name || depth > 5) return NULL; const CBMRegisteredType* rt = cbm_registry_lookup_type(ctx->registry, type_qn); + if (!rt && depth == 0) { + /* Import-alias re-qualification: field texts from cross-package defs + * may embed an alias segment only this file's import map resolves. */ + const char* alt_qn = go_requalify_via_imports(ctx, type_qn); + if (alt_qn) rt = cbm_registry_lookup_type(ctx->registry, alt_qn); + } if (!rt) return NULL; // Follow alias chain @@ -907,6 +967,18 @@ static const CBMRegisteredFunc* go_lookup_field_or_method_depth(GoLSPContext* ct const CBMRegisteredFunc* f = cbm_registry_lookup_method(ctx->registry, type_qn, member_name); if (f) return f; + /* Import-alias re-qualification fallback: NAMED receivers built from + * cross-package field type texts can carry a ".svc.Svc" QN; + * retry the method set on the import-resolved QN (see + * go_requalify_via_imports). */ + if (depth == 0) { + const char* alt_qn = go_requalify_via_imports(ctx, type_qn); + if (alt_qn) { + f = go_lookup_field_or_method_depth(ctx, alt_qn, member_name, depth + 1); + if (f) return f; + } + } + const CBMRegisteredType* rt = cbm_registry_lookup_type(ctx->registry, type_qn); if (rt) { // Follow type alias chain @@ -1430,22 +1502,55 @@ static void resolve_calls_in_node_inner(GoLSPContext* ctx, TSNode node) { if (base && base->kind == CBM_TYPE_POINTER) base = cbm_type_deref(base); if (base && base->kind == CBM_TYPE_NAMED) { + const char *recv_qn = base->data.named.qualified_name; const CBMRegisteredType *receiver_type = cbm_registry_lookup_type( - ctx->registry, base->data.named.qualified_name); + ctx->registry, recv_qn); + const char *alt_qn = NULL; + /* Re-qualify NAMED receivers that embed an import + * alias segment (cross-package field type texts) — + * the real type only exists under the import QN. */ + if (!receiver_type) { + alt_qn = go_requalify_via_imports(ctx, recv_qn); + if (alt_qn) { + receiver_type = cbm_registry_lookup_type(ctx->registry, alt_qn); + if (receiver_type) recv_qn = alt_qn; + } + } + { + static _Atomic int g_diag_once = 0; + FILE *tf = cbm_dispatch_trace_sink(); + if (tf && atomic_fetch_add_explicit(&g_diag_once, 1, + memory_order_relaxed) < 200000) { + fprintf(tf, "[DISPATCH] pkg=%s call=%s recv_qn=%s recv_hit=%d alt=%s reg_has_type_total=%d\n", + ctx->package_qn ? ctx->package_qn : "?", field_name ? field_name : "?", + base->data.named.qualified_name ? base->data.named.qualified_name : "?", + receiver_type != NULL, alt_qn ? alt_qn : "-", ctx->registry->type_count); + } + } /* Registered interface receivers must reach the * interface-resolution branch below. Their semantic * method registrations are signatures, not concrete * dispatch targets. */ if (!receiver_type || !receiver_type->is_interface) { const CBMRegisteredFunc *method = go_lookup_field_or_method( - ctx, base->data.named.qualified_name, field_name); + ctx, recv_qn, field_name); if (method) { const char *strategy = "lsp_type_dispatch"; if (method->receiver_type && strcmp(method->receiver_type, - base->data.named.qualified_name) != 0) { + recv_qn) != 0) { strategy = "lsp_embed_dispatch"; } + { + FILE *tf = cbm_dispatch_trace_sink(); + if (tf) { + fprintf(tf, "[DISPATCH-OK] pkg=%s call=%s target=%s strat=%s recv=%s\n", + ctx->package_qn ? ctx->package_qn : "?", + field_name ? field_name : "?", + method->qualified_name ? method->qualified_name : "?", + strategy, recv_qn ? recv_qn : "?"); + } + } emit_resolved_call(ctx, method->qualified_name, strategy, 0.95f, node); goto recurse; @@ -1460,9 +1565,15 @@ static void resolve_calls_in_node_inner(GoLSPContext* ctx, TSNode node) { if (!is_iface && base->kind == CBM_TYPE_NAMED) { const CBMRegisteredType* rt = cbm_registry_lookup_type(ctx->registry, base->data.named.qualified_name); + if (!rt) { + const char* alt_qn = go_requalify_via_imports( + ctx, base->data.named.qualified_name); + if (alt_qn) + rt = cbm_registry_lookup_type(ctx->registry, alt_qn); + } if (rt && rt->is_interface) { is_iface = true; - iface_qn = base->data.named.qualified_name; + iface_qn = rt->qualified_name; } } if (is_iface) { @@ -1533,12 +1644,31 @@ static void resolve_calls_in_node_inner(GoLSPContext* ctx, TSNode node) { // Type resolved to NAMED but neither method nor interface matched if (base && base->kind == CBM_TYPE_NAMED) { + { + FILE *tf = cbm_dispatch_trace_sink(); + if (tf) { + fprintf(tf, "[DISPATCH-FAIL] pkg=%s call=%s.%s qn=%s type_total=%d\n", + ctx->package_qn ? ctx->package_qn : "?", + base->data.named.qualified_name ? base->data.named.qualified_name : "?", + field_name ? field_name : "?", "method_not_found", + ctx->registry->type_count); + } + } emit_unresolved_call(ctx, cbm_arena_sprintf(ctx->arena, "%s.%s", base->data.named.qualified_name, field_name), "method_not_found", node); } else if (cbm_type_is_unknown(recv_type)) { + { + FILE *tf = cbm_dispatch_trace_sink(); + if (tf) { + fprintf(tf, "[DISPATCH-FAIL] pkg=%s call=%s qn=%s type_total=%d\n", + ctx->package_qn ? ctx->package_qn : "?", + field_name ? field_name : "?", "unknown_receiver_type", + ctx->registry->type_count); + } + } char* operand_text = lsp_node_text(ctx, operand); emit_unresolved_call( ctx, @@ -1787,42 +1917,15 @@ static void process_function(GoLSPContext* ctx, TSNode func_node) { char* func_name = lsp_node_text(ctx, name_node); if (!func_name || !func_name[0]) return; - // For methods, the enclosing-function QN must include the receiver type - // (package.Type.Method), matching how the textual extractor and the - // registry qualify the method. Building it as package.Method (no receiver) - // here made the LSP-resolved call's caller_qn disagree with the textual - // call's enclosing_func_qn, so cbm_pipeline_find_lsp_resolution never - // joined them — every call inside a method body silently lost its - // type-aware LSP strategy. Derive the bare receiver type name the same way - // the receiver binding below does. - char* recv_type_name = NULL; - { - TSNode recv0 = ts_node_child_by_field_name(func_node, "receiver", 8); - if (!ts_node_is_null(recv0)) { - uint32_t rnc0 = ts_node_child_count(recv0); - for (uint32_t i = 0; i < rnc0 && !recv_type_name; i++) { - TSNode rp = ts_node_child(recv0, i); - if (ts_node_is_null(rp) || !ts_node_is_named(rp)) continue; - if (strcmp(ts_node_type(rp), "parameter_declaration") != 0) continue; - TSNode rtype = ts_node_child_by_field_name(rp, "type", 4); - if (ts_node_is_null(rtype)) continue; - // Unwrap a pointer receiver (*Type) to the bare type identifier. - const char* rtk = ts_node_type(rtype); - if (strcmp(rtk, "pointer_type") == 0 && ts_node_named_child_count(rtype) > 0) { - rtype = ts_node_named_child(rtype, 0); - } - char* tn = lsp_node_text(ctx, rtype); - if (tn && tn[0]) recv_type_name = tn; - } - } - } - - if (recv_type_name) { - ctx->enclosing_func_qn = - cbm_arena_sprintf(ctx->arena, "%s.%s.%s", ctx->package_qn, recv_type_name, func_name); - } else { - ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->package_qn, func_name); - } + // Enclosing-function QN must be the BARE package.Func form (no receiver + // type segment). The textual call events (extract_unified.c) source calls + // as package_qn.func_name — methods included — and the defs pass creates + // the graph Method node under the same QN, so any other form breaks the + // caller-QN join in cbm_pipeline_find_lsp_resolution and the LSP-resolved + // call silently falls back to the registry short-name resolver. The + // receiver type still reaches the registry via the def's parent_class / + // method->receiver_type; it just does not appear in the caller QN. + ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->package_qn, func_name); // Push function scope CBMScope* saved_scope = ctx->current_scope; diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index ab92301e8..704a73d41 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -413,6 +413,73 @@ static int pxc_build_lsp_def(CBMArena *arena, const CBMDefinition *src, const ch return 0; } +/* Go: fold per-field "Field" definitions into their owning struct's + * field_defs. extract_defs.c emits one flat CBMDefinition per struct field + * (label "Field", parent_class = owning struct QN, name = field name, + * return_type = raw type text). Those rows are dropped by pxc_build_lsp_def + * (pxc_map_label excludes "Field"), so without this fold every Go struct + * registers with zero fields and field-chain calls (h.svc.Handle) can + * never resolve. Fields are always declared in the same file as their struct, + * so scanning the file's own defs covers every case. Runs inside + * cbm_pxc_collect_all_defs — one site covers both the prebuilt-registry path + * and the per-file fallback, since both consume all_defs. */ +static void pxc_fold_go_struct_fields(CBMArena *arena, const CBMFileResult *result, CBMLSPDef *defs, + int start, int end) { + if (!arena || !result || !defs || start >= end) { + return; + } + for (int si = start; si < end; si++) { + CBMLSPDef *dst = &defs[si]; + if (!dst->label || strcmp(dst->label, "Struct") != 0 || !dst->qualified_name) { + continue; + } + int count = 0; + size_t total = 0; /* "name:type" bytes; separators and NUL added below */ + for (int di = 0; di < result->defs.count; di++) { + const CBMDefinition *fd = &result->defs.items[di]; + if (!fd->label || !fd->parent_class || !fd->name || !fd->name[0] || !fd->return_type || + !fd->return_type[0] || strcmp(fd->label, "Field") != 0 || + strcmp(fd->parent_class, dst->qualified_name) != 0) { + continue; + } + total += strlen(fd->name) + 1 + strlen(fd->return_type); + count++; + } + if (count == 0) { + continue; + } + /* count - 1 separators + NUL. */ + size_t bufsz = total + (size_t)(count - 1) + 1; + char *buf = (char *)cbm_arena_alloc(arena, bufsz); + if (!buf) { + continue; + } + char *p = buf; + int written = 0; + for (int di = 0; di < result->defs.count; di++) { + const CBMDefinition *fd = &result->defs.items[di]; + if (!fd->label || !fd->parent_class || !fd->name || !fd->name[0] || !fd->return_type || + !fd->return_type[0] || strcmp(fd->label, "Field") != 0 || + strcmp(fd->parent_class, dst->qualified_name) != 0) { + continue; + } + size_t n = strlen(fd->name); + memcpy(p, fd->name, n); + p += n; + *p++ = ':'; + n = strlen(fd->return_type); + memcpy(p, fd->return_type, n); + p += n; + if (written + 1 < count) { + *p++ = '|'; + } + written++; + } + *p = '\0'; + dst->field_defs = buf; + } +} + /* Carry one Rust type-level impl independently of any method definition. * `impl Trait for Type {}` is semantically meaningful even when the block is * empty (the trait may provide defaults), so attaching the relation only to @@ -472,6 +539,7 @@ CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMFileResult if (out_def_starts) { out_def_starts[fi] = idx; } + const int file_start = idx; if (!cache[fi]) continue; if (!def_modules[fi]) { @@ -510,6 +578,9 @@ CBMLSPDef *cbm_pxc_collect_all_defs(const cbm_pipeline_ctx_t *ctx, CBMFileResult } } cbm_pxc_free_import_map(imp_keys, imp_vals, imp_count); /* NULL-safe */ + if (files[fi].language == CBM_LANG_GO) { + pxc_fold_go_struct_fields(&cache[fi]->arena, cache[fi], defs, file_start, idx); + } if (files[fi].language == CBM_LANG_RUST) { for (int ii = 0; ii < cache[fi]->impl_traits.count; ii++) { if (pxc_build_rust_impl_relation( @@ -1218,8 +1289,16 @@ void cbm_pxc_dispatch_file(CBMLanguage lang, CBMFileResult *result, const char * switch (lang) { case CBM_LANG_GO: /* Tier 3 (metadata-driven): pure lookup over the Tier-1 - * lsp_unresolved entries — no parse, no AST walk. */ + * lsp_unresolved entries — no parse, no AST walk. Then the + * AST walk on the shared Tier-2 registry (mirroring every + * other language) so NAMED receivers evaluated against + * project-wide defs also resolve. The walk variant below is + * read-only — the sealed registry is safe for parallel + * workers. */ cbm_go_fast_resolve_qualified_calls(result, prebuilt, imp_keys, imp_vals, imp_count); + cbm_run_go_lsp_cross_with_registry(&result->arena, source, source_len, def_module, + prebuilt, imp_keys, imp_vals, imp_count, + result->cached_tree, &result->resolved_calls); used_prebuilt = true; break; case CBM_LANG_PYTHON: { diff --git a/tests/test_go_lsp.c b/tests/test_go_lsp.c index c77e5ee80..e852f4aca 100644 --- a/tests/test_go_lsp.c +++ b/tests/test_go_lsp.c @@ -1285,6 +1285,99 @@ TEST(golsp_crossfile_stdlib_interface) { PASS(); } +/* Cross-package receiver-method resolution when struct field type texts are + * written through import aliases. parse_field_defs_into_type qualifies the + * text with the defining module ("Svc:svc.Svc" in module test.main becomes + * "test.main.svc.Svc") — a QN that exists nowhere in the project-wide + * registry, so the dispatch must re-qualify the alias segment through the + * calling file's import map and land on the real receiver type. Mirrors a + * common cross-module shape: a service struct field typed from an sdk module + * and an interface-typed client field from an api module. */ +TEST(golsp_crossfile_aliased_field_requal) { + const char *source = "package main\n\n" + "func callSvc(h *Handler) error {\n" + "\th.Svc.Ping()\n\treturn nil\n}\n\n" + "func callPb(h *Holder) error {\n" + "\th.C.Ping()\n\treturn nil\n}\n"; + + CBMLSPDef defs[] = { + /* myapp/svc — concrete service struct */ + {.qualified_name = "myapp/svc.Svc", + .short_name = "Svc", + .label = "Struct", + .def_module_qn = "myapp/svc"}, + /* test.main — receiver structs; field texts use import aliases, the + * trigger for the wrong qualification */ + {.qualified_name = "test.main.Handler", + .short_name = "Handler", + .label = "Struct", + .def_module_qn = "test.main", + .field_defs = "Svc:svc.Svc"}, + {.qualified_name = "test.main.Holder", + .short_name = "Holder", + .label = "Struct", + .def_module_qn = "test.main", + .field_defs = "C:pb.Client"}, + /* myapp/pb — client interface */ + {.qualified_name = "myapp/pb.Client", + .short_name = "Client", + .label = "Interface", + .def_module_qn = "myapp/pb", + .is_interface = true, + .method_names_str = "Ping"}, + /* methods, after their receiver types (extraction order) */ + {.qualified_name = "myapp/svc.Svc.Ping", + .short_name = "Ping", + .label = "Method", + .def_module_qn = "myapp/svc", + .receiver_type = "myapp/svc.Svc", + .return_types = "error"}, + {.qualified_name = "myapp/pb.Client.Ping", + .short_name = "Ping", + .label = "Method", + .def_module_qn = "myapp/pb", + .receiver_type = "myapp/pb.Client", + .return_types = "error"}, + }; + const char *imp_names[] = {"svc", "pb"}; + const char *imp_qns[] = {"myapp/svc", "myapp/pb"}; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + + CBMTypeRegistry *reg = cbm_go_build_cross_registry(&arena, defs, 6); + ASSERT_NOT_NULL(reg); + + cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", reg, + imp_names, imp_qns, 2, NULL, &out); + + int svc_idx = find_resolved_arr_confident(&out, "callSvc", "Svc.Ping"); + if (svc_idx < 0) { + printf(" cross-registry diagnostics (%d records):\n", out.count); + for (int i = 0; i < out.count; i++) { + const CBMResolvedCall *rc = &out.items[i]; + printf(" %s -> %s [%s %.2f]\n", rc->caller_qn ? rc->caller_qn : "(null)", + rc->callee_qn ? rc->callee_qn : "(null)", + rc->strategy ? rc->strategy : "(null)", rc->confidence); + } + } + ASSERT_GTE(svc_idx, 0); + ASSERT_STR_EQ(out.items[svc_idx].callee_qn, "myapp/svc.Svc.Ping"); + ASSERT_STR_EQ(out.items[svc_idx].strategy, "lsp_type_dispatch"); + ASSERT_TRUE(out.items[svc_idx].confidence >= 0.9f); + + int pb_idx = find_resolved_arr_confident(&out, "callPb", "Client.Ping"); + ASSERT_GTE(pb_idx, 0); + ASSERT_STR_EQ(out.items[pb_idx].callee_qn, "myapp/pb.Client.Ping"); + ASSERT_TRUE(strcmp(out.items[pb_idx].strategy, "lsp_interface_dispatch") == 0 || + strcmp(out.items[pb_idx].strategy, "lsp_type_dispatch") == 0); + ASSERT_TRUE(out.items[pb_idx].confidence >= 0.8f); + + cbm_arena_destroy(&arena); + PASS(); +} + TEST(golsp_crossfile_local_interface_single_impl) { const char *source = "package main\n\n" @@ -1448,6 +1541,7 @@ SUITE(go_lsp) { RUN_TEST(golsp_crossfile_return_type_chain); RUN_TEST(golsp_crossfile_interface_dispatch); RUN_TEST(golsp_crossfile_interface_field_chain); + RUN_TEST(golsp_crossfile_aliased_field_requal); RUN_TEST(golsp_crossfile_map_index); RUN_TEST(golsp_crossfile_stdlib_interface); RUN_TEST(golsp_crossfile_local_interface_single_impl); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 181c1f7ca..631446f9c 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -3203,6 +3203,233 @@ TEST(parallel_python_lsp_override_emits_lsp_strategy_edges) { PASS(); } +/* ── Go cross-package field-chain fixture (field_defs fold) ─────── */ + +/* Production's sequential driver seeds Folder nodes via pass_structure; the + * compact harness (run_sequential_with_lsp_cross_*) does not. Go imports + * resolve to Folder nodes, so a Go import-map fixture must seed File and + * Folder nodes itself — replicating the production shape. */ +static cbm_gbuf_t *run_go_field_chain_sequential(const char *project, const char *repo_path, + cbm_file_info_t *files, int file_count) { + cbm_gbuf_t *gbuf = cbm_gbuf_new(project, repo_path); + cbm_registry_t *reg = cbm_registry_new(); + CBMFileResult **cache = (CBMFileResult **)calloc((size_t)file_count, sizeof(CBMFileResult *)); + if (!gbuf || !reg || !cache) { + cbm_gbuf_free(gbuf); + cbm_registry_free(reg); + free(cache); + return NULL; + } + atomic_int cancelled; + atomic_init(&cancelled, 0); + cbm_pipeline_ctx_t ctx = { + .project_name = project, + .repo_path = repo_path, + .gbuf = gbuf, + .registry = reg, + .cancelled = &cancelled, + .result_cache = cache, + }; + + seed_test_file_nodes(gbuf, project, files, file_count); + char *svc_dir_qn = cbm_pipeline_fqn_folder(project, "svc"); + if (svc_dir_qn) { + cbm_gbuf_upsert_node(gbuf, "Folder", "svc", svc_dir_qn, "svc", 0, 0, "{}"); + free(svc_dir_qn); + } + + cbm_init(); + cbm_pipeline_pass_definitions(&ctx, files, file_count); + cbm_pipeline_pass_lsp_cross(&ctx, files, file_count, cache); + cbm_pipeline_pass_calls(&ctx, files, file_count); + cbm_pipeline_pass_usages(&ctx, files, file_count); + cbm_pipeline_pass_semantic(&ctx, files, file_count); + + /* CBM_GO_FIELD_DIAG dump. NOTE: resolved-call records may borrow QN + * strings across file results (cross-file resolution in pass_lsp_cross), + * so every result must stay alive while ANY is inspected. Print all + * first, free all second. */ + if (getenv("CBM_GO_FIELD_DIAG")) { + for (int i = 0; i < file_count; i++) { + if (!cache[i]) { + continue; + } + const CBMFileResult *r = cache[i]; + printf(" [diag] file %s defs=%d imports=%d calls=%d resolved=%d\n", files[i].rel_path, + r->defs.count, r->imports.count, r->calls.count, r->resolved_calls.count); + for (int j = 0; j < r->resolved_calls.count; j++) { + const CBMResolvedCall *rc = &r->resolved_calls.items[j]; + printf(" [diag] rc caller=%s callee=%s strategy=%s conf=%.2f kind=%d span=[%u,%u)\n", + rc->caller_qn ? rc->caller_qn : "?", rc->callee_qn ? rc->callee_qn : "?", + rc->strategy ? rc->strategy : "?", rc->confidence, (int)rc->kind, + (unsigned)rc->site_start_byte, (unsigned)rc->site_end_byte); + } + for (int j = 0; j < r->calls.count; j++) { + const CBMCall *c = &r->calls.items[j]; + printf(" [diag] call callee=%s enclosing=%s span=[%u,%u) req=%d\n", + c->callee_name ? c->callee_name : "?", c->enclosing_func_qn ? c->enclosing_func_qn : "?", + (unsigned)c->site_start_byte, (unsigned)c->site_end_byte, (int)c->requires_lsp_resolution); + } + for (int j = 0; j < r->defs.count; j++) { + const CBMDefinition *d = &r->defs.items[j]; + printf(" [diag] def label=%s qn=%s parent=%s ret=%s\n", d->label ? d->label : "?", + d->qualified_name ? d->qualified_name : "?", d->parent_class ? d->parent_class : "?", + d->return_type ? d->return_type : "?"); + } + } + } + for (int i = 0; i < file_count; i++) { + cbm_free_result(cache[i]); + } + free(cache); + harness_ctx_free_tables(&ctx); + cbm_registry_free(reg); + if (ctx.seq_cross_arena_live) { + cbm_arena_destroy(&ctx.seq_cross_arena); + ctx.seq_cross_arena_live = false; + } + if (ctx.seq_cross_def_modules) { + for (int i = 0; i < ctx.seq_cross_def_module_count; i++) { + free(ctx.seq_cross_def_modules[i]); + } + free(ctx.seq_cross_def_modules); + ctx.seq_cross_def_modules = NULL; + } + return gbuf; +} + +/* Cross-package field chains (h.S.Ping()) resolve end to end through the + * shared prebuilt Go registry. Regression for the field_defs fold: Go struct + * fields are extracted as flat "Field" definitions that pxc_map_label drops, + * so structs registered with zero fields and field chains never resolved. + * Mirrors the real-world handler shape — an app package holds an aliased + * cross-package field ("S *s.Svc") and app.Call calls through it. */ +TEST(parallel_go_cross_package_field_chain_resolves) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_par_gofold_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + FAIL("mkdtemp failed"); + } + + char svc_path[512]; + char app_path[512]; + snprintf(svc_path, sizeof(svc_path), "%s/acme-order/internal/service/order_service.go", tmpdir); + snprintf(app_path, sizeof(app_path), "%s/acme-order/internal/handler/order_handler.go", tmpdir); + if (th_write_file(svc_path, "package service\n" + "\n" + "import (\n" + " \"context\"\n" + " pb \"acme-sdk/apis/order\"\n" + ")\n" + "\n" + "type OrderService struct{}\n" + "\n" + "func (s *OrderService) PlaceOrder(ctx context.Context, req *pb.PlaceOrderReq) (*pb.PlaceOrderRsp, error) {\n" + " return nil, nil\n" + "}\n" + "\n" + "func (s *OrderService) ListOrders(ctx context.Context, req *pb.ListOrdersReq) (*pb.ListOrdersRsp, error) {\n" + " return nil, nil\n" + "}\n") != 0 || + th_write_file(app_path, "package handler\n" + "\n" + "import (\n" + " \"context\"\n" + "\n" + " pb \"acme-sdk/apis/order\"\n" + "\n" + " \"acme-order/internal/service\"\n" + ")\n" + "\n" + "type OrderHandler struct {\n" + " pb.UnimplementedOrderServer\n" + " cartSvc *service.CartService\n" + " userSvc *service.UserService\n" + " orderSvc *service.OrderService\n" + " inventorySvc *service.InventoryService\n" + " paymentSvc *service.PaymentService\n" + " shipmentSvc *service.ShipmentService\n" + " couponSvc *service.CouponService\n" + " searchSvc *service.SearchService\n" + "}\n" + "\n" + "func (h *OrderHandler) ListOrders(ctx context.Context, req *pb.ListOrdersReq) (*pb.ListOrdersRsp, error) {\n" + " return h.orderSvc.ListOrders(ctx, req)\n" + "}\n" + "\n" + "func (h *OrderHandler) PlaceOrder(ctx context.Context, req *pb.PlaceOrderReq) (*pb.PlaceOrderRsp, error) {\n" + " return h.orderSvc.PlaceOrder(ctx, req)\n" + "}\n" + "\n" + "func (h *OrderHandler) UpdateCart(ctx context.Context, req *pb.UpdateCartReq) (*pb.UpdateCartRsp, error) {\n" + " return h.cartSvc.UpdateCart(ctx, req)\n" + "}\n") != 0) { + th_rmtree(tmpdir); + FAIL("write fixture failed"); + } + + cbm_file_info_t files[2] = {0}; + files[0].path = svc_path; + files[0].rel_path = (char *)"acme-order/internal/service/order_service.go"; + files[0].language = CBM_LANG_GO; + files[1].path = app_path; + files[1].rel_path = (char *)"acme-order/internal/handler/order_handler.go"; + files[1].language = CBM_LANG_GO; + + cbm_gbuf_t *gbuf = run_go_field_chain_sequential("go_field_fold", tmpdir, files, 2); + ASSERT_NOT_NULL(gbuf); + + const cbm_gbuf_edge_t *edge = find_call_edge_to_target_fragment(gbuf, "handler.PlaceOrder", ".service.PlaceOrder"); + const bool found = edge != NULL; + const bool dispatch = + edge && edge->properties_json && + strstr(edge->properties_json, "\"strategy\":\"lsp_type_dispatch\""); + if (!found || !dispatch) { + printf(" go field chain diagnostic: found=%d dispatch=%d\n", found, dispatch); + if (edge && edge->properties_json) { + printf(" go field chain props: %s\n", edge->properties_json); + } + /* Dump all callable nodes and CALLS edges for cross-referencing */ + { + const cbm_gbuf_node_t **nodes = NULL; + int ncount = 0; + if (cbm_gbuf_find_by_label(gbuf, "Function", &nodes, &ncount) == 0) { + for (int i = 0; i < ncount; i++) { + printf(" node Function: %s\n", nodes[i]->qualified_name); + } + } + if (cbm_gbuf_find_by_label(gbuf, "Method", &nodes, &ncount) == 0) { + for (int i = 0; i < ncount; i++) { + printf(" node Method: %s\n", nodes[i]->qualified_name); + } + } + cbm_gbuf_edge_visitor_fn edge_dump = NULL; + (void)edge_dump; + /* print every CALLS edge with endpoints */ + const cbm_gbuf_edge_t **all = NULL; + int ecount = 0; + if (cbm_gbuf_find_edges_by_type(gbuf, "CALLS", &all, &ecount) == 0) { + for (int i = 0; i < ecount; i++) { + const cbm_gbuf_node_t *src = + all[i] ? cbm_gbuf_find_by_id(gbuf, all[i]->source_id) : NULL; + const cbm_gbuf_node_t *dst = + all[i] ? cbm_gbuf_find_by_id(gbuf, all[i]->target_id) : NULL; + printf(" CALLS edge: %s -> %s props=%s\n", + src ? src->qualified_name : "?", + dst ? dst->qualified_name : "?", + all[i]->properties_json ? all[i]->properties_json : "{}"); + } + } + } + } + cbm_gbuf_free(gbuf); + th_rmtree(tmpdir); + + ASSERT_TRUE(found); + ASSERT_TRUE(dispatch); + PASS(); +} + /* Cross-file regression for the QN-mismatch bug: py_lsp's per-file mode * emits resolved_calls.callee_qn as the raw import-module path (e.g. * `greeter.Greeter` from `from greeter import Greeter`) rather than the @@ -4108,6 +4335,7 @@ SUITE(parallel) { RUN_TEST(parallel_cpp_preprocessed_coordinate_collision_preserves_hidden_target); RUN_TEST(parallel_cuda_preprocessed_coordinate_collision_preserves_hidden_target); RUN_TEST(parallel_python_lsp_override_cross_file_emits_lsp_strategy_edges); + 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_lsp_tail_match_fallbacks_gated_to_jvm);