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 6bb4a32bc..b7661d5e6 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 b25e9f592..db6a86178 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 1ce766927..0a43a3f5e 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 d174bb6d6..6122f450d 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 eefdb7596..10ee864bd 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 5b8d16f61..6f7d0c085 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 d62a5572f..2c7f45732 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 3e1d604af..b58e98dde 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 2e359af67..b298ec50c 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 a5e314272..8502c37b8 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 4e8150b6f..29a858328 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 7efa273e1..812026291 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 6f7d0c085..8f5fbf2d2 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 631446f9c..d3cd08417 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 2c7f45732..96eac8714 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 b298ec50c..af49ef485 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 8502c37b8..1bcbd5753 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 e8a6eb8ec..5ac97f610 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 29a858328..e2f148ecf 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 8f5fbf2d2..edecba47d 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 96eac8714..a5d5b545f 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 ac46c17fc37f78ae518145e73d51b46ca3013fac Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sun, 30 Aug 2026 13:43:40 +0200 Subject: [PATCH 4/4] fix(pipeline): bind field_type_hint by owning QN segment, not substring try_field_type_hint accepted any candidate whose qualified name merely CONTAINED the hinted type name. A single-letter receiver (f.Close(), hint "F") matched almost every same-named candidate - the method name itself included - and rebound a call whose resolution had already failed to an arbitrary project symbol at confidence 0.85, presented as the one weak strategy the Go selector guard deliberately trusts. Require the candidate's owning dot-segment - the segment immediately before the method - to EQUAL the hinted type (or its I-prefixed interface form). The intended variable-named-after-its-type case keeps resolving (lrp_go_s8_field_type_hint stays green); the fabricated bindings lose their only anchor and fall back to the weak short-name strategies the guards already handle. Measured on a real ~1150-file Go repo (with the #1906/#1909/#1910 fixes applied): field_type_hint 1484 -> 356 CALLS edges, the single-character-receiver class 376 -> 0, Go->C/C++ targets 34 -> 0, and no other strategy absorbed the removed edges. 63 edges moved to the interface that declares the method instead of an arbitrary implementation. Fixes #1927 Signed-off-by: Ilya Brykau --- src/pipeline/pass_parallel.c | 26 +++++++- tests/test_pipeline.c | 120 +++++++++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 0a43a3f5e..6fffffea0 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2092,6 +2092,29 @@ static const cbm_gbuf_node_t *find_source_node(const cbm_gbuf_t *gbuf, const cha /* Field type hint resolution for obj.Method() with multiple candidates. * Strips C# field prefixes (_ / m_), capitalizes to get type name, and * checks if TypeName.Method or ITypeName.Method exists among candidates. */ +/* #1927: the hint may only bind a candidate whose OWNING dot-segment — the + * segment immediately before the method name — EQUALS the hinted type. The + * previous raw strstr() accepted the hinted name anywhere in the QN, so a + * single-letter receiver (`f.Close()` → hint "F") matched nearly every + * candidate ("F" ⊂ "…FileStore.Close", the method name included) and rebound + * a call whose resolution had already failed to an arbitrary project symbol + * at PP_FIELD_HINT_CONF. Segment equality keeps the intended + * variable-named-after-its-type case (`repo.Find()` → `Repo.Find`, + * lrp_go_s8_field_type_hint) and picks the candidate whose owner actually + * carries the name instead of whichever QN contains the letters. */ +static bool fth_owner_segment_is(const char *candidate_qn, const char *type_name) { + const char *last_dot = strrchr(candidate_qn, '.'); + if (!last_dot || last_dot == candidate_qn) { + return false; + } + const char *seg_start = last_dot; + while (seg_start > candidate_qn && seg_start[-1] != '.') { + seg_start--; + } + size_t seg_len = (size_t)(last_dot - seg_start); + return seg_len > 0 && strncmp(seg_start, type_name, seg_len) == 0 && type_name[seg_len] == '\0'; +} + static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const char *callee_name, int64_t source_id) { if (!res->qualified_name || res->candidate_count <= SKIP_ONE) { @@ -2131,7 +2154,8 @@ static void try_field_type_hint(resolve_ctx_t *rc, cbm_resolution_t *res, const int cand_count = 0; cbm_registry_find_by_name(rc->registry, method, &cands, &cand_count); for (int ci = 0; ci < cand_count; ci++) { - if (strstr(cands[ci], type_name) || strstr(cands[ci], iface_name)) { + if (fth_owner_segment_is(cands[ci], type_name) || + fth_owner_segment_is(cands[ci], iface_name)) { const cbm_gbuf_node_t *better = cbm_gbuf_find_by_qn(rc->main_gbuf, cands[ci]); if (better && better->id != source_id) { res->qualified_name = cands[ci]; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index a5d5b545f..666b4674d 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4886,6 +4886,125 @@ TEST(pipeline_go_multi_init_nodes_survive) { PASS(); } +TEST(pipeline_go_parallel_field_hint_requires_owner_segment) { + /* #1927: try_field_type_hint (parallel resolver only) matched the hinted + * type name as a raw SUBSTRING of the candidate QN. A single-letter + * receiver — `f.Close()` on an *os.File — hints "F", and "F" is a + * substring of "…FileStore.Close", so a stdlib call was rebound to an + * arbitrary project method at PP_FIELD_HINT_CONF and presented as + * field_type_hint (the one weak strategy the Go selector guard trusts). + * The hint must fire only when the candidate's OWNING segment — the + * dot-segment immediately before the method — EQUALS the hinted type; + * otherwise the resolution stays a weak short-name match and the Go + * guard (#1906) drops it. >= 50 files forces pass_parallel.c, the only + * path that runs try_field_type_hint(). */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_fth_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "go.mod", "module example.com/fxhint\n\ngo 1.22\n"); + /* Two project methods named Close → the registry resolves f.Close() as an + * ambiguous short-name match (candidate_count 2), the shape the hint + * upgrades. FileStore's QN carries the capital F the buggy substring + * latches onto; Conn is the second candidate. */ + write_temp_file(tmp, "filestore/filestore.go", + "package filestore\n" + "\n" + "type FileStore struct{ open bool }\n" + "\n" + "func (fs *FileStore) Close() {\n" + "\tfs.open = false\n" + "}\n"); + write_temp_file(tmp, "conn/conn.go", + "package conn\n" + "\n" + "type Conn struct{ live bool }\n" + "\n" + "func (cn *Conn) Close() {\n" + "\tcn.live = false\n" + "}\n"); + /* Two project methods named Find → same ambiguity, but here the receiver + * variable IS named after its type (`finder`), the case the hint exists + * for: the owning segment of Finder.Find equals the hinted "Finder". */ + write_temp_file(tmp, "finder/finder.go", + "package finder\n" + "\n" + "type Finder struct{ n int }\n" + "\n" + "func NewFinder() *Finder { return &Finder{n: 1} }\n" + "\n" + "func (fi *Finder) Find(id int) int {\n" + "\treturn fi.n + id\n" + "}\n"); + write_temp_file(tmp, "locator/locator.go", + "package locator\n" + "\n" + "type Locator struct{ n int }\n" + "\n" + "func (lo *Locator) Find(id int) int {\n" + "\treturn lo.n - id\n" + "}\n"); + write_temp_file(tmp, "app/app.go", + "package app\n" + "\n" + "import (\n" + "\t\"os\"\n" + "\n" + "\txf \"example.com/fxhint/finder\"\n" + ")\n" + "\n" + "func Lookup(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 UseFinder(id int) int {\n" + "\tfinder := xf.NewFinder()\n" + "\treturn finder.Find(id)\n" + "}\n"); + for (int i = 0; i < 52; 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); + } + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_fth.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) Reproduce-first: RED before the fix. The substring hint rebinds the + * stdlib f.Close() to FileStore.Close ("F" ⊂ QN) at high confidence and + * the Go guard keeps it. With segment equality the upgrade is refused, + * the match stays suffix_match, and the Go guard drops it. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "Lookup", "Close")); + /* (2) The intended hint case survives: `finder` names its type, so the + * owning segment of Finder.Find equals the hint. */ + ASSERT_TRUE(cross_file_call_exists(s, project, "UseFinder", "Find")); + + 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 @@ -13310,6 +13429,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_parallel_field_hint_requires_owner_segment); 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);