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/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 2e359af67..af49ef485 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,13 +3720,42 @@ 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); + } + } + + /* 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 diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index a5e314272..1bcbd5753 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -899,6 +899,41 @@ 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); + } + } + } + + /* 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 4e8150b6f..e2f148ecf 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); @@ -191,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/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/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..edecba47d 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); @@ -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,138 @@ 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(); +} + +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(); +} + +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 @@ -6503,6 +6640,9 @@ 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_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_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 d62a5572f..a5d5b545f 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4693,6 +4693,199 @@ 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"); + 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(); +} + +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 @@ -13115,6 +13308,9 @@ 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_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); 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); }