From 651ae3c2af1a22ec61a498b39350785aee480973 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Fri, 28 Aug 2026 17:26:26 +0200 Subject: [PATCH 1/4] fix(pipeline): suppress weak short-name matches for Go selector calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Go selector call x.foo() whose receiver the Go LSP cannot type falls through to the generic registry resolver, which binds it by bare short name to an arbitrary same-named project symbol. Stdlib calls are the worst case: f.Close() on an *os.File gets a CALLS edge to whatever project Close wins candidate ranking (measured on a real Go repo: confidence 0.11, 15 candidates; suffix_match + unique_name were 36% of all CALLS edges, and one 14-line stdlib-only function got 3 out of 3 false outbound edges). Extend the TS/JS receiver-aware guard (#592/#606) to Go: - extract_calls.c: flag Go call_expression with a selector_expression callee as is_method, mirroring the TS/JS member_expression flag. - registry.c: add cbm_go_suppress_weak_method_match. Unlike the TS/JS drop-list, field_type_hint is KEPT (Go struct fields carry declared types, so the hint is receiver-aware — lrp_go_s8_field_type_hint), and unique_name is dropped only when its confidence carries the import-unreachability penalty (the stdlib-hijack shape); an unpenalized lone candidate inside the caller's import closure never enters the field-type-hint upgrade and must survive. - pass_calls.c / pass_parallel.c: feed the Go gate next to the TS/JS one; the drop still defers to the emit path so service/route/HTTP edges stay main-identical. Reproduce-first: pipeline_go_receiver_suppresses_weak_method_edge is RED without the extractor flag (the f.Close -> project Close edge exists) and GREEN with it; typed same-package calls, bare local calls and import-qualified cross-package calls still resolve. The old extraction contract test used Go as the flag-exempt language — Python takes that role, and extract_go_selector_call_flags_is_method pins the new behavior. Signed-off-by: Ilya Brykau --- internal/cbm/extract_calls.c | 18 +++++++ src/pipeline/pass_calls.c | 12 ++++- src/pipeline/pass_parallel.c | 8 ++- src/pipeline/pipeline.h | 9 ++++ src/pipeline/registry.c | 27 +++++++++++ tests/test_extraction.c | 39 +++++++++++++-- tests/test_pipeline.c | 94 ++++++++++++++++++++++++++++++++++++ tests/test_registry.c | 42 ++++++++++++++++ 8 files changed, 242 insertions(+), 7 deletions(-) diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c index 6bb4a32bce..b7661d5e6d 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -3619,6 +3619,24 @@ CBMInvocationDescriptor handle_calls(CBMExtractCtx *ctx, TSNode node, const CBML } } } + // Go receiver-aware guard (same direction as the TS/JS flag above). + // Flag a selector call x.foo(). The Go AST cannot separate a method + // call on a value from a package-qualified call — but every selector + // call the Go LSP or the import/qualified registry strategies CAN + // place never reaches the weak short-name guards, so the flag only + // bites on unresolvable receivers (`f.Close()` on an os.File, + // `sha256.New()` behind an unindexed import), where a project-wide + // short-name match fabricates an edge to an unrelated project + // symbol sharing the name. Bare calls (helper()) keep + // is_method=false and resolve same-module/import paths as before. + if (ctx->language == CBM_LANG_GO && + strcmp(ts_node_type(node), "call_expression") == 0) { + TSNode gofn = ts_node_child_by_field_name(node, TS_FIELD("function")); + if (!ts_node_is_null(gofn) && + strcmp(ts_node_type(gofn), "selector_expression") == 0) { + call.is_method = true; + } + } TSNode args = ts_node_child_by_field_name(node, TS_FIELD("arguments")); // ObjectScript stores args under oref_method/method_args, not the diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index b25e9f592b..db6a86178f 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -623,12 +623,20 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, * language gated on only one resolver produces an edge on the sequential * path and not the parallel one (or vice versa), breaking MT determinism. * ArkTS belongs to the JS/TS family here (#1842); dropping it would - * reintroduce the #592/#606 false-edge class for .ets files. */ + * reintroduce the #592/#606 false-edge class for .ets files. + * + * Go (#1906) rides the same deferred-drop plumbing through its OWN + * predicate: its drop-list differs (field_type_hint is receiver-aware for + * Go, and unique_name drops only when import-unreachability-penalized), so + * it composes via cbm_go_suppress_weak_method_match instead of widening + * the shared gate. Same lockstep rule: mirror pass_parallel.c. */ bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; bool drop_plain_call = - cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy); + cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || + cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy, + res.confidence); /* Service-pattern HTTP/ASYNC calls to an EXTERNAL client library (e.g. * `requests.get("/api/orders/{id}")`) resolve to a QN containing the library diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 1ce7669276..0a43a3f5e5 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2488,12 +2488,16 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * #606 direction. * * This language set MUST match the one in pass_calls.c exactly — see the - * note there. ArkTS belongs to the JS/TS family (#1842). */ + * note there. ArkTS belongs to the JS/TS family (#1842). Go (#1906) + * composes via its own predicate (different drop-list — see + * cbm_go_suppress_weak_method_match), mirrored in pass_calls.c. */ bool suppress_weak_member = lang == CBM_LANG_PYTHON || lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; bool drop_plain_call = - cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy); + cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || + cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy, + res.confidence); /* Service-pattern HTTP/ASYNC client call (`requests.get(url)`): the * service signal lives in the callee_name. The registry can mis-resolve diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index d174bb6d68..6122f450d4 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -280,6 +280,15 @@ bool cbm_perl_suppress_generic_match(bool is_perl, bool is_method, const char *c * Pure; unit-tested in test_registry.c. */ bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *strategy); +/* Go analog of the TS/JS guard, same failure class: a selector call whose + * receiver the Go LSP could not type must not be bound by a receiver-blind + * short-name strategy. Drops suffix_match / fuzzy always, and unique_name only + * when its confidence is import-unreachability-penalized (the stdlib/vendor + * hijack shape). field_type_hint is deliberately NOT dropped for Go — struct + * fields carry declared types, so the hint is receiver-aware there. */ +bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy, + double confidence); + /* #725: drop a suffix_match CALLS edge when the caller language and the * target file's language disagree. unique_name (candidates == 1) is #1572 * and is left alone; same_module / import_map / lsp_* are kept. JS/TS/TSX diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index eefdb75968..10ee864bd8 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -464,6 +464,33 @@ bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *st strcmp(strategy, "field_type_hint") == 0 || strcmp(strategy, "fuzzy") == 0; } +bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy, + double confidence) { + if (!is_go || !is_method || !strategy || !strategy[0]) { + return false; + } + /* Go analog of the TS/JS guard above, same failure class: a selector call + * whose receiver the Go LSP could not type reaches the registry and a bare + * short-name strategy binds it to an arbitrary same-named project symbol + * (`f.Close()` on an os.File -> a project `Close`, suffix_match over 15 + * candidates). Unlike the TS/JS list, field_type_hint is KEPT: a Go struct + * field carries a declared type, so the parallel resolver's field-type + * hint is receiver-aware for Go (lrp_go_s8_field_type_hint), not a + * heuristic. */ + if (strcmp(strategy, "suffix_match") == 0 || strcmp(strategy, "fuzzy") == 0) { + return true; + } + /* unique_name is dropped only when PENALIZED: resolve_name_lookup scales + * CONF_UNIQUE_NAME by DEFAULT_CONFIDENCE exactly when the lone candidate + * is not reachable through the caller's imports — the stdlib/vendor + * hijack shape (`io.Copy` -> a project `Copy`). An unpenalized + * unique_name target sits inside the caller's import closure (or the + * file has no imports, e.g. a same-package call) and must be kept — + * dropping it kills genuinely-typed lone-candidate calls that never + * enter the field-type-hint upgrade (candidate_count == 1). */ + return strcmp(strategy, "unique_name") == 0 && confidence < CONF_UNIQUE_NAME; +} + static bool js_ts_family(CBMLanguage lang) { return lang == CBM_LANG_JAVASCRIPT || lang == CBM_LANG_TYPESCRIPT || lang == CBM_LANG_TSX || lang == CBM_LANG_ARKTS; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5b8d16f61a..6f7d0c0854 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4743,9 +4743,14 @@ TEST(extract_perl_method_call_flags_is_method) { /* Languages OUTSIDE the is_method flag set (only Perl and TS/JS/TSX set it) must * be unaffected: a Go method call never sets is_method. */ TEST(extract_flag_exempt_method_call_not_flagged_is_method) { - CBMFileResult *r = extract("package m\n" - "func run(o Obj) { o.Commit(); helper() }\n", - CBM_LANG_GO, "t", "x.go"); + /* Rust is flag-exempt: only Perl, Python, TS/JS and Go set is_method. + * Guards the blast radius of the receiver-aware flags for every other + * language. */ + CBMFileResult *r = extract("fn run(o: Obj) {\n" + " o.commit();\n" + " helper();\n" + "}\n", + CBM_LANG_RUST, "t", "x.rs"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); for (int i = 0; i < r->calls.count; i++) { @@ -4824,6 +4829,33 @@ TEST(extract_python_member_call_flags_is_method) { PASS(); } +TEST(extract_go_selector_call_flags_is_method) { + /* Go selector calls are flagged so the weak-match guard can fire when the + * Go LSP cannot type the receiver; bare calls stay unflagged. */ + CBMFileResult *r = extract("package m\n" + "func run(o Obj) { o.Commit(); helper() }\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + bool saw_selector = false; + bool saw_bare = false; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + if (c->callee_name && strstr(c->callee_name, "Commit") != NULL) { + ASSERT_TRUE(c->is_method); + saw_selector = true; + } + if (c->callee_name && strcmp(c->callee_name, "helper") == 0) { + ASSERT_FALSE(c->is_method); + saw_bare = true; + } + } + ASSERT_TRUE(saw_selector); + ASSERT_TRUE(saw_bare); + cbm_free_result(r); + PASS(); +} + /* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above). * A member call x.foo() with a non-this/super receiver is flagged is_method so * the resolver can suppress a weak short-name match (`re.test()` must not bind a @@ -6503,6 +6535,7 @@ SUITE(extraction) { RUN_TEST(extract_perl_method_call_flags_is_method); RUN_TEST(extract_flag_exempt_method_call_not_flagged_is_method); RUN_TEST(extract_python_member_call_flags_is_method); + RUN_TEST(extract_go_selector_call_flags_is_method); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d62a5572f5..2c7f457321 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4693,6 +4693,99 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) { PASS(); } +TEST(pipeline_go_receiver_suppresses_weak_method_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_recv_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* go.mod makes project imports resolvable — real Go repos always have one, + * and import reachability (the unique_name penalty) depends on it. */ + write_temp_file(tmp, "go.mod", "module example.com/myapp\n\ngo 1.22\n"); + /* The lone project symbol named "Close" — a real method. */ + write_temp_file(tmp, "storage/storage.go", + "package storage\n" + "\n" + "type Storage struct{ open bool }\n" + "\n" + "func NewStorage() *Storage { return &Storage{open: true} }\n" + "\n" + "func (s *Storage) Close() {\n" + "\ts.open = false\n" + "}\n" + "\n" + "func Boot() {\n" + "\ts := NewStorage()\n" + "\ts.Close()\n" + "}\n"); + /* Cross-package control target: imported by hash.go, so the caller file + * has a non-empty import map (like any real Go file) and unreachable + * unique_name candidates get the import penalty. */ + write_temp_file(tmp, "util/util.go", + "package util\n" + "\n" + "func Tag() string { return \"t\" }\n"); + /* Stdlib receiver: `f.Close()` closes an *os.File, NOT the project method. + * The Go LSP cannot bind it to a project symbol → the registry would guess + * Close by short name (weak). This is the false edge to suppress — + * the exact shape that attached every file/rows/gzip Close in a real Go + * repo to one unrelated project method. */ + write_temp_file(tmp, "hash/hash.go", + "package hash\n" + "\n" + "import (\n" + "\t\"os\"\n" + "\n" + "\t\"example.com/myapp/util\"\n" + ")\n" + "\n" + "func FileLen(path string) int64 {\n" + "\tf, err := os.Open(path)\n" + "\tif err != nil {\n" + "\t\treturn 0\n" + "\t}\n" + "\tdefer f.Close()\n" + "\tst, err := f.Stat()\n" + "\tif err != nil {\n" + "\t\treturn 0\n" + "\t}\n" + "\treturn st.Size()\n" + "}\n" + "\n" + "func localHelper() int { return 1 }\n" + "\n" + "func CallsLocal() int { return localHelper() }\n" + "\n" + "func UsesUtil() string { return util.Tag() }\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_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); + + /* (1) The false edge is suppressed (reproduce-first: RED before the fix). */ + ASSERT_FALSE(cross_file_call_exists(s, project, "FileLen", "Close")); + /* (2) The same-package typed-receiver call survives (LSP / same_module — + * both outside the weak drop-list). */ + ASSERT_TRUE(cross_file_call_exists(s, project, "Boot", "Close")); + /* (3) The bare local call survives (is_method stays false for bare calls). */ + ASSERT_TRUE(cross_file_call_exists(s, project, "CallsLocal", "localHelper")); + /* (4) The import-qualified cross-package call survives (import-aware + * strategies are outside the drop-list). */ + ASSERT_TRUE(cross_file_call_exists(s, project, "UsesUtil", "Tag")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + 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 @@ -13115,6 +13208,7 @@ 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_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_go_bare_ref_never_binds_field); diff --git a/tests/test_registry.c b/tests/test_registry.c index 3e1d604af8..b58e98dded 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -901,6 +901,46 @@ TEST(dynamic_suppress_keeps_high_confidence_and_non_methods) { PASS(); } +TEST(go_suppress_drops_weak_selector_matches) { + /* Go selector call with an untyped receiver, landed via a receiver-blind + * short-name strategy → drop (same failure class as #592/#606). + * suffix_match/fuzzy drop at any confidence; unique_name drops only when + * import-unreachability-penalized (CONF_UNIQUE_NAME 0.75 * 0.5 = 0.375 — + * the `io.Copy` -> project `Copy` stdlib-hijack shape). */ + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "suffix_match", 0.9)); + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "suffix_match", 0.11)); + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "fuzzy", 0.9)); + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "unique_name", 0.375)); + PASS(); +} + +TEST(go_suppress_keeps_typed_and_import_aware_matches) { + /* Unpenalized unique_name = lone candidate inside the caller's import + * closure (or an import-free file, e.g. same-package) — a genuinely-typed + * lone-candidate call never enters the field-type-hint upgrade, so it must + * survive (lrp_go_s8_field_type_hint). */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "unique_name", 0.75)); + /* field_type_hint is receiver-aware for Go — struct fields carry declared + * types (lrp_go_s8_field_type_hint) — so it stays, unlike the TS/JS list. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "field_type_hint", 0.85)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "same_module", 0.9)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "import_map", 0.95)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "import_map_suffix", 0.9)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "qualified_suffix", 0.9)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "callee_suffix", 0.5)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "service_pattern", 0.5)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "lsp_strategy_cross_file", 0.92)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "lsp_direct", 0.95)); + /* A bare call (is_method=false) is a free-function call → never suppressed. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, false, "suffix_match", 0.11)); + /* Non-Go languages are never affected by this gate. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(false, true, "suffix_match", 0.11)); + /* No match (NULL/empty strategy) → nothing to suppress. */ + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, NULL, 0.5)); + ASSERT_FALSE(cbm_go_suppress_weak_method_match(true, true, "", 0.5)); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ /* Method call THROUGH an imported symbol that is itself an indexed node @@ -997,4 +1037,6 @@ SUITE(registry) { RUN_TEST(go_bare_ref_never_binds_field); RUN_TEST(dynamic_suppress_drops_weak_method_matches); RUN_TEST(dynamic_suppress_keeps_high_confidence_and_non_methods); + RUN_TEST(go_suppress_drops_weak_selector_matches); + RUN_TEST(go_suppress_keeps_typed_and_import_aware_matches); } From dbba7573016e17f20fe5afaafe5cc2cddd98744e Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sat, 29 Aug 2026 18:04:16 +0200 Subject: [PATCH 2/4] fix(extract): receiver-qualify Go method QNs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Go method's QN was the flat package form (proj.pkg.method) — the receiver was ignored, so every same-name method in a package collided on one QN and the graph upsert kept exactly one node. Measured on a real Go repo: 20 Process/Name methods across 15 files kept 2 nodes; 9 different Task() methods fused into one chimera node carrying all nine bodies' call edges; 19 structs pointed DEFINES_METHOD at a single shared method node; a _test.go mock Close outranked the production Close in the dedupe tie-break. The upsert's own comment calls kind-disambiguated QNs 'the real cure'. Qualify the QN with the receiver type (proj.pkg.Recv.method), the same shape as Go interface members and the C++ out-of-line method path right below it in extract_func_def: - extract_defs.c: def.qualified_name = parent_class + name whenever the receiver type resolves; go_receiver_type_name becomes the shared cbm_go_receiver_type_name (exported via helpers.h) so both sides of the contract use one formula. - extract_unified.c (compute_func_qn): mirror branch for method_declaration, so method-body calls keep exact source attribution instead of degrading to File-node fallback (calls_find_source). - Consumers already agree: pxc_build_lsp_def passes the def QN and parent_class (receiver_type) verbatim into the Go LSP registries, and check_go_class_implements explicitly supports class-qualified method QNs (its path (b)). Side effect: resolve_same_module's exact module.name hash no longer matches concrete methods, which kills the conf-0.9 false edges where an interface-typed call bound to an unrelated same-package method. Fixes #1909 Signed-off-by: Ilya Brykau --- internal/cbm/extract_defs.c | 14 +++++++-- internal/cbm/extract_unified.c | 19 +++++++++++++ internal/cbm/helpers.h | 6 ++++ internal/cbm/lsp/go_lsp.c | 25 ++++++++++------ tests/test_extraction.c | 50 ++++++++++++++++++++++++++++++-- tests/test_parallel.c | 4 ++- tests/test_pipeline.c | 52 ++++++++++++++++++++++++++++++++++ 7 files changed, 156 insertions(+), 14 deletions(-) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 2e359af67a..b298ec50ca 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3508,7 +3508,7 @@ static void set_def_complexity(CBMDefinition *def, TSNode body, const CBMLangSpe * Walks to the parameter_declaration's `type` field, unwrapping pointer_type * and generic_type, and returns the type_identifier text (e.g. "OrderService"). * Returns NULL if no type_identifier is found. */ -static char *go_receiver_type_name(CBMArena *a, TSNode recv, const char *source) { +char *cbm_go_receiver_type_name(CBMArena *a, TSNode recv, const char *source) { uint32_t nc = ts_node_child_count(recv); for (uint32_t i = 0; i < nc; i++) { TSNode child = ts_node_child(recv, i); @@ -3720,12 +3720,22 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * (and downstream Go IMPLEMENTS/OVERRIDE) link the method to its owning * struct/type node. The parent QN must match the type's node QN, which * is computed the same way (cbm_fqn_compute on the type name). */ - char *recv_type = go_receiver_type_name(a, recv, ctx->source); + char *recv_type = cbm_go_receiver_type_name(a, recv, ctx->source); if (recv_type && recv_type[0]) { /* Must match the Go type node QN (directory-based module) so the * DEFINES_METHOD edge links the method to its owning type. */ def.parent_class = cbm_fqn_compute_source_lang(a, ctx->project, ctx->rel_path, recv_type, ctx->language); + /* Receiver-qualify the method QN (proj.pkg.Recv.method) — same + * shape as the C++ out-of-line path below and Go interface + * members. With the flat proj.pkg.method QN every same-name + * method in a package collided in the graph upsert: one body + * survived and the twins' call edges accreted onto it. The + * call-scope side (compute_func_qn in extract_unified.c) mirrors + * this formula, and go_lsp consumers read the def QN and + * parent_class (receiver_type) verbatim, so resolution joins + * stay exact. */ + def.qualified_name = cbm_arena_sprintf(a, "%s.%s", def.parent_class, name); } } diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index a5e3142727..8502c37b81 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -899,6 +899,25 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan } } + /* Go method `func (s *Storage) Close() {...}`: the def extractor records + * this as Method "proj.pkg.Storage.Close" (receiver-qualified, mirroring + * the C++ out-of-line rule above). The call-scope QN must match — a bare + * "proj.pkg.Close" names a node that no longer exists, so every call + * inside the method body would fall back to File-node attribution + * (calls_find_source). Same ONE-formula contract as the def side: + * cbm_go_receiver_type_name + cbm_fqn_compute_source_lang. */ + if (ctx->language == CBM_LANG_GO && strcmp(ts_node_type(node), "method_declaration") == 0) { + TSNode recv = ts_node_child_by_field_name(node, TS_FIELD("receiver")); + if (!ts_node_is_null(recv)) { + char *recv_type = cbm_go_receiver_type_name(ctx->arena, recv, ctx->source); + if (recv_type && recv_type[0]) { + const char *type_qn = cbm_fqn_compute_source_lang( + ctx->arena, ctx->project, ctx->rel_path, recv_type, ctx->language); + return cbm_arena_sprintf(ctx->arena, "%s.%s", type_qn, name); + } + } + } + /* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def * extractor bakes it into the def QN. Compose it identically here — otherwise * an in-body call sources to a QN one or more segments short of the def, and diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 4e8150b6f7..29a8583284 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -119,6 +119,12 @@ TSNode cbm_resolve_func_name(TSNode node, CBMLanguage lang); // def extractor — drift dropped the class qualifier from in-body calls (#554/#621). char *cbm_cpp_out_of_line_parent_class(CBMArena *a, TSNode node, const char *source); +/* Go: resolve a method_declaration's receiver parameter_list down to the bare + * receiver type_identifier (unwrapping pointer_type / generic_type). Shared by + * def extraction (extract_defs.c) and call-scope attribution + * (extract_unified.c) so the receiver-qualified method QN has ONE formula. */ +char *cbm_go_receiver_type_name(CBMArena *a, TSNode recv, const char *source); + // Find a child node by kind string. TSNode cbm_find_child_by_kind(TSNode parent, const char *kind); diff --git a/internal/cbm/lsp/go_lsp.c b/internal/cbm/lsp/go_lsp.c index 7efa273e1e..812026291d 100644 --- a/internal/cbm/lsp/go_lsp.c +++ b/internal/cbm/lsp/go_lsp.c @@ -1859,15 +1859,24 @@ 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; - // 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. + // Enclosing-function QN must be EXACTLY the QN the textual call events + // (extract_unified.c compute_func_qn) and the defs pass produce, or the + // caller-QN join in cbm_pipeline_find_lsp_resolution breaks and the + // LSP-resolved call silently falls back to the registry short-name + // resolver. Since #1909 receiver-qualified Go method QNs, that shared + // form is package_qn.ReceiverType.method for methods (the ONE-formula + // contract: cbm_go_receiver_type_name) and package_qn.func for functions. ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->package_qn, func_name); + if (strcmp(ts_node_type(func_node), "method_declaration") == 0) { + TSNode recv = ts_node_child_by_field_name(func_node, "receiver", 8); + if (!ts_node_is_null(recv)) { + char *recv_type = cbm_go_receiver_type_name(ctx->arena, recv, ctx->source); + if (recv_type && recv_type[0]) { + ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s.%s", ctx->package_qn, + recv_type, func_name); + } + } + } // Push function scope CBMScope* saved_scope = ctx->current_scope; diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 6f7d0c0854..8f5fbf2d2e 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4254,12 +4254,12 @@ TEST(extract_go_no_filename_in_module_qn) { ASSERT_NOT_NULL(conn); ASSERT_STR_EQ(conn->qualified_name, "proj.myapp.db.Conn"); - /* Go method nodes keep a FLAT QN (module + name) with a separate - * parent_class link to the receiver type — the QN must carry the + /* Go method nodes carry a receiver-qualified QN (module + receiver type + + * name) plus the parent_class link — the QN must carry the * directory-based module and NOT the `.conn.` filename segment. */ const CBMDefinition *query = find_def_by_name(r, "Query"); ASSERT_NOT_NULL(query); - ASSERT_STR_EQ(query->qualified_name, "proj.myapp.db.Query"); + ASSERT_STR_EQ(query->qualified_name, "proj.myapp.db.Conn.Query"); ASSERT_EQ(strstr(query->qualified_name, ".conn."), NULL); /* The method's parent_class must match the type node QN (for DEFINES_METHOD). */ ASSERT_NOT_NULL(query->parent_class); @@ -4856,6 +4856,49 @@ TEST(extract_go_selector_call_flags_is_method) { PASS(); } +TEST(extract_go_method_receiver_qualified_qn) { + /* A Go method QN carries the receiver type (proj.pkg.Recv.method), same + * shape as C++ out-of-line methods and Go interface members — so two + * same-name methods on different receivers no longer collide in the + * graph upsert. Free functions keep the flat package QN. */ + CBMFileResult *r = extract("package m\n" + "type Storage struct{}\n" + "type Cache struct{}\n" + "func (s *Storage) Close() {}\n" + "func (c Cache) Close() {}\n" + "func Shutdown() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + bool saw_storage = false; + bool saw_cache = false; + bool saw_free = false; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (!d->name || !d->qualified_name) { + continue; + } + if (strcmp(d->name, "Close") == 0 && strcmp(d->qualified_name, "t.Storage.Close") == 0) { + ASSERT_STR_EQ(d->label, "Method"); + ASSERT_STR_EQ(d->parent_class, "t.Storage"); + saw_storage = true; + } + if (strcmp(d->name, "Close") == 0 && strcmp(d->qualified_name, "t.Cache.Close") == 0) { + ASSERT_STR_EQ(d->parent_class, "t.Cache"); + saw_cache = true; + } + if (strcmp(d->name, "Shutdown") == 0) { + ASSERT_STR_EQ(d->qualified_name, "t.Shutdown"); + saw_free = true; + } + } + ASSERT_TRUE(saw_storage); + ASSERT_TRUE(saw_cache); + ASSERT_TRUE(saw_free); + cbm_free_result(r); + PASS(); +} + /* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above). * A member call x.foo() with a non-this/super receiver is flagged is_method so * the resolver can suppress a weak short-name match (`re.test()` must not bind a @@ -6536,6 +6579,7 @@ SUITE(extraction) { RUN_TEST(extract_flag_exempt_method_call_not_flagged_is_method); RUN_TEST(extract_python_member_call_flags_is_method); RUN_TEST(extract_go_selector_call_flags_is_method); + RUN_TEST(extract_go_method_receiver_qualified_qn); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_parallel.c b/tests/test_parallel.c index 631446f9c4..d3cd08417a 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -3379,7 +3379,9 @@ TEST(parallel_go_cross_package_field_chain_resolves) { 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"); + /* Since #1909, Go method QNs are receiver-qualified on both endpoints. */ + const cbm_gbuf_edge_t *edge = find_call_edge_to_target_fragment( + gbuf, "OrderHandler.PlaceOrder", ".service.OrderService.PlaceOrder"); const bool found = edge != NULL; const bool dispatch = edge && edge->properties_json && diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 2c7f457321..96eac87148 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4693,6 +4693,57 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) { PASS(); } +TEST(pipeline_go_method_caller_keeps_lsp_join) { + /* #1909: receiver-qualifying method QNs moves the def/textual-call QN to + * package.Type.method — go_lsp's enclosing-function QN must move with it + * (the ONE-formula contract), or every LSP resolution sourced from inside + * a method body loses its caller join and the edge dies. The callee name + * collides across receivers so no short-name fallback can mask the loss. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_mcall_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_temp_file(tmp, "go.mod", "module example.com/fxmcall\n\ngo 1.22\n"); + write_temp_file(tmp, "svc/repo.go", + "package svc\n" + "\n" + "type Repo struct {\n" + "\tn int\n" + "}\n" + "\n" + "func (r *Repo) Install() int {\n" + "\treturn r.helper()\n" + "}\n"); + write_temp_file(tmp, "svc/helper.go", + "package svc\n" + "\n" + "func (r *Repo) helper() int {\n" + "\treturn r.n\n" + "}\n"); + write_temp_file(tmp, "other/other.go", + "package other\n" + "\n" + "type Decoy struct{}\n" + "\n" + "func (d *Decoy) helper() int { return 2 }\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_mcall.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_TRUE(cross_file_call_exists(s, project, "Install", "helper")); + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + TEST(pipeline_go_receiver_suppresses_weak_method_edge) { char tmp[256]; snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_recv_XXXXXX"); @@ -13208,6 +13259,7 @@ 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_method_caller_keeps_lsp_join); RUN_TEST(pipeline_go_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); From adff62e7f20dd439129d17c5dc028c7069af92f2 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sat, 29 Aug 2026 18:35:32 +0200 Subject: [PATCH 3/4] fix(extract): give each Go init() its own QN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go allows any number of init() functions per package — even several in one file — and all of them run at start-up. On the flat QN (proj.pkg.init) they all collided and the graph upsert kept ONE node per package, silently dropping the rest: measured on a real Go repo, an event-source package registering each decoder in its own file's init() collapsed 21 init functions into one node (29 in source, 7 in the graph), erasing the whole registration pattern. Disambiguate with the #495 cfg-twin pattern: fold the file basename and line into the QN (proj.pkg.init#a.go:L5). Calling init explicitly is illegal in Go, so nothing ever joins on the plain QN; the call-scope side (compute_func_qn) mirrors the exact formula so init-body calls keep their source attribution instead of degrading to the calls_find_source File fallback. Reproduce-first: pipeline_go_multi_init_nodes_survive is RED without the def-side change (node count 1, expected 2) and GREEN with it; extract_go_multiple_init_disambiguated pins two same-file inits to distinct suffixed QNs. Fixes #1910 Signed-off-by: Ilya Brykau --- internal/cbm/extract_defs.c | 19 ++++++++++ internal/cbm/extract_unified.c | 16 +++++++++ internal/cbm/helpers.c | 36 +++++++++++++++++++ internal/cbm/helpers.h | 4 +++ tests/test_extraction.c | 63 ++++++++++++++++++++++++++++++++++ tests/test_pipeline.c | 50 +++++++++++++++++++++++++++ 6 files changed, 188 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b298ec50ca..af49ef4858 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3739,6 +3739,25 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } } + /* Go allows any number of init() functions per package — even several in + * one file — and they all run at start-up. On the flat QN they all + * collided and the graph upsert kept ONE node per package, silently + * dropping the rest (#1910). Disambiguate with the #495 cfg-twin pattern: + * fold the file basename and the init's per-file ordinal into the QN. + * The ordinal (not the line) keeps the QN stable when code is edited + * above the function — the QN is node identity, and a line suffix would + * churn nodes on every unrelated insertion. Nothing ever joins on the + * plain QN — calling init explicitly is illegal in Go — and the + * call-scope side (compute_func_qn in extract_unified.c) computes the + * same cbm_go_init_ordinal so init-body calls keep their attribution. */ + if (ctx->language == CBM_LANG_GO && strcmp(def.label, "Function") == 0 && + strcmp(name, "init") == 0) { + const char *base = strrchr(ctx->rel_path, '/'); + base = base ? base + 1 : ctx->rel_path; + def.qualified_name = cbm_arena_sprintf(a, "%s#%s:%d", def.qualified_name, base, + cbm_go_init_ordinal(node, ctx->source)); + } + // C++/CUDA: out-of-line method definition (`Foo::bar` in a .cc/.cpp). The // class body in the header is declaration-only, so without this the // definition is recorded as a free Function. Promote it to a Method whose QN diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 8502c37b81..1bcbd5753d 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -918,6 +918,22 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan } } + /* Go init(): the def extractor folds the file basename and the init's + * per-file ordinal into the QN so every init in a package survives the + * upsert (#1910, the #495 cfg-twin pattern; ordinal, not line, so the QN + * is stable under edits above the function). Mirror the exact formula + * here, or init-body calls fall back to File-node attribution + * (calls_find_source). */ + if (ctx->language == CBM_LANG_GO && strcmp(name, "init") == 0 && + strcmp(ts_node_type(node), "function_declaration") == 0) { + const char *base_qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, + name, ctx->language); + const char *base = strrchr(ctx->rel_path, '/'); + base = base ? base + 1 : ctx->rel_path; + return cbm_arena_sprintf(ctx->arena, "%s#%s:%d", base_qn, base, + cbm_go_init_ordinal(node, ctx->source)); + } + /* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def * extractor bakes it into the def QN. Compose it identically here — otherwise * an in-body call sources to a QN one or more segments short of the def, and diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index e8a6eb8ecc..5ac97f6100 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1734,6 +1734,42 @@ int cbm_classify_string(const char *str, int len) { * canonical parameter shape of server-side route paths * (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields * no text or exceeds the route-sized buffer. */ +/* 1-based source-order ordinal of a top-level Go `init` function_declaration + * among the file's `init`s. Both the definition extractor and compute_func_qn + * fold it into the QN (`pkg.init#file.go:N`, #1910), so it must be computed + * from the tree on both sides with this one formula. Unlike a line number, + * the ordinal is stable under edits above the function — QNs are node + * identity, and a line-based suffix would churn nodes on every unrelated + * insertion (the #495 cfg suffix and the GoogleTest derivation are both + * edit-stable for the same reason). */ +int cbm_go_init_ordinal(TSNode fn_node, const char *source) { + TSNode root = fn_node; + for (TSNode p = ts_node_parent(root); !ts_node_is_null(p); p = ts_node_parent(p)) { + root = p; + } + int ordinal = 0; + uint32_t n = ts_node_named_child_count(root); + for (uint32_t i = 0; i < n; i++) { + TSNode child = ts_node_named_child(root, i); + if (strcmp(ts_node_type(child), "function_declaration") != 0) { + continue; + } + TSNode name = ts_node_child_by_field_name(child, TS_FIELD("name")); + if (ts_node_is_null(name)) { + continue; + } + uint32_t start = ts_node_start_byte(name); + if (ts_node_end_byte(name) - start != 4 || memcmp(source + start, "init", 4) != 0) { + continue; + } + ordinal++; + if (ts_node_eq(child, fn_node)) { + return ordinal; + } + } + return ordinal; +} + const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source) { enum { TPL_BUF = 512 }; char buf[TPL_BUF]; diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 29a8583284..e2f148ecfe 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -197,6 +197,10 @@ bool cbm_is_loop_node_type(const char *kind); // Is this a module-level node? (not nested inside function/class body) bool cbm_is_module_level(TSNode node, CBMLanguage lang); +/* 1-based source-order ordinal of a Go `init` among its file's top-level + * `init` function_declarations (QN disambiguation, #1910). */ +int cbm_go_init_ordinal(TSNode fn_node, const char *source); + // Same check, but the node's PARENT is supplied directly — avoids the // O(n) ts_node_parent rescan. Use at call sites iterating a known // parent's children (the common case). `parent` is the parent of the diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 8f5fbf2d2e..edecba47df 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4899,6 +4899,68 @@ TEST(extract_go_method_receiver_qualified_qn) { PASS(); } +TEST(extract_go_multiple_init_disambiguated) { + /* Go allows several init() per package (even per file); each gets a + * file+ordinal-suffixed QN (#495 cfg-twin pattern) so none is lost to the + * same-QN upsert. The ordinal — unlike a line number — must not move when + * code is inserted above the function: the QN is node identity, and a + * line-based suffix would churn nodes on every unrelated edit. */ + CBMFileResult *r = extract("package m\n" + "func init() { a() }\n" + "func init() { b() }\n" + "func a() {}\n" + "func b() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const char *first = NULL; + const char *second = NULL; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (!d->name || strcmp(d->name, "init") != 0) { + continue; + } + if (!first) { + first = d->qualified_name; + } else { + second = d->qualified_name; + } + } + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(second); + ASSERT_STR_EQ(first, "t.init#x.go:1"); + ASSERT_STR_EQ(second, "t.init#x.go:2"); + + /* Stability: an insertion ABOVE both inits must not change either QN. */ + CBMFileResult *r2 = extract("package m\n" + "import \"fmt\"\n" + "var shifted = fmt.Sprint(\"pad\")\n" + "func init() { a() }\n" + "func init() { b() }\n" + "func a() {}\n" + "func b() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r2); + ASSERT_FALSE(r2->has_error); + int seen = 0; + for (int i = 0; i < r2->defs.count; i++) { + const CBMDefinition *d = &r2->defs.items[i]; + if (!d->name || strcmp(d->name, "init") != 0) { + continue; + } + seen++; + if (seen == 1) { + ASSERT_STR_EQ(d->qualified_name, "t.init#x.go:1"); + } else { + ASSERT_STR_EQ(d->qualified_name, "t.init#x.go:2"); + } + } + ASSERT_EQ(seen, 2); + cbm_free_result(r2); + cbm_free_result(r); + PASS(); +} + /* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above). * A member call x.foo() with a non-this/super receiver is flagged is_method so * the resolver can suppress a weak short-name match (`re.test()` must not bind a @@ -6580,6 +6642,7 @@ SUITE(extraction) { RUN_TEST(extract_python_member_call_flags_is_method); RUN_TEST(extract_go_selector_call_flags_is_method); RUN_TEST(extract_go_method_receiver_qualified_qn); + RUN_TEST(extract_go_multiple_init_disambiguated); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 96eac87148..a5d5b545f8 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4837,6 +4837,55 @@ TEST(pipeline_go_receiver_suppresses_weak_method_edge) { PASS(); } +TEST(pipeline_go_multi_init_nodes_survive) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* Two files, one package, one init() each — Go runs both at start-up. + * Pre-fix both collapsed onto one QN and the upsert kept one node + * (reproduce-first: RED asserts node count == 2). */ + write_temp_file(tmp, "go.mod", "module example.com/reg\n\ngo 1.22\n"); + write_temp_file(tmp, "reg/a.go", + "package reg\n" + "\n" + "var handlers = map[string]func(){}\n" + "\n" + "func init() { handlers[\"a\"] = handleA }\n" + "\n" + "func handleA() {}\n"); + write_temp_file(tmp, "reg/b.go", + "package reg\n" + "\n" + "func init() { handlers[\"b\"] = handleB }\n" + "\n" + "func handleB() {}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_init.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); + + /* Both init functions survive as distinct nodes. */ + cbm_node_t *inits = NULL; + int ic = 0; + cbm_store_find_nodes_by_name(s, project, "init", &inits, &ic); + ASSERT_EQ(ic, 2); + cbm_store_free_nodes(inits, ic); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + 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 @@ -13261,6 +13310,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_go_method_caller_keeps_lsp_join); RUN_TEST(pipeline_go_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_go_multi_init_nodes_survive); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); RUN_TEST(pipeline_go_bare_ref_never_binds_field); From 6e784c38a2ab6d0a7f1f65bc22792cae0d9b9034 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Tue, 27 Oct 2026 20:43:28 +0100 Subject: [PATCH 4/4] fix(lsp): name Go init() callers per file in the Go LSP #1910 gives every Go init() its own QN, pkg.init#:, on the def side and in compute_func_qn. The Go LSP computes its enclosing-function QN on its own and still produced the flat pkg.init. The pipeline joins LSP rows to textual calls by exact caller QN, so every LSP resolution inside an init body missed the join: typed calls fell back to the registry resolver and callable references (Register(fn)) were dropped. On kubernetes (8c078bc3), without this commit all 499 lsp_* CALLS from init bodies fell back (import_map, same_module, unique_name) and all 562 CALL_REFERENCE edges from them were lost; with it, the init edges match main's strategy mix. - helpers.c: cbm_go_init_qn is the one formula for all three sides (def extractor, compute_func_qn, Go LSP process_function). The per-file ordinal now walks the top-level declarations with a cursor: ts_node_named_child(root, i) is O(i) per call, so the indexed loop was quadratic on wide generated files. - go_lsp: GoLSPContext carries the file's rel_path. cbm_run_go_lsp, cbm_run_go_lsp_cross and cbm_run_go_lsp_cross_with_registry take it, so the per-file walk, the shared Tier-2 registry walk and the per-file fallback all name init callers like the def side. Tier 3 copies the per-file rows' caller QN, so it follows. - pass_lsp_cross: the dispatch passes rel to the Tier-2 walk and to the per-file fallback (cbm_pxc_run_one_with_php_bindings gains rel_path; the incremental route can run without shared registries). - compute_func_qn: a NULL base QN keeps the flat QN, as the receiver branch above it does. Tests: golsp_init_caller_qn_matches_def (per-file rows and the caller join), golsp_crossfile_init_caller_qn_per_file (both cross entry points), pipeline_go_init_lsp_edges_per_file and its _parallel twin (three inits in two files: lsp_ CALLS and a CALL_REFERENCE on the right init, no cross-wiring, nothing onto a decoy), and pipeline_go_init_dispatch_fallback_names_init_caller. Each fails with its piece reverted; the pipeline tests also fail with the compute_func_qn mirror disabled, which no test covered before. Signed-off-by: Martin Vogel --- internal/cbm/cbm.c | 2 +- internal/cbm/extract_defs.c | 11 +- internal/cbm/extract_unified.c | 13 +- internal/cbm/helpers.c | 87 +++++++----- internal/cbm/helpers.h | 11 +- internal/cbm/lsp/go_lsp.c | 28 ++-- internal/cbm/lsp/go_lsp.h | 10 +- src/pipeline/pass_lsp_cross.c | 26 ++-- src/pipeline/pass_lsp_cross.h | 10 +- tests/test_c_lsp.c | 4 +- tests/test_go_lsp.c | 147 ++++++++++++++++++-- tests/test_parallel.c | 2 +- tests/test_pipeline.c | 238 +++++++++++++++++++++++++++++++++ 13 files changed, 503 insertions(+), 86 deletions(-) diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index 23b871dbc0..59c2c20c3e 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -3919,7 +3919,7 @@ static CBMFileResult *extract_file_ex_body(const char *source, int source_len, C uint64_t lsp_start = now_ns(); if (!result->lsp_skipped) { if (language == CBM_LANG_GO) { - cbm_run_go_lsp(a, result, source, source_len, root); + cbm_run_go_lsp(a, result, source, source_len, root, rel_path); } if (language == CBM_LANG_C || language == CBM_LANG_CPP || language == CBM_LANG_CUDA) { if (!cbm_run_c_lsp_with_test_owners(a, result, source, source_len, root, diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 8cf268b11c..ef86c332d4 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -7069,14 +7069,13 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec * above the function — the QN is node identity, and a line suffix would * churn nodes on every unrelated insertion. Nothing ever joins on the * plain QN — calling init explicitly is illegal in Go — and the - * call-scope side (compute_func_qn in extract_unified.c) computes the - * same cbm_go_init_ordinal so init-body calls keep their attribution. */ + * call-scope side (compute_func_qn in extract_unified.c) and the Go LSP + * (process_function in lsp/go_lsp.c) name it through the same + * cbm_go_init_qn, so init-body calls keep their attribution. */ if (ctx->language == CBM_LANG_GO && strcmp(def.label, "Function") == 0 && strcmp(name, "init") == 0) { - const char *base = strrchr(ctx->rel_path, '/'); - base = base ? base + 1 : ctx->rel_path; - def.qualified_name = cbm_arena_sprintf(a, "%s#%s:%d", def.qualified_name, base, - cbm_go_init_ordinal(node, ctx->source)); + def.qualified_name = + cbm_go_init_qn(a, def.qualified_name, ctx->rel_path, node, ctx->source); } // C++/CUDA: out-of-line method definition (`Foo::bar` in a .cc/.cpp). The diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 39b20f460b..e8edbd448d 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -1136,17 +1136,18 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan /* Go init(): the def extractor folds the file basename and the init's * per-file ordinal into the QN so every init in a package survives the * upsert (#1910, the #495 cfg-twin pattern; ordinal, not line, so the QN - * is stable under edits above the function). Mirror the exact formula - * here, or init-body calls fall back to File-node attribution + * is stable under edits above the function). Name it through the same + * cbm_go_init_qn, or init-body calls fall back to File-node attribution * (calls_find_source). */ if (ctx->language == CBM_LANG_GO && strcmp(name, "init") == 0 && strcmp(ts_node_type(node), "function_declaration") == 0) { const char *base_qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, name, ctx->language); - const char *base = strrchr(ctx->rel_path, '/'); - base = base ? base + 1 : ctx->rel_path; - return cbm_arena_sprintf(ctx->arena, "%s#%s:%d", base_qn, base, - cbm_go_init_ordinal(node, ctx->source)); + /* NULL only on allocation failure: fall through to the flat QN, as + * the receiver branch above does. */ + if (base_qn) { + return cbm_go_init_qn(ctx->arena, base_qn, ctx->rel_path, node, ctx->source); + } } /* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index ebb653e402..d6a8e73b97 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -2330,48 +2330,71 @@ int cbm_classify_string(const char *str, int len) { return NOT_FOUND; } -/* Flatten a JS/TS `template_string` node into plain text (issue #1006). - * String fragments are kept verbatim; each ${...} substitution becomes the - * "{}" placeholder so client URLs built from template literals share the - * canonical parameter shape of server-side route paths - * (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields - * no text or exceeds the route-sized buffer. */ /* 1-based source-order ordinal of a top-level Go `init` function_declaration - * among the file's `init`s. Both the definition extractor and compute_func_qn - * fold it into the QN (`pkg.init#file.go:N`, #1910), so it must be computed - * from the tree on both sides with this one formula. Unlike a line number, - * the ordinal is stable under edits above the function — QNs are node - * identity, and a line-based suffix would churn nodes on every unrelated - * insertion (the #495 cfg suffix and the GoogleTest derivation are both - * edit-stable for the same reason). */ -int cbm_go_init_ordinal(TSNode fn_node, const char *source) { + * among the file's `init`s. Unlike a line number, the ordinal is stable under + * edits above the function — QNs are node identity, and a line-based suffix + * would churn nodes on every unrelated insertion (the #495 cfg suffix and the + * GoogleTest derivation are both edit-stable for the same reason). + * One cursor pass over the top-level declarations: ts_node_named_child(root, i) + * is O(i), so an indexed loop is O(n^2) per init on a wide file, and generated + * Go (.pb.go, tables) carries thousands of top-level declarations next to its + * init(). */ +static int go_init_ordinal(TSNode fn_node, const char *source) { TSNode root = fn_node; for (TSNode p = ts_node_parent(root); !ts_node_is_null(p); p = ts_node_parent(p)) { root = p; } int ordinal = 0; - uint32_t n = ts_node_named_child_count(root); - for (uint32_t i = 0; i < n; i++) { - TSNode child = ts_node_named_child(root, i); - if (strcmp(ts_node_type(child), "function_declaration") != 0) { - continue; - } - TSNode name = ts_node_child_by_field_name(child, TS_FIELD("name")); - if (ts_node_is_null(name)) { - continue; - } - uint32_t start = ts_node_start_byte(name); - if (ts_node_end_byte(name) - start != 4 || memcmp(source + start, "init", 4) != 0) { - continue; - } - ordinal++; - if (ts_node_eq(child, fn_node)) { - return ordinal; - } + TSTreeCursor cur = ts_tree_cursor_new(root); + if (ts_tree_cursor_goto_first_child(&cur)) { + do { + TSNode child = ts_tree_cursor_current_node(&cur); + if (strcmp(ts_node_type(child), "function_declaration") != 0) { + continue; + } + TSNode name = ts_node_child_by_field_name(child, TS_FIELD("name")); + if (ts_node_is_null(name)) { + continue; + } + uint32_t start = ts_node_start_byte(name); + if (ts_node_end_byte(name) - start != 4 || memcmp(source + start, "init", 4) != 0) { + continue; + } + ordinal++; + if (ts_node_eq(child, fn_node)) { + break; + } + } while (ts_tree_cursor_goto_next_sibling(&cur)); } + ts_tree_cursor_delete(&cur); return ordinal; } +/* Qualified name of a Go `init` function (#1910). Go allows any number of + * init() per package — even several in one file — so the flat `pkg.init` + * collides and the graph upsert keeps one node. The QN folds in the file + * basename and the init's per-file ordinal: `pkg.init#file.go:N`. Every side + * that names an init calls this one function — the definition extractor, the + * call-scope side (compute_func_qn) and the Go LSP's enclosing-function QN — + * so the three cannot drift. A NULL base QN or path keeps base_qn as is. */ +const char *cbm_go_init_qn(CBMArena *a, const char *base_qn, const char *rel_path, TSNode fn_node, + const char *source) { + if (!base_qn || !rel_path) { + return base_qn; + } + const char *base = strrchr(rel_path, '/'); + base = base ? base + 1 : rel_path; + const char *qn = + cbm_arena_sprintf(a, "%s#%s:%d", base_qn, base, go_init_ordinal(fn_node, source)); + return qn ? qn : base_qn; +} + +/* Flatten a JS/TS `template_string` node into plain text (issue #1006). + * String fragments are kept verbatim; each ${...} substitution becomes the + * "{}" placeholder so client URLs built from template literals share the + * canonical parameter shape of server-side route paths + * (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields + * no text or exceeds the route-sized buffer. */ const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source) { enum { TPL_BUF = 512 }; char buf[TPL_BUF]; diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 13dd1dc2bc..4c96cb5f05 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -281,16 +281,19 @@ bool cbm_is_loop_node_type(const char *kind); // Is this a module-level node? (not nested inside function/class body) bool cbm_is_module_level(TSNode node, CBMLanguage lang); -/* 1-based source-order ordinal of a Go `init` among its file's top-level - * `init` function_declarations (QN disambiguation, #1910). */ -int cbm_go_init_ordinal(TSNode fn_node, const char *source); - // Same check, but the node's PARENT is supplied directly — avoids the // O(n) ts_node_parent rescan. Use at call sites iterating a known // parent's children (the common case). `parent` is the parent of the // node being classified. bool cbm_is_module_level_p(TSNode parent, CBMLanguage lang); +/* QN of a Go `init` function_declaration (#1910): base_qn + "#:", + * N = its 1-based ordinal among the file's top-level `init`s. The ONE formula + * for the def extractor, compute_func_qn and the Go LSP's enclosing-function + * QN. A NULL base_qn or rel_path returns base_qn unchanged. */ +const char *cbm_go_init_qn(CBMArena *a, const char *base_qn, const char *rel_path, TSNode fn_node, + const char *source); + // --- FQN computation --- // Compute qualified name: project.rel_path_parts.name diff --git a/internal/cbm/lsp/go_lsp.c b/internal/cbm/lsp/go_lsp.c index 34fad4b9e3..3de20c0d26 100644 --- a/internal/cbm/lsp/go_lsp.c +++ b/internal/cbm/lsp/go_lsp.c @@ -1994,6 +1994,8 @@ static void process_function(GoLSPContext *ctx, TSNode func_node) { // resolver. Since #1909 receiver-qualified Go method QNs, that shared // form is package_qn.ReceiverType.method for methods (the ONE-formula // contract: cbm_go_receiver_type_name) and package_qn.func for functions. + // A package may hold any number of init() functions, so each one is + // package_qn.init#: (#1910, cbm_go_init_qn — the same formula). ctx->enclosing_func_qn = cbm_arena_sprintf(ctx->arena, "%s.%s", ctx->package_qn, func_name); if (strcmp(ts_node_type(func_node), "method_declaration") == 0) { TSNode recv = ts_node_child_by_field_name(func_node, "receiver", 8); @@ -2004,6 +2006,9 @@ static void process_function(GoLSPContext *ctx, TSNode func_node) { recv_type, func_name); } } + } else if (strcmp(func_name, "init") == 0) { + ctx->enclosing_func_qn = cbm_go_init_qn(ctx->arena, ctx->enclosing_func_qn, ctx->rel_path, + func_node, ctx->source); } // Push function scope @@ -2261,7 +2266,7 @@ static void go_registry_with_stdlib(CBMTypeRegistry *reg, CBMArena *arena) { // --- Entry point: build registry from file defs + run LSP --- void cbm_run_go_lsp(CBMArena *arena, CBMFileResult *result, const char *source, int source_len, - TSNode root) { + TSNode root, const char *rel_path) { CBMTypeRegistry reg; cbm_registry_init(®, arena); @@ -2619,6 +2624,7 @@ void cbm_run_go_lsp(CBMArena *arena, CBMFileResult *result, const char *source, // Phase 2: Build LSP context and run GoLSPContext lsp_ctx; go_lsp_init(&lsp_ctx, arena, source, source_len, ®, module_qn, &result->resolved_calls); + lsp_ctx.rel_path = rel_path; // Add imports for (int i = 0; i < result->imports.count; i++) { @@ -3218,9 +3224,9 @@ static void extract_type_params_from_ast(CBMArena *arena, CBMTypeRegistry *reg, extern const TSLanguage *tree_sitter_go(void); void cbm_run_go_lsp_cross(CBMArena *arena, const char *source, int source_len, - const char *module_qn, CBMLSPDef *defs, int def_count, - const char **import_names, const char **import_qns, int import_count, - TSTree *cached_tree, CBMResolvedCallArray *out) { + const char *module_qn, const char *rel_path, CBMLSPDef *defs, + int def_count, const char **import_names, const char **import_qns, + int import_count, TSTree *cached_tree, CBMResolvedCallArray *out) { if (!source || source_len <= 0) return; @@ -3483,6 +3489,7 @@ void cbm_run_go_lsp_cross(CBMArena *arena, const char *source, int source_len, // 4. Build LSP context and run GoLSPContext ctx; go_lsp_init(&ctx, arena, source, source_len, ®, module_qn, out); + ctx.rel_path = rel_path; for (int i = 0; i < import_count; i++) { if (import_names[i] && import_qns[i]) { @@ -3624,10 +3631,10 @@ CBMTypeRegistry *cbm_go_build_cross_registry(CBMArena *arena, CBMLSPDef *defs, i } void cbm_run_go_lsp_cross_with_registry(CBMArena *arena, const char *source, int source_len, - const char *module_qn, CBMTypeRegistry *reg, - const char **import_names, const char **import_qns, - int import_count, TSTree *cached_tree, - CBMResolvedCallArray *out) { + const char *module_qn, const char *rel_path, + CBMTypeRegistry *reg, const char **import_names, + const char **import_qns, int import_count, + TSTree *cached_tree, CBMResolvedCallArray *out) { if (!source || source_len <= 0 || !reg) return; @@ -3653,6 +3660,7 @@ void cbm_run_go_lsp_cross_with_registry(CBMArena *arena, const char *source, int GoLSPContext ctx; go_lsp_init(&ctx, arena, source, source_len, reg, module_qn, out); + ctx.rel_path = rel_path; for (int i = 0; i < import_count; i++) { if (import_names[i] && import_qns[i]) { go_lsp_add_import(&ctx, import_names[i], import_qns[i]); @@ -3802,8 +3810,8 @@ void cbm_batch_go_lsp_cross(CBMArena *arena, CBMBatchGoLSPFile *files, int file_ // Delegate to existing per-file function cbm_run_go_lsp_cross(&file_arena, file->source, file->source_len, file->module_qn, - file->defs, file->def_count, file->import_names, file->import_qns, - file->import_count, file->cached_tree, &file_out); + file->rel_path, file->defs, file->def_count, file->import_names, + file->import_qns, file->import_count, file->cached_tree, &file_out); // Copy results to output arena (must outlive per-file arena) if (file_out.count > 0) { diff --git a/internal/cbm/lsp/go_lsp.h b/internal/cbm/lsp/go_lsp.h index 5874cbeefa..3b504d007d 100644 --- a/internal/cbm/lsp/go_lsp.h +++ b/internal/cbm/lsp/go_lsp.h @@ -22,6 +22,7 @@ typedef struct { // Current function context const char* enclosing_func_qn; const char* package_qn; // module QN for this file + const char* rel_path; // repo-relative file path: init() QN suffix (#1910); NULL = flat // Output: resolved calls accumulate here CBMResolvedCallArray* resolved_calls; @@ -63,8 +64,10 @@ const CBMRegisteredFunc* go_lookup_field_or_method(GoLSPContext* ctx, // Entry point: build registry from file's own defs + run LSP resolution. // Called from cbm_extract_file() after definitions and imports are extracted. +// rel_path is the file's repo-relative path: it names the file's init() +// functions exactly as the def extractor does (cbm_go_init_qn, #1910). void cbm_run_go_lsp(CBMArena* arena, CBMFileResult* result, - const char* source, int source_len, TSNode root); + const char* source, int source_len, TSNode root, const char* rel_path); // Register Go stdlib types and functions into a registry. // Auto-generated by scripts/gen-go-stdlib.go. @@ -108,11 +111,14 @@ typedef struct { // Re-parses source with tree-sitter internally. // defs includes both file-local and cross-file definitions. // import_names/import_qns are parallel arrays of length import_count. +// rel_path: the file's repo-relative path, for its init() QNs (#1910); NULL +// leaves init callers flat (they then miss the caller join). // Results written to out (arena-allocated). void cbm_run_go_lsp_cross( CBMArena* arena, const char* source, int source_len, const char* module_qn, + const char* rel_path, CBMLSPDef* defs, int def_count, const char** import_names, const char** import_qns, int import_count, TSTree* cached_tree, // NULL = parse internally @@ -131,6 +137,7 @@ void cbm_run_go_lsp_cross_with_registry( CBMArena* arena, const char* source, int source_len, const char* module_qn, + const char* rel_path, // see cbm_run_go_lsp_cross CBMTypeRegistry* reg, // pre-built, finalized, READ-ONLY const char** import_names, const char** import_qns, int import_count, TSTree* cached_tree, // NULL = parse internally @@ -156,6 +163,7 @@ typedef struct { const char* source; int source_len; const char* module_qn; + const char* rel_path; // repo-relative path (init() QNs, #1910); NULL = flat TSTree* cached_tree; // from TSTree caching (NULL = parse internally) CBMLSPDef* defs; // combined file-local + cross-file defs int def_count; diff --git a/src/pipeline/pass_lsp_cross.c b/src/pipeline/pass_lsp_cross.c index 5aa9551123..ed8af87d92 100644 --- a/src/pipeline/pass_lsp_cross.c +++ b/src/pipeline/pass_lsp_cross.c @@ -2079,14 +2079,15 @@ static CBMRustLSPDef *pxc_lspdefs_to_rust(CBMArena *arena, const CBMLSPDef *defs void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int source_len, const char *module_qn, CBMLSPDef *defs, int def_count, const char **imp_names, const char **imp_qns, int imp_count) { - cbm_pxc_run_one_with_php_bindings(lang, r, source, source_len, module_qn, defs, def_count, + cbm_pxc_run_one_with_php_bindings(lang, r, source, source_len, module_qn, NULL, defs, def_count, imp_names, imp_qns, imp_count, NULL); } void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const char *source, - int source_len, const char *module_qn, CBMLSPDef *defs, - int def_count, const char **imp_names, const char **imp_qns, - int imp_count, const CBMPHPImportBindings *bindings) { + int source_len, const char *module_qn, const char *rel_path, + CBMLSPDef *defs, int def_count, const char **imp_names, + const char **imp_qns, int imp_count, + const CBMPHPImportBindings *bindings) { TSTree *tree = r->cached_tree; /* may be NULL — LSP re-parses then */ CBMArena scratch; @@ -2098,8 +2099,8 @@ void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const switch (lang) { case CBM_LANG_GO: - cbm_run_go_lsp_cross(&scratch, source, source_len, module_qn, defs, def_count, imp_names, - imp_qns, imp_count, tree, &out); + cbm_run_go_lsp_cross(&scratch, source, source_len, module_qn, rel_path, defs, def_count, + imp_names, imp_qns, imp_count, tree, &out); break; case CBM_LANG_C: case CBM_LANG_CPP: @@ -2293,9 +2294,9 @@ void cbm_pxc_dispatch_file_with_php_bindings( * lsp_unresolved entries -- no parse, no AST walk -- then the * AST walk against the overlay (chained to the sealed base). */ cbm_go_fast_resolve_qualified_calls(result, prebuilt, imp_keys, imp_vals, imp_count); - cbm_run_go_lsp_cross_with_registry(&scratch, source, source_len, def_module, &overlay, - imp_keys, imp_vals, imp_count, result->cached_tree, - &out); + cbm_run_go_lsp_cross_with_registry(&scratch, source, source_len, def_module, rel, + &overlay, imp_keys, imp_vals, imp_count, + result->cached_tree, &out); used_prebuilt = true; break; case CBM_LANG_PYTHON: { @@ -2429,7 +2430,7 @@ void cbm_pxc_dispatch_file_with_php_bindings( pxc_append_synthetic_calls(&result->arena, &result->calls, &synthetic_calls); pxc_scratch_give(PXC_SCRATCH_DISPATCH, &scratch); } else { - cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, + cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, rel, file_defs, file_def_count, imp_keys, imp_vals, imp_count, bindings); } @@ -2441,8 +2442,9 @@ void cbm_pxc_dispatch_file_with_php_bindings( cbm_pxc_run_one_ts(result, source, source_len, def_module, file_defs, file_def_count, imp_keys, imp_vals, imp_count, js, jsx, dts); } else { - cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, file_defs, - file_def_count, imp_keys, imp_vals, imp_count, bindings); + cbm_pxc_run_one_with_php_bindings(lang, result, source, source_len, def_module, rel, + file_defs, file_def_count, imp_keys, imp_vals, imp_count, + bindings); } free(filtered); } diff --git a/src/pipeline/pass_lsp_cross.h b/src/pipeline/pass_lsp_cross.h index bcbe304251..51964bc41e 100644 --- a/src/pipeline/pass_lsp_cross.h +++ b/src/pipeline/pass_lsp_cross.h @@ -216,10 +216,14 @@ void cbm_pxc_run_one(CBMLanguage lang, CBMFileResult *r, const char *source, int const char *module_qn, CBMLSPDef *defs, int def_count, const char **imp_names, const char **imp_qns, int imp_count); +/* rel_path: the file's repo-relative path. Go needs it to name the file's + * init() functions as the def extractor does (#1910); cbm_pxc_run_one passes + * NULL (Go init() callers then stay flat). */ void cbm_pxc_run_one_with_php_bindings(CBMLanguage lang, CBMFileResult *r, const char *source, - int source_len, const char *module_qn, CBMLSPDef *defs, - int def_count, const char **imp_names, const char **imp_qns, - int imp_count, const CBMPHPImportBindings *bindings); + int source_len, const char *module_qn, const char *rel_path, + CBMLSPDef *defs, int def_count, const char **imp_names, + const char **imp_qns, int imp_count, + const CBMPHPImportBindings *bindings); /* TS / JS / JSX / TSX variant with explicit dialect flags. */ void cbm_pxc_run_one_ts(CBMFileResult *r, const char *source, int source_len, const char *module_qn, diff --git a/tests/test_c_lsp.c b/tests/test_c_lsp.c index a4e46c7bce..fc7bf2f1ee 100644 --- a/tests/test_c_lsp.c +++ b/tests/test_c_lsp.c @@ -16000,8 +16000,8 @@ TEST(seal_go_shared_registry_readonly) { const char *src = "package main\nfunc helper(x int) int { return x + 1 }\n" "func caller() int { return helper(1) }\n"; CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross_with_registry(&arena, src, (int)strlen(src), "test.mod", reg, NULL, NULL, - 0, NULL, &out); + cbm_run_go_lsp_cross_with_registry(&arena, src, (int)strlen(src), "test.mod", NULL, reg, NULL, + NULL, 0, NULL, &out); ASSERT_EQ(reg->func_count, fb); ASSERT_EQ(reg->type_count, tb); cbm_arena_destroy(&arena); diff --git a/tests/test_go_lsp.c b/tests/test_go_lsp.c index e852f4aca1..900c3c8822 100644 --- a/tests/test_go_lsp.c +++ b/tests/test_go_lsp.c @@ -1063,7 +1063,7 @@ TEST(golsp_crossfile_method_dispatch) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names, imp_qns, 1, NULL, &out); ASSERT_GTE(find_resolved_arr(&out, "doQuery", "Connect"), 0); @@ -1109,7 +1109,7 @@ TEST(golsp_crossfile_return_type_chain) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names, imp_qns, 1, NULL, &out); ASSERT_GTE(find_resolved_arr(&out, "showUser", "Name"), 0); @@ -1149,7 +1149,7 @@ TEST(golsp_crossfile_interface_dispatch) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 4, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 4, imp_names, imp_qns, 1, NULL, &out); int idx = find_resolved_arr_confident(&out, "handler", "Bind"); @@ -1205,7 +1205,7 @@ TEST(golsp_crossfile_interface_field_chain) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 6, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 6, imp_names, imp_qns, 1, NULL, &out); int idx = find_resolved_arr_confident(&out, "process", "Bind"); @@ -1245,7 +1245,7 @@ TEST(golsp_crossfile_map_index) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 3, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 3, imp_names, imp_qns, 1, NULL, &out); int idx = find_resolved_arr_confident(&out, "dispatch", "ServeHTTP"); @@ -1275,7 +1275,7 @@ TEST(golsp_crossfile_stdlib_interface) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 1, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 1, imp_names, imp_qns, 1, NULL, &out); ASSERT_GTE(find_resolved_arr_confident(&out, "process", "Done"), 0); @@ -1349,7 +1349,7 @@ TEST(golsp_crossfile_aliased_field_requal) { 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, + cbm_run_go_lsp_cross_with_registry(&arena, source, (int)strlen(source), "test.main", NULL, reg, imp_names, imp_qns, 2, NULL, &out); int svc_idx = find_resolved_arr_confident(&out, "callSvc", "Svc.Ping"); @@ -1417,7 +1417,7 @@ TEST(golsp_crossfile_local_interface_single_impl) { cbm_arena_init(&arena); CBMResolvedCallArray out = {0}; - cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", defs, 5, imp_names, + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.main", NULL, defs, 5, imp_names, imp_qns, 1, NULL, &out); int idxGet = find_resolved_arr_confident(&out, "process", "Get"); @@ -1433,6 +1433,133 @@ TEST(golsp_crossfile_local_interface_single_impl) { PASS(); } +/* ── Go init(): per-file caller QN (#1910) ──────────────────────── */ + +/* Every init() of a package is its own node, pkg.init#: + * (cbm_go_init_qn). The LSP's enclosing-function QN must be exactly that QN: + * the pipeline joins LSP rows to textual calls by caller QN, so a flat + * pkg.init loses every LSP resolution inside an init body. Two inits in one + * file pin the ordinal; the method name exists on both types, so only the + * typed receiver tells the targets apart. */ +TEST(golsp_init_caller_qn_matches_def) { + const char *source = "package main\n\n" + "type Codec struct{}\n\n" + "func (c *Codec) register() {}\n\n" + "type Store struct{}\n\n" + "func (s *Store) register() {}\n\n" + "func init() {\n\tc := &Codec{}\n\tc.register()\n}\n\n" + "func init() {\n\ts := &Store{}\n\ts.register()\n}\n"; + CBMFileResult *r = extract_go(source); + ASSERT_NOT_NULL(r); + const char *init1 = NULL; + const char *init2 = NULL; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (d->name && strcmp(d->name, "init") == 0) { + if (!init1) { + init1 = d->qualified_name; + } else { + init2 = d->qualified_name; + } + } + } + ASSERT_NOT_NULL(init1); + ASSERT_NOT_NULL(init2); + ASSERT_NOT_NULL(strstr(init1, ".init#main.go:1")); + ASSERT_NOT_NULL(strstr(init2, ".init#main.go:2")); + + int a = require_resolved(r, "init", "Codec.register"); + ASSERT_GTE(a, 0); + ASSERT_STR_EQ(r->resolved_calls.items[a].caller_qn, init1); + int b = require_resolved(r, "init", "Store.register"); + ASSERT_GTE(b, 0); + ASSERT_STR_EQ(r->resolved_calls.items[b].caller_qn, init2); + + /* The join itself: each textual register() call (raw callee text + * "c.register" / "s.register") finds its own init's row. */ + int joined = 0; + for (int i = 0; i < r->calls.count; i++) { + const CBMCall *c = &r->calls.items[i]; + if (!c->callee_name || !strstr(c->callee_name, ".register")) { + continue; + } + const CBMResolvedCall *rc = cbm_pipeline_find_lsp_resolution(&r->resolved_calls, c, false); + ASSERT_NOT_NULL(rc); + ASSERT_STR_EQ(rc->caller_qn, c->enclosing_func_qn); + ASSERT_NOT_NULL( + strstr(rc->callee_qn, strcmp(c->enclosing_func_qn, init1) == 0 ? "Codec" : "Store")); + joined++; + } + ASSERT_EQ(joined, 2); + cbm_free_result(r); + PASS(); +} + +/* Cross-file twin: both cross entry points (per-file defs, and the shared + * Tier-2 registry the pipeline uses) name the init caller from rel_path. */ +TEST(golsp_crossfile_init_caller_qn_per_file) { + const char *source = "package reg\n\n" + "func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n\n" + "func init() {\n\ts := NewStore()\n\ts.Register()\n}\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.reg.NewCodec", + .short_name = "NewCodec", + .label = "Function", + .def_module_qn = "test.reg", + .return_types = "*Codec"}, + {.qualified_name = "test.reg.Codec", + .short_name = "Codec", + .label = "Class", + .def_module_qn = "test.reg"}, + {.qualified_name = "test.reg.Codec.Register", + .short_name = "Register", + .label = "Method", + .def_module_qn = "test.reg", + .receiver_type = "test.reg.Codec"}, + {.qualified_name = "test.reg.NewStore", + .short_name = "NewStore", + .label = "Function", + .def_module_qn = "test.reg", + .return_types = "*Store"}, + {.qualified_name = "test.reg.Store", + .short_name = "Store", + .label = "Class", + .def_module_qn = "test.reg"}, + {.qualified_name = "test.reg.Store.Register", + .short_name = "Register", + .label = "Method", + .def_module_qn = "test.reg", + .receiver_type = "test.reg.Store"}, + }; + + CBMArena arena; + cbm_arena_init(&arena); + CBMResolvedCallArray out = {0}; + cbm_run_go_lsp_cross(&arena, source, (int)strlen(source), "test.reg", "reg/b.go", defs, 6, NULL, + NULL, 0, NULL, &out); + int c1 = find_resolved_arr_confident(&out, "init", "Codec.Register"); + ASSERT_GTE(c1, 0); + ASSERT_STR_EQ(out.items[c1].caller_qn, "test.reg.init#b.go:1"); + int s1 = find_resolved_arr_confident(&out, "init", "Store.Register"); + ASSERT_GTE(s1, 0); + ASSERT_STR_EQ(out.items[s1].caller_qn, "test.reg.init#b.go:2"); + + CBMResolvedCallArray shared_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.reg", "reg/b.go", + reg, NULL, NULL, 0, NULL, &shared_out); + int c2 = find_resolved_arr_confident(&shared_out, "init", "Codec.Register"); + ASSERT_GTE(c2, 0); + ASSERT_STR_EQ(shared_out.items[c2].caller_qn, "test.reg.init#b.go:1"); + int s2 = find_resolved_arr_confident(&shared_out, "init", "Store.Register"); + ASSERT_GTE(s2, 0); + ASSERT_STR_EQ(shared_out.items[s2].caller_qn, "test.reg.init#b.go:2"); + + cbm_arena_destroy(&arena); + PASS(); +} + /* ── Suite ─────────────────────────────────────────────────────── */ SUITE(go_lsp) { @@ -1545,4 +1672,8 @@ SUITE(go_lsp) { RUN_TEST(golsp_crossfile_map_index); RUN_TEST(golsp_crossfile_stdlib_interface); RUN_TEST(golsp_crossfile_local_interface_single_impl); + + /* Go init(): per-file caller QN (#1910) */ + RUN_TEST(golsp_init_caller_qn_matches_def); + RUN_TEST(golsp_crossfile_init_caller_qn_per_file); } diff --git a/tests/test_parallel.c b/tests/test_parallel.c index a641e7e0e5..993c646eb7 100644 --- a/tests/test_parallel.c +++ b/tests/test_parallel.c @@ -5476,7 +5476,7 @@ TEST(parallel_php_external_row_replaces_only_its_exact_occurrence) { const char *keys[] = {"LocalRequest"}; const char *vals[] = {project_type}; cbm_pxc_run_one_with_php_bindings(CBM_LANG_PHP, r, source, (int)strlen(source), "p.main", - defs, 2, keys, vals, 1, &bindings); + NULL, defs, 2, keys, vals, 1, &bindings); int external_count = 0, project_count = 0; for (int i = 0; i < r->resolved_calls.count; i++) { const CBMResolvedCall *row = &r->resolved_calls.items[i]; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 8b413066d8..9f32c3d068 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -10298,6 +10298,194 @@ TEST(pipeline_go_multi_init_nodes_survive) { PASS(); } +/* Edges of `edge_type` whose source and target QNs end with the given + * suffixes (go_calls_between for any edge type, without the properties). */ +static int go_edges_between(cbm_store_t *s, const char *project, const char *edge_type, + const char *caller_suffix, const char *callee_suffix) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, edge_type, &edges, &edge_count); + size_t cs = strlen(caller_suffix); + size_t ts = strlen(callee_suffix); + int hits = 0; + for (int i = 0; i < edge_count; i++) { + cbm_node_t src = {0}; + cbm_node_t tgt = {0}; + if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK && + cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK && + src.qualified_name && tgt.qualified_name) { + size_t sl = strlen(src.qualified_name); + size_t tl = strlen(tgt.qualified_name); + if (sl >= cs && tl >= ts && strcmp(src.qualified_name + sl - cs, caller_suffix) == 0 && + strcmp(tgt.qualified_name + tl - ts, callee_suffix) == 0) { + hits++; + } + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + return hits; +} + +/* Fixture for the #1910 Go init() LSP-join probes (sequential and parallel + * twins). Three init() in two files of one package -- two in b.go, so the + * per-file ordinal is pinned too -- each calling a method that only the Go + * LSP can bind: the receiver's type comes from a constructor in another + * file, and the method name also exists on a second package type and on + * other.Decoy, so no short-name fallback lands on the right target. a.go's + * init also passes a handler by value (add("a", handleA): a CALL_REFERENCE, + * which exists only as an LSP row). pad_files > 0 adds filler Go files past + * MIN_FILES_FOR_PARALLEL. */ +static void write_go_init_lsp_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "go.mod", "module example.com/fxinit\n\ngo 1.22\n"); + write_temp_file(tmp, "reg/codec.go", + "package reg\n" + "\n" + "type Codec struct{ n int }\n" + "\n" + "func NewCodec() *Codec { return &Codec{} }\n" + "\n" + "func (c *Codec) register() { c.n++ }\n"); + write_temp_file(tmp, "reg/store.go", + "package reg\n" + "\n" + "type Store struct{ open bool }\n" + "\n" + "func NewStore() *Store { return &Store{} }\n" + "\n" + "func (s *Store) register() { s.open = true }\n" + "\n" + "func (s *Store) warm() {}\n"); + write_temp_file(tmp, "reg/a.go", + "package reg\n" + "\n" + "var handlers = map[string]func(){}\n" + "\n" + "func handleA() {}\n" + "\n" + "func add(name string, fn func()) { handlers[name] = fn }\n" + "\n" + "func init() {\n" + "\tc := NewCodec()\n" + "\tc.register()\n" + "\tadd(\"a\", handleA)\n" + "}\n"); + write_temp_file(tmp, "reg/b.go", + "package reg\n" + "\n" + "func init() {\n" + "\ts := NewStore()\n" + "\ts.register()\n" + "}\n" + "\n" + "func init() {\n" + "\ts := NewStore()\n" + "\ts.warm()\n" + "}\n"); + write_temp_file(tmp, "other/other.go", + "package other\n" + "\n" + "type Decoy struct{}\n" + "\n" + "func (d *Decoy) register() {}\n" + "\n" + "func (d *Decoy) warm() {}\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); + } +} + +/* Each init's LSP-resolved edges start at that init's own node. */ +static int assert_go_init_lsp_edges(cbm_store_t *s, const char *project) { + const char *lsp = "\"strategy\":\"lsp_"; + ASSERT_GTE(go_calls_between(s, project, ".reg.init#a.go:1", ".reg.Codec.register", lsp), 1); + ASSERT_GTE(go_calls_between(s, project, ".reg.init#b.go:1", ".reg.Store.register", lsp), 1); + ASSERT_GTE(go_calls_between(s, project, ".reg.init#b.go:2", ".reg.Store.warm", lsp), 1); + ASSERT_GTE(go_edges_between(s, project, "CALL_REFERENCE", ".reg.init#a.go:1", ".reg.handleA"), + 1); + /* No cross-wiring between the inits, and nothing onto the decoy. */ + ASSERT_EQ(go_calls_between(s, project, ".reg.init#a.go:1", ".reg.Store.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:1", ".reg.Codec.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:2", ".reg.Store.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#a.go:1", ".other.Decoy.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:1", ".other.Decoy.register", NULL), 0); + ASSERT_EQ(go_calls_between(s, project, ".reg.init#b.go:2", ".other.Decoy.warm", NULL), 0); + return 0; +} + +/* #1910: the def extractor and compute_func_qn name each init + * pkg.init#:; the Go LSP must name its caller the same way + * (cbm_go_init_qn), or every LSP resolution inside an init body loses the + * exact caller-QN join and falls back to the registry resolver. */ +TEST(pipeline_go_init_lsp_edges_per_file) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_lsp_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_init_lsp_fixture(tmp, 0); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_init_lsp.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_EQ(assert_go_init_lsp_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Parallel twin: >= MIN_FILES_FOR_PARALLEL (50) files plus CBM_WORKERS=4 + * take the parallel resolver (resolve_worker's Tier-2 dispatch). */ +TEST(pipeline_go_init_lsp_edges_per_file_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_lsp_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_init_lsp_fixture(tmp, 52); + + char *old_workers = getenv("CBM_WORKERS"); + char *saved = old_workers ? strdup(old_workers) : NULL; + cbm_setenv("CBM_WORKERS", "4", 1); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_init_lsp_par.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_EQ(assert_go_init_lsp_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + 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 @@ -16256,6 +16444,53 @@ TEST(pipeline_python_init_import_probe_failure_falls_back) { PASS(); } +/* #1910: with no shared Go registry (the incremental route can pass NULL + * cross_registries) the dispatch takes the per-file fallback, which must name + * each init() caller from the file path exactly as the def extractor does. */ +TEST(pipeline_go_init_dispatch_fallback_names_init_caller) { + const char *source = "package reg\n\n" + "func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n\n" + "func init() {\n\tc := NewCodec()\n\tc.Register()\n}\n"; + CBMLSPDef defs[] = { + {.qualified_name = "test.reg.NewCodec", + .short_name = "NewCodec", + .label = "Function", + .def_module_qn = "test.reg", + .return_types = "*Codec", + .lang = CBM_LANG_GO}, + {.qualified_name = "test.reg.Codec", + .short_name = "Codec", + .label = "Class", + .def_module_qn = "test.reg", + .lang = CBM_LANG_GO}, + {.qualified_name = "test.reg.Codec.Register", + .short_name = "Register", + .label = "Method", + .def_module_qn = "test.reg", + .receiver_type = "test.reg.Codec", + .lang = CBM_LANG_GO}, + }; + CBMFileResult result = {0}; + cbm_arena_init(&result.arena); + cbm_pxc_dispatch_file(CBM_LANG_GO, &result, source, (int)strlen(source), "reg/b.go", "test.reg", + NULL, NULL, defs, 3, NULL, NULL, 0, NULL, NULL); + int first = 0; + int second = 0; + for (int i = 0; i < result.resolved_calls.count; i++) { + const CBMResolvedCall *rc = &result.resolved_calls.items[i]; + if (!rc->caller_qn || !rc->callee_qn || rc->confidence <= 0 || + strcmp(rc->callee_qn, "test.reg.Codec.Register") != 0) { + continue; + } + first += strcmp(rc->caller_qn, "test.reg.init#b.go:1") == 0; + second += strcmp(rc->caller_qn, "test.reg.init#b.go:2") == 0; + } + cbm_arena_destroy(&result.arena); + ASSERT_EQ(first, 1); + ASSERT_EQ(second, 1); + PASS(); +} + /* #1277: typed instance fields of a Python class imported from another file. * Counts CALLS edges caller -> callee (QN suffixes) whose properties name an * LSP strategy. */ @@ -28171,6 +28406,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_init_register_scope_failure_parallel); RUN_TEST(pipeline_python_init_import_filter_precedence); RUN_TEST(pipeline_python_init_import_probe_failure_falls_back); + RUN_TEST(pipeline_go_init_dispatch_fallback_names_init_caller); RUN_TEST(pipeline_issue1527_python_raw_memo_failure_sequential); RUN_TEST(pipeline_issue1527_python_raw_memo_failure_parallel); RUN_TEST(pipeline_issue1527_python_cross_memo_failure_sequential); @@ -28291,6 +28527,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_go_multi_return_receiver_is_typed); RUN_TEST(pipeline_go_multi_return_receiver_is_typed_parallel); RUN_TEST(pipeline_go_multi_init_nodes_survive); + RUN_TEST(pipeline_go_init_lsp_edges_per_file); + RUN_TEST(pipeline_go_init_lsp_edges_per_file_parallel); 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_call_never_binds_another_language);