From 50ee111714baa9d3bfe9bdba3b81e27cd1bdfe38 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Fri, 28 Aug 2026 17:26:26 +0200 Subject: [PATCH 1/3] 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 47250203d8..6d315faf04 100644 --- a/internal/cbm/extract_calls.c +++ b/internal/cbm/extract_calls.c @@ -3712,6 +3712,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")); /* tree-sitter-elixir attaches NO field name to a call's arguments diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index c9744dda29..2d96d440d1 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -623,7 +623,13 @@ 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; @@ -636,7 +642,9 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, bool drop_plain_call = cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || cbm_suppress_weak_local_binding_call(suppress_weak_local_binding, - call->callee_is_locally_bound, res.strategy); + call->callee_is_locally_bound, 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 f5e4ab4d22..3e896c1f42 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -2834,7 +2834,9 @@ 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; @@ -2844,7 +2846,9 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB bool drop_plain_call = cbm_suppress_weak_member_match(suppress_weak_member, call->is_method, res.strategy) || cbm_suppress_weak_local_binding_call(suppress_weak_local_binding, - call->callee_is_locally_bound, res.strategy); + call->callee_is_locally_bound, 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 954686849d..764f8bd3c5 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -297,6 +297,15 @@ bool cbm_suppress_weak_member_match(bool enabled, bool is_method, const char *st bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_bound, 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 fa926d3c54..6eabdf2274 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -501,6 +501,33 @@ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_b return weak_short_name_strategy(strategy); } +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 14d75bf8bd..4bdab079ef 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -5204,9 +5204,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++) { @@ -5364,6 +5369,33 @@ TEST(extract_python_bare_call_flags_locally_bound_callee) { 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(); +} + /* The bare-call flag is DEPTH-INDEPENDENT, and the binding is UNWOUND when its * scope closes. * @@ -7508,6 +7540,7 @@ SUITE(extraction) { RUN_TEST(extract_python_member_call_flags_is_method); RUN_TEST(extract_python_bare_call_flags_locally_bound_callee); RUN_TEST(extract_python_bare_call_flag_is_depth_independent); + 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 565c727196..dfa5fea20a 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5467,6 +5467,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 @@ -14594,6 +14687,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 247ca61adf..aaeb70abb4 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -973,6 +973,46 @@ TEST(weak_call_guards_share_one_drop_list) { 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 @@ -1072,4 +1112,6 @@ SUITE(registry) { RUN_TEST(local_binding_suppress_drops_weak_shadowed_bare_calls); RUN_TEST(local_binding_suppress_keeps_unshadowed_and_strong_strategies); RUN_TEST(weak_call_guards_share_one_drop_list); + RUN_TEST(go_suppress_drops_weak_selector_matches); + RUN_TEST(go_suppress_keeps_typed_and_import_aware_matches); } From 7528ec06fe7da5ab703e7c802624fef88285ae37 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 17 Oct 2026 22:33:37 +0200 Subject: [PATCH 2/3] test(pipeline): bind the Go selector guard on the parallel resolver Review follow-up for #1907 (asks from the 2026-09-28 review): - pipeline_go_receiver_parallel_suppresses_weak_method_edge: parallel twin of pipeline_go_receiver_suppresses_weak_method_edge. The guard is wired into pass_parallel.c as well as pass_calls.c, but the four-file fixture only runs the sequential resolver, so the parallel hunk was bound by no test. The fixture is shared (write_go_receiver_fixture) and padded with 52 Go files past MIN_FILES_FOR_PARALLEL, with CBM_WORKERS=4, in the shape of the Python parallel twin. - registry.c: cbm_go_suppress_weak_method_match builds on the shared weak_short_name_strategy list and narrows it in two places only (field_type_hint kept, unique_name only when penalized). Behaviour is unchanged; weak_call_guards_share_one_drop_list now binds the Go guard to the shared list too. - test_extraction.c: the flag-exempt comment names the current flag set (Perl, Python, TS/JS/TSX and Go) and Rust as the exempt language. Refs #1906 Signed-off-by: Martin Vogel --- src/pipeline/registry.c | 24 +++++++------ tests/test_extraction.c | 4 +-- tests/test_pipeline.c | 78 +++++++++++++++++++++++++++++++++++++---- tests/test_registry.c | 10 ++++++ 4 files changed, 97 insertions(+), 19 deletions(-) diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index cbfa4ec5d1..bfc2e330eb 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -750,19 +750,23 @@ bool cbm_suppress_weak_local_binding_call(bool enabled, bool callee_is_locally_b 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; + * candidates). Built on the shared drop-list so "weak" means one thing + * for every guard; Go narrows it in two places only. */ + if (!is_go || !is_method || !weak_short_name_strategy(strategy)) { + return false; + } + /* field_type_hint is KEPT for Go: 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, "field_type_hint") == 0) { + return false; + } + if (strcmp(strategy, "unique_name") != 0) { + return true; /* suffix_match, fuzzy */ } /* unique_name is dropped only when PENALIZED: resolve_name_lookup scales * CONF_UNIQUE_NAME by DEFAULT_CONFIDENCE exactly when the lone candidate @@ -772,7 +776,7 @@ bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *s * 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; + return confidence < CONF_UNIQUE_NAME; } /* Import-binding counterpart of the parameter guard above (#2127). An import diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 41a796ce85..13b03aa370 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -6886,8 +6886,8 @@ TEST(extract_perl_method_call_flags_is_method) { PASS(); } -/* 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. */ +/* Languages OUTSIDE the is_method flag set (Perl, Python, TS/JS/TSX and Go set + * it) must be unaffected: a Rust method call never sets is_method. */ TEST(extract_flag_exempt_method_call_not_flagged_is_method) { /* 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 diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 4da054b144..d413aa9bb9 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8650,13 +8650,11 @@ TEST(pipeline_html_embedded_member_call_stays_unbound) { 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"); - } - +/* Fixture for the #1906 Go selector-guard probes (sequential and parallel + * twins). pad_files > 0 adds filler Go files to push the index over + * MIN_FILES_FOR_PARALLEL, because the guard is wired into both resolvers + * (pass_calls.c and pass_parallel.c) and each needs its own probe. */ +static void write_go_receiver_fixture(const char *tmp, int pad_files) { /* 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"); @@ -8715,6 +8713,22 @@ TEST(pipeline_go_receiver_suppresses_weak_method_edge) { "func CallsLocal() int { return localHelper() }\n" "\n" "func UsesUtil() string { return util.Tag() }\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "filler/filler%d.go", i); + snprintf(body, sizeof(body), "package filler\n\nfunc Filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +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"); + } + write_go_receiver_fixture(tmp, 0); char db_path[512]; snprintf(db_path, sizeof(db_path), "%s/go_recv.db", tmp); @@ -8743,6 +8757,55 @@ TEST(pipeline_go_receiver_suppresses_weak_method_edge) { PASS(); } +/* Parallel twin of the test above. The guard is wired into pass_parallel.c as + * well as pass_calls.c, but the four-file fixture only ever runs the + * sequential resolver, so the parallel hunk was bound by no test. Real + * repositories take the parallel path: >= MIN_FILES_FOR_PARALLEL (50) files + * plus CBM_WORKERS=4 force it, the shape of + * pipeline_python_receiver_parallel_suppresses_weak_method_edges. Same + * both-directions pin as the sequential test. */ +TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_recv_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_receiver_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_recv_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); + + /* NEGATIVE: the stdlib *os.File Close must not bind the project method. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "FileLen", "Close")); + /* POSITIVE: typed same-package, bare local and import-qualified calls + * survive the parallel resolver too. */ + ASSERT_TRUE(cross_file_call_exists(s, project, "Boot", "Close")); + ASSERT_TRUE(cross_file_call_exists(s, project, "CallsLocal", "localHelper")); + ASSERT_TRUE(cross_file_call_exists(s, project, "UsesUtil", "Tag")); + + 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 @@ -23707,6 +23770,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_keeps_specific_unique_name_member_call); RUN_TEST(pipeline_html_embedded_member_call_stays_unbound); RUN_TEST(pipeline_go_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_go_receiver_parallel_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_rust_std_receiver_never_binds_project_method); diff --git a/tests/test_registry.c b/tests/test_registry.c index e25ba369e6..a6fbd62011 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1673,6 +1673,16 @@ TEST(weak_call_guards_share_one_drop_list) { strategies[i], member, binding); } ASSERT_EQ(member, binding); + /* The Go selector guard builds on the same list and narrows it in + * exactly one place at a penalized confidence (0.75 * 0.5): it keeps + * field_type_hint. Any other drift from the shared list fails here. */ + bool go = cbm_go_suppress_weak_method_match(true, true, strategies[i], 0.375); + bool go_expected = member && strcmp(strategies[i], "field_type_hint") != 0; + if (go != go_expected) { + printf(" Go drop-list divergence on strategy \"%s\": go=%d expected=%d\n", + strategies[i], go, go_expected); + } + ASSERT_EQ(go, go_expected); } PASS(); } From e554a2ab58ca954c2e9f46ba3fe9d340d1bcf06d Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sun, 18 Oct 2026 16:54:36 +0200 Subject: [PATCH 3/3] fix(go): no field-type hint for Go; it is a name guess, par-only The parallel resolver re-picks a multi-candidate registry match through try_field_type_hint. That helper capitalizes the receiver VARIABLE name and accepts any candidate whose QN contains it as a substring: `t.Run` on a *testing.T became `T` and bound a project `TBench.Run`. The sequential resolver has no such step. The Go selector guard treated field_type_hint as receiver-aware and kept it, so the same weak pick it drops in sequential mode survived in parallel mode under a new name. On caddy + prometheus, parallel and sequential CALLS diverged by 1,684 (this branch) and 5,560 (with receiver-qualified method QNs, which put type names into more QNs). At base the gap is 84. SCIP precision of Go field_type_hint is 0.016 at base. Go no longer takes the hint. The guard keeps field_type_hint on the shared weak list, and the fixture of both twins now pins the `t.Run` case. A typed field call (lrp_go_s8, `s.repo.Find()`) resolves through the Go LSP and is unaffected. Signed-off-by: Martin Vogel --- src/pipeline/pass_parallel.c | 10 +++++++++- src/pipeline/registry.c | 14 ++++++-------- tests/test_pipeline.c | 28 ++++++++++++++++++++++++++++ tests/test_registry.c | 15 ++++++++------- 4 files changed, 51 insertions(+), 16 deletions(-) diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 37bdc26d49..56a85e8a25 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3298,7 +3298,15 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB } _rc_t0 = extract_now_ns(); - try_field_type_hint(rc, &res, call->callee_name, source_node->id); + /* Not for Go (F-G9): the hint matches the capitalized receiver + * VARIABLE name as a substring of a candidate QN (`t.Run` -> any + * `...T...Run`), with no type evidence. The sequential resolver has no + * such step, so for Go it produced parallel-only edges (par/seq CALLS + * diverged), and the Go selector guard could not see them as the weak + * pick they are. SCIP precision of Go field_type_hint: 0.016. */ + if (lang != CBM_LANG_GO) { + try_field_type_hint(rc, &res, call->callee_name, source_node->id); + } /* Cross-language veto — the same predicate as pass_calls.c, applied * AFTER the field-type hint so the hint may still re-pick a compatible * candidate first, and BEFORE the empty-resolution fallbacks below so a diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index bfc2e330eb..9b5165a272 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -755,18 +755,16 @@ bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *s * 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). Built on the shared drop-list so "weak" means one thing - * for every guard; Go narrows it in two places only. */ + * for every guard; Go narrows it in one place only (unique_name below). + * field_type_hint stays weak: it is a receiver-NAME substring match, not + * a declared-type lookup (the parallel resolver no longer produces it for + * Go; a typed field call such as lrp_go_s8's `s.repo.Find()` resolves + * through the Go LSP). */ if (!is_go || !is_method || !weak_short_name_strategy(strategy)) { return false; } - /* field_type_hint is KEPT for Go: 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, "field_type_hint") == 0) { - return false; - } if (strcmp(strategy, "unique_name") != 0) { - return true; /* suffix_match, fuzzy */ + return true; /* suffix_match, field_type_hint, fuzzy */ } /* unique_name is dropped only when PENALIZED: resolve_name_lookup scales * CONF_UNIQUE_NAME by DEFAULT_CONFIDENCE exactly when the lone candidate diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index d413aa9bb9..6d04ad3275 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -8713,6 +8713,30 @@ static void write_go_receiver_fixture(const char *tmp, int pad_files) { "func CallsLocal() int { return localHelper() }\n" "\n" "func UsesUtil() string { return util.Tag() }\n"); + /* F-G9: two project methods named Run, one under a directory whose QN + * carries an uppercase T. `t.Run` on a *testing.T must bind neither. The + * parallel resolver field-type hint matched the capitalized receiver + * variable ("T") as a QN substring and produced a par-only edge. */ + write_temp_file(tmp, "TBench/bench.go", + "package tbench\n" + "\n" + "type Bench struct{}\n" + "\n" + "func (b *Bench) Run(name string) bool { return name != \"\" }\n"); + write_temp_file(tmp, "worker/worker.go", + "package worker\n" + "\n" + "type Worker struct{}\n" + "\n" + "func (w *Worker) Run(name string) bool { return false }\n"); + write_temp_file(tmp, "hash/hash_test.go", + "package hash\n" + "\n" + "import \"testing\"\n" + "\n" + "func TestSubRun(t *testing.T) {\n" + "\tt.Run(\"a\", func(t *testing.T) {})\n" + "}\n"); for (int i = 0; i < pad_files; i++) { char name[64]; char body[128]; @@ -8750,6 +8774,8 @@ TEST(pipeline_go_receiver_suppresses_weak_method_edge) { /* (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")); + /* (5) F-G9: the stdlib *testing.T Run binds no project Run. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "TestSubRun", "Run")); cbm_store_close(s); cbm_pipeline_free(p); @@ -8793,6 +8819,8 @@ TEST(pipeline_go_receiver_parallel_suppresses_weak_method_edge) { ASSERT_TRUE(cross_file_call_exists(s, project, "Boot", "Close")); ASSERT_TRUE(cross_file_call_exists(s, project, "CallsLocal", "localHelper")); ASSERT_TRUE(cross_file_call_exists(s, project, "UsesUtil", "Tag")); + /* F-G9: the parallel-only field-type hint must not bind `t.Run` either. */ + ASSERT_FALSE(cross_file_call_exists(s, project, "TestSubRun", "Run")); cbm_store_close(s); cbm_pipeline_free(p); diff --git a/tests/test_registry.c b/tests/test_registry.c index a6fbd62011..90ab691706 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1673,11 +1673,12 @@ TEST(weak_call_guards_share_one_drop_list) { strategies[i], member, binding); } ASSERT_EQ(member, binding); - /* The Go selector guard builds on the same list and narrows it in - * exactly one place at a penalized confidence (0.75 * 0.5): it keeps - * field_type_hint. Any other drift from the shared list fails here. */ + /* The Go selector guard builds on the same list. At a penalized + * confidence (0.75 * 0.5) it agrees with it exactly; its one narrowing + * (an unpenalized unique_name) does not apply here. Any drift from + * the shared list fails here. */ bool go = cbm_go_suppress_weak_method_match(true, true, strategies[i], 0.375); - bool go_expected = member && strcmp(strategies[i], "field_type_hint") != 0; + bool go_expected = member; if (go != go_expected) { printf(" Go drop-list divergence on strategy \"%s\": go=%d expected=%d\n", strategies[i], go, go_expected); @@ -1697,6 +1698,9 @@ TEST(go_suppress_drops_weak_selector_matches) { 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)); + /* field_type_hint is a receiver-NAME substring match, not type evidence + * (F-G9): weak for Go like the shared list. */ + ASSERT_TRUE(cbm_go_suppress_weak_method_match(true, true, "field_type_hint", 0.85)); PASS(); } @@ -1706,9 +1710,6 @@ TEST(go_suppress_keeps_typed_and_import_aware_matches) { * 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));