diff --git a/internal/cbm/doclink_go.c b/internal/cbm/doclink_go.c
index 97dab618d..aeb0c5858 100644
--- a/internal/cbm/doclink_go.c
+++ b/internal/cbm/doclink_go.c
@@ -43,10 +43,10 @@
* drops these lines and so carries no line number
* at all.
* G path go.mod: the module path
- * w says who keeps the node. A Go method node has a FLAT qualified name
- * (
.), so a function, the methods of every receiver, a type and
- * a value of one name all claim the node ., and within a file the
- * declaration on the LAST line keeps it (cbm_gbuf_upsert_node). w is 1 for
+ * w says who keeps the node .. A function, a type and a value of
+ * one name all claim it, and within a file the declaration on the LAST line
+ * keeps it (cbm_gbuf_upsert_node). A method claims nothing: its node is
+ * receiver-qualified (.., #1913), and its w is `-`. w is 1 for
* that declaration, 0 for the others of that name, x when two of them start
* on that last line, and - for a value the extractor gives no node at all
* (it gives one to the first name of a constant spec and of a parenthesis-
@@ -1511,9 +1511,11 @@ static void go_scan_method(go_scan_t *s, TSNode m) {
recv = "?";
recv_len = SKIP_ONE;
}
- go_push_claim(s,
- cbm_arena_sprintf(s->tmp, "M\t%.*s\t%.*s", (int)recv_len, recv, (int)len, name),
- name, len, m);
+ /* #1913: a Go method node is receiver-qualified (..),
+ * so a method claims no flat name: w is `-`, and the method is found
+ * through its receiver (the member table), never through .. */
+ (void)go_push_rec(
+ s, cbm_arena_sprintf(s->tmp, "M\t%.*s\t%.*s\t-", (int)recv_len, recv, (int)len, name));
}
/* The implied name of an embedded field: the type's own name, without
diff --git a/internal/cbm/extract_calls.c b/internal/cbm/extract_calls.c
index 2f512e5c7..4eb02659c 100644
--- a/internal/cbm/extract_calls.c
+++ b/internal/cbm/extract_calls.c
@@ -4493,6 +4493,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/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c
index 6491a677f..eb654cf33 100644
--- a/internal/cbm/extract_defs.c
+++ b/internal/cbm/extract_defs.c
@@ -5453,7 +5453,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);
@@ -6687,12 +6687,27 @@ 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. Go only: templ's Go-derived grammar reaches this
+ * branch too, but its call-scope QN (compute_func_qn) stays flat,
+ * and a def QN the calls do not share would orphan every call
+ * inside a templ method. */
+ if (ctx->language == CBM_LANG_GO && def.parent_class) {
+ 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 aa3db1e0d..fbb7e12c3 100644
--- a/internal/cbm/extract_unified.c
+++ b/internal/cbm/extract_unified.c
@@ -1110,6 +1110,29 @@ 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);
+ /* NULL only on allocation failure: fall through to the flat
+ * QN, as the def side does (extract_defs.c). */
+ if (type_qn) {
+ 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 f7c18c808..3ffa80bc4 100644
--- a/internal/cbm/helpers.h
+++ b/internal/cbm/helpers.h
@@ -173,6 +173,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 58c151a53..34fad4b9e 100644
--- a/internal/cbm/lsp/go_lsp.c
+++ b/internal/cbm/lsp/go_lsp.c
@@ -1987,15 +1987,24 @@ static void process_function(GoLSPContext *ctx, TSNode func_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/doc_links_go.c b/src/pipeline/doc_links_go.c
index 047a7654e..b43289c95 100644
--- a/src/pipeline/doc_links_go.c
+++ b/src/pipeline/doc_links_go.c
@@ -32,10 +32,11 @@
* fields of its own struct declaration and the methods of its own interface
* declaration: nothing promoted through embedding.
*
- * Binding. The graph keeps one node per qualified name, Go method nodes have
- * a FLAT name (.), and is the directory, not the package
- * clause: functions, methods of every receiver, types and values of one name,
- * in every file of the directory, share one node, and the node is ONE of those
+ * Binding. The graph keeps one node per qualified name, and is the
+ * directory, not the package clause. Go method nodes are receiver-qualified
+ * (.., #1913) and are found through their receiver only.
+ * Functions, types and values of one name, in every file of the directory,
+ * share one node ., and the node is ONE of those
* declarations (cbm_gbuf_upsert_node keeps the smallest file path, then the
* last line). So a reference binds only when the node IS the entity it names:
* the node's file is a file of the named package, the declaration that keeps
@@ -59,7 +60,7 @@
* not), missing (a repository package that does not declare the name; an
* import path inside a module without a package there), graph_gap (declared,
* but the node is another declaration's or absent: block variables, later
- * names of a declaration, embedded fields, methods that lost the flat name;
+ * names of a declaration, embedded fields;
* also an undeclared name in a package with a parse error), test_only_target
* (product code naming a declaration that only test files have), ambiguous
* (several modules or packages fit; a name declared as two kinds; a name two
@@ -1063,7 +1064,7 @@ static void go_record_decl(go_index_t *ix, go_file_t *file, char **f, int nf) {
FIELD_FIELDS = 4,
IM_FIELDS = 3
};
- enum { F_OWNER = 2, T_ALIAS = 3, T_OWNER = 4, V_OWNER = 2, M_OWNER = 3, FIELD_NODE = 3 };
+ enum { F_OWNER = 2, T_ALIAS = 3, T_OWNER = 4, V_OWNER = 2, FIELD_NODE = 3 };
char kind = f[0][0];
if (nf < IM_FIELDS || !go_name_ok(f[SKIP_ONE])) {
return;
@@ -1078,10 +1079,12 @@ static void go_record_decl(go_index_t *ix, go_file_t *file, char **f, int nf) {
go_add_sym(ix, file, GO_KIND_VALUE, f[SKIP_ONE], NULL);
go_add_claim(ix, file, GO_KIND_VALUE, f[SKIP_ONE], NULL, f[V_OWNER]);
} else if (kind == 'M' && nf == M_FIELDS && go_name_ok(f[PAIR_LEN])) {
+ /* #1913: a method is found through its receiver only; it claims no
+ * flat . (its record says w `-`), so a type, function or
+ * value of the same name keeps that node. */
if (strcmp(f[SKIP_ONE], "?") != 0) {
go_add_member(ix, file, GO_MEM_METHOD, f[SKIP_ONE], f[PAIR_LEN], true);
}
- go_add_claim(ix, file, GO_KIND_METHOD, f[PAIR_LEN], f[SKIP_ONE], f[M_OWNER]);
} else if (kind == 'f' && nf == FIELD_FIELDS && go_name_ok(f[PAIR_LEN])) {
go_add_member(ix, file, GO_MEM_FIELD, f[SKIP_ONE], f[PAIR_LEN], f[FIELD_NODE][0] == '1');
} else if (kind == 'm' && nf == IM_FIELDS && go_name_ok(f[PAIR_LEN])) {
@@ -1342,9 +1345,9 @@ static const cbm_gbuf_node_t *go_member_node(const go_ctx_t *c, const go_pkg_t *
return (fm && (fm->node_kinds & kind)) ? n : NULL;
}
-/* The node of the method `recv.name`: its receiver-qualified node, else the
- * flat node . when the method is the one that kept it (the last
- * declaration of that name in the node's file). */
+/* The node of the method `recv.name`: its receiver-qualified node. The flat
+ * fallback below (the method that kept .) is unreachable since
+ * #1913: no method record claims a flat name any more. */
static const cbm_gbuf_node_t *go_method_node(const go_ctx_t *c, const go_pkg_t *pkg, go_str_t recv,
go_str_t name) {
const cbm_gbuf_node_t *qualified = go_member_node(c, pkg, GO_MEM_METHOD, recv, name);
diff --git a/src/pipeline/doc_links_pdf.c b/src/pipeline/doc_links_pdf.c
index a667fb6c7..1eaf057ae 100644
--- a/src/pipeline/doc_links_pdf.c
+++ b/src/pipeline/doc_links_pdf.c
@@ -328,9 +328,10 @@ static void qn_candidate(const pdfr_index_t *x, const cbm_gbuf_node_t *n, const
const char *rel = rel_qn(x, n->qualified_name);
char alt[PDFR_KEY_CAP];
bool owned = owner_name(x, n, alt, sizeof(alt));
- /* A member whose QN leaves its owner out (a Go method's has no receiver)
- * is named through the owner: `time.Now` is package time's function,
- * never the method Now of a type that package time declares. */
+ /* A member whose QN leaves its owner out (a templ method's keeps the flat
+ * package QN; a Go method's did before index format 4) is named through
+ * the owner: `time.Now` is package time's function, never the method Now
+ * of a type that package time declares. */
bool ownerless_qn = owned && !comp_suffix(rel, alt);
bool via_rel = !ownerless_qn && comp_suffix(rel, s);
bool via_alt = !via_rel && owned && strcmp(alt, rel) != 0 && comp_suffix(alt, s);
diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c
index dfbb8f7fb..f06544ae5 100644
--- a/src/pipeline/pass_calls.c
+++ b/src/pipeline/pass_calls.c
@@ -1103,7 +1103,13 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
* 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 ||
@@ -1131,6 +1137,8 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM
res.strategy)) ||
cbm_suppress_weak_local_binding_call(suppress_weak_local_binding,
call->callee_is_locally_bound, res.strategy) ||
+ cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy,
+ res.confidence) ||
/* Import-binding suppression (#2127): an external `from m import f`
* binds `f` for the whole module, so a weak short-name match onto a
* project `X.f` outside m is fabricated. Python-only (the import
diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c
index 298b6b8d2..22531d167 100644
--- a/src/pipeline/pass_parallel.c
+++ b/src/pipeline/pass_parallel.c
@@ -3364,7 +3364,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
@@ -3401,7 +3409,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 ||
@@ -3419,6 +3429,8 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB
call->callee_name, res.strategy)) ||
cbm_suppress_weak_local_binding_call(suppress_weak_local_binding,
call->callee_is_locally_bound, res.strategy) ||
+ cbm_go_suppress_weak_method_match(lang == CBM_LANG_GO, call->is_method, res.strategy,
+ res.confidence) ||
/* Import-binding suppression (#2127) — see pass_calls.c; this gate
* MUST stay identical to the one there. */
(lang == CBM_LANG_PYTHON &&
diff --git a/src/pipeline/pass_semantic.c b/src/pipeline/pass_semantic.c
index 186b2dfdc..bc23e0d26 100644
--- a/src/pipeline/pass_semantic.c
+++ b/src/pipeline/pass_semantic.c
@@ -266,9 +266,9 @@ static int check_go_class_implements(cbm_pipeline_ctx_t *ctx, const cbm_gbuf_nod
}
/* Resolve the struct's methods two ways and use whichever finds them:
* (a) the struct's DEFINES_METHOD edges, matched by method NAME — the real
- * pipeline path, where Go receiver methods carry a flat QN (e.g.
- * "pkg.Area" rather than "pkg.Circle.Area"), so QN-string
- * reconstruction would miss; and
+ * pipeline path. Go method QNs are receiver-qualified since #1913
+ * ("pkg.Circle.Area"), so (b) matches them as well, but the edges
+ * stay the primary join; and
* (b) the reconstructed QN "." — used by tests and any
* extractor that does emit class-qualified method QNs without
* DEFINES_METHOD edges from the class. */
diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h
index 072674d8d..a04e0144a 100644
--- a/src/pipeline/pipeline.h
+++ b/src/pipeline/pipeline.h
@@ -412,6 +412,15 @@ bool cbm_weak_member_unique_name_exempt(bool is_python, bool receiver_is_self_at
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);
+
/* Import-binding counterpart (#2127). True when the callee's root identifier
* is bound by an import of this file whose module chain contradicts the
* resolved target (`from unittest.mock import patch; patch()` must not bind a
diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c
index 5dda868a4..d3d035e57 100644
--- a/src/pipeline/registry.c
+++ b/src/pipeline/registry.c
@@ -750,6 +750,35 @@ 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) {
+ /* 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). Built on the shared drop-list so "weak" means one thing
+ * 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;
+ }
+ if (strcmp(strategy, "unique_name") != 0) {
+ 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
+ * 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 confidence < CONF_UNIQUE_NAME;
+}
+
/* Import-binding counterpart of the parameter guard above (#2127). An import
* binds the identifier for the whole module; when that import is external it
* never materializes, and the chain falls through to project-wide short-name
diff --git a/src/store/store.h b/src/store/store.h
index 803df81d8..0f291daf9 100644
--- a/src/store/store.h
+++ b/src/store/store.h
@@ -32,8 +32,10 @@ typedef struct cbm_store_read_scope cbm_store_read_scope_t;
* Dockerfile. keeps its suffix in its QNs.
* 3 Image identity invents no registry: a reference without a host is
* __image__, registry "unknown" -- no longer
- * __image__docker.io/[library/]. */
-#define CBM_INDEX_FORMAT_VERSION 3
+ * __image__docker.io/[library/].
+ * 4 #1913: Go method QNs are receiver-qualified (pkg.Recv.method, generic
+ * receivers unwrapped); free functions stay pkg.func. */
+#define CBM_INDEX_FORMAT_VERSION 4
#define CBM_STORE_CANCELLED (-3)
#define CBM_STORE_SCAN_LIMIT (-4)
#define CBM_STORE_CALLBACK_ERR (-5)
diff --git a/tests/test_doc_links_pdf.c b/tests/test_doc_links_pdf.c
index 1a78b7254..9c7bf90ff 100644
--- a/tests/test_doc_links_pdf.c
+++ b/tests/test_doc_links_pdf.c
@@ -1087,8 +1087,9 @@ TEST(pdf_links_pipeline) {
ASSERT_NOT_NULL(strstr(props, "\"line\":1"));
/* qualified names (pdf_qn) passed a second held-out audit (97 of 99
* correct, Wilson 95 % low 0.929) once a member whose QN leaves its owner
- * out binds only through the owner. A Go method through its receiver: */
- dm_edge(db, "docs.design.page_1", "pkg.server.Serve", props, sizeof(props), &n);
+ * out binds only through the owner. A Go method through its receiver
+ * (receiver-qualified QN since index format 4): */
+ dm_edge(db, "docs.design.page_1", "pkg.server.Handler.Serve", props, sizeof(props), &n);
ASSERT_EQ(n, 1);
ASSERT_NOT_NULL(strstr(props, "\"syntax\":\"pdf_qn\""));
ASSERT_NOT_NULL(strstr(props, "\"tier\":\"unique\""));
@@ -1142,10 +1143,12 @@ TEST(pdf_links_pipeline) {
PASS();
}
-/* A held-out finding of the PDF audit: a Go method's QN has no receiver, so
+/* A held-out finding of the PDF audit: a Go method's QN had no receiver, so
* `time.Now` (the standard library's function, in a spec) matched the method
* Now of a type that the repository's own package time declares. A method is
- * named through its owner: `time.Provider.Now` still binds it. */
+ * named through its owner: `time.Provider.Now` still binds it. Since index
+ * format 4 the QN carries the receiver (pkg.time.Provider.Now), and both
+ * directions are checked against that node. */
TEST(pdf_qn_method_through_owner) {
cbm_doclink_test_set_ships(CBM_DOCLINK_PDF_QN, true);
char tmp[256];
@@ -1169,9 +1172,9 @@ TEST(pdf_qn_method_through_owner) {
ASSERT_EQ(dm_index(repo, db, NULL), 0);
char props[512];
int n;
- dm_edge(db, "docs.spec.page_1", "pkg.time.Now", props, sizeof(props), &n);
+ dm_edge(db, "docs.spec.page_1", "pkg.time.Provider.Now", props, sizeof(props), &n);
ASSERT_EQ(n, 0);
- dm_edge(db, "docs.spec.page_2", "pkg.time.Now", props, sizeof(props), &n);
+ dm_edge(db, "docs.spec.page_2", "pkg.time.Provider.Now", props, sizeof(props), &n);
ASSERT_EQ(n, 1);
ASSERT_NOT_NULL(strstr(props, "\"syntax\":\"pdf_qn\""));
th_cleanup(tmp);
diff --git a/tests/test_doc_mentions_go.c b/tests/test_doc_mentions_go.c
index 78f1934e0..0d4462f76 100644
--- a/tests/test_doc_mentions_go.c
+++ b/tests/test_doc_mentions_go.c
@@ -228,10 +228,10 @@ TEST(doc_mentions_go_sources) {
const char *raw;
const char *source;
} want[] = {
- {"RefF", "p.pkg.F"}, {"RefS", "p.pkg.S"}, {"RefField", "p.pkg.S.Field"},
- {"RefM", "p.pkg.M"}, /* Go method nodes have a flat name */
- {"RefI", "p.pkg.I"}, {"RefDo", "p.pkg.I.Do"}, {"RefC", "p.pkg.C"},
- {"RefK", "p.pkg.K"}, {"RefV", "p.pkg.V"}, {"RefA", "p.pkg.A"},
+ {"RefF", "p.pkg.F"}, {"RefS", "p.pkg.S"}, {"RefField", "p.pkg.S.Field"},
+ {"RefM", "p.pkg.S.M"}, /* #1913: Go method nodes are receiver-qualified */
+ {"RefI", "p.pkg.I"}, {"RefDo", "p.pkg.I.Do"}, {"RefC", "p.pkg.C"},
+ {"RefK", "p.pkg.K"}, {"RefV", "p.pkg.V"}, {"RefA", "p.pkg.A"},
};
for (size_t i = 0; i < GO_COUNT(want); i++) {
const CBMDocLink *t = dm_find_token(r, want[i].raw);
@@ -772,14 +772,15 @@ TEST(doc_mentions_go_scope_blob) {
"T\tAlias\ta\tal.Widget\t1",
"T\tSame\ta\tKind\t1",
"T\tList\ts\t-\t1",
- /* functions and methods; two methods named Run compete for one node
- * and the later one keeps it */
+ /* functions and methods; a method node is receiver-qualified
+ * (#1913), so a method claims no flat name and its w is `-`: the two
+ * methods named Run no longer compete */
"F\tNew\t1",
"F\tMap\t1",
- "M\tWidget\tRun\t0",
- "M\tKind\tRun\t1",
- "M\tList\tPush\t1",
- "M\tlower\tExported\t1",
+ "M\tWidget\tRun\t-",
+ "M\tKind\tRun\t-",
+ "M\tList\tPush\t-",
+ "M\tlower\tExported\t-",
/* own type parameters, by the declaration's first and last line */
"g\t36\t36\tT,U",
"g\t42\t42\tA,B",
@@ -814,15 +815,20 @@ TEST(doc_mentions_go_scope_blob) {
char *portable = cbm_doclink_portable_scope(s);
ASSERT_NOT_NULL(portable);
ASSERT_NULL(strstr(portable, "\ng\t"));
- ASSERT_TRUE(go_has_rec(portable, "M\tKind\tRun\t1"));
+ ASSERT_TRUE(go_has_rec(portable, "M\tKind\tRun\t-"));
ASSERT(strncmp(portable, "go1\n", 4) == 0);
cbm_free(CBM_MEM_CLASS_OTHER, portable);
cbm_free_result(r);
- /* moving declarations changes nothing unless a node changes its owner */
+ /* moving declarations changes nothing unless a node changes its owner.
+ * Methods own no flat name (#1913), so swapping two methods of one name
+ * changes nothing either; two package-level declarations of one name
+ * still hand . over when swapped */
const char *a = "package p\n\nfunc (a A) Run() {}\nfunc (b B) Run() {}\nfunc F() {}\n";
const char *moved = "package p\n\nfunc F() {}\n\n\nfunc (a A) Run() {}\nfunc (b B) Run() {}\n";
const char *swapped = "package p\n\nfunc (b B) Run() {}\nfunc (a A) Run() {}\nfunc F() {}\n";
+ const char *pair = "package p\n\nfunc Open() {}\ntype Open struct{}\n";
+ const char *pair_swapped = "package p\n\ntype Open struct{}\nfunc Open() {}\n";
CBMFileResult *ra = go_extract(a, "p/x.go");
CBMFileResult *rm = go_extract(moved, "p/x.go");
CBMFileResult *rs = go_extract(swapped, "p/x.go");
@@ -830,16 +836,25 @@ TEST(doc_mentions_go_scope_blob) {
ASSERT_NOT_NULL(rm);
ASSERT_NOT_NULL(rs);
ASSERT_STR_EQ(ra->doc_scope, rm->doc_scope);
- ASSERT(strcmp(ra->doc_scope, rs->doc_scope) != 0);
- ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tA\tRun\t1"));
- ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tB\tRun\t0"));
+ ASSERT_STR_EQ(ra->doc_scope, rs->doc_scope);
+ ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tA\tRun\t-"));
+ ASSERT_TRUE(go_has_rec(rs->doc_scope, "M\tB\tRun\t-"));
cbm_free_result(ra);
cbm_free_result(rm);
cbm_free_result(rs);
-
- /* the node . is one for a function, a type, a value and the
- * methods of that name: the last declaration keeps it, whatever its kind,
- * and two on one line contest it */
+ CBMFileResult *rp = go_extract(pair, "p/x.go");
+ CBMFileResult *rq = go_extract(pair_swapped, "p/x.go");
+ ASSERT_NOT_NULL(rp);
+ ASSERT_NOT_NULL(rq);
+ ASSERT(strcmp(rp->doc_scope, rq->doc_scope) != 0);
+ ASSERT_TRUE(go_has_rec(rp->doc_scope, "T\tOpen\ts\t-\t1"));
+ ASSERT_TRUE(go_has_rec(rq->doc_scope, "F\tOpen\t1"));
+ cbm_free_result(rp);
+ cbm_free_result(rq);
+
+ /* the node . is one for a function, a type and a value of
+ * that name: the last declaration keeps it, whatever its kind, and two on
+ * one line contest it. A method of that name claims nothing (#1913) */
CBMFileResult *k1 = go_extract("package p\n\nfunc Open() {}\nfunc (t T) Open() {}\n", "p/k.go");
CBMFileResult *k2 = go_extract("package p\n\nfunc (t T) Open() {}\nfunc Open() {}\n", "p/k.go");
CBMFileResult *k3 = go_extract("package p\n\ntype Open int\nvar Open = 1\n"
@@ -848,14 +863,21 @@ TEST(doc_mentions_go_scope_blob) {
ASSERT_NOT_NULL(k1);
ASSERT_NOT_NULL(k2);
ASSERT_NOT_NULL(k3);
- ASSERT_TRUE(go_has_rec(k1->doc_scope, "F\tOpen\t0"));
- ASSERT_TRUE(go_has_rec(k1->doc_scope, "M\tT\tOpen\t1"));
+ ASSERT_TRUE(go_has_rec(k1->doc_scope, "F\tOpen\t1"));
+ ASSERT_TRUE(go_has_rec(k1->doc_scope, "M\tT\tOpen\t-"));
ASSERT_TRUE(go_has_rec(k2->doc_scope, "F\tOpen\t1"));
- ASSERT_TRUE(go_has_rec(k2->doc_scope, "M\tT\tOpen\t0"));
+ ASSERT_TRUE(go_has_rec(k2->doc_scope, "M\tT\tOpen\t-"));
ASSERT_TRUE(go_has_rec(k3->doc_scope, "T\tOpen\to\t-\t0"));
ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\t0"));
- ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\tx"));
- ASSERT_TRUE(go_has_rec(k3->doc_scope, "M\tT\tOpen\tx"));
+ ASSERT_TRUE(go_has_rec(k3->doc_scope, "V\tOpen\t1"));
+ ASSERT_TRUE(go_has_rec(k3->doc_scope, "M\tT\tOpen\t-"));
+ /* two package-level declarations on one line contest the name */
+ CBMFileResult *k4 =
+ go_extract("package p\n\ntype Open int\nvar Open = 1; const Open = 2\n", "p/k.go");
+ ASSERT_NOT_NULL(k4);
+ ASSERT_TRUE(go_has_rec(k4->doc_scope, "T\tOpen\to\t-\t0"));
+ ASSERT_TRUE(go_has_rec(k4->doc_scope, "V\tOpen\tx"));
+ cbm_free_result(k4);
cbm_free_result(k1);
cbm_free_result(k2);
cbm_free_result(k3);
@@ -1030,7 +1052,7 @@ static const char GO_ALPHA_C[] = "package alpha\n"
"// Target is a target; see [A.Close] and [B.Close].\n"
"func Target() {}\n"
"\n"
- "// Open is a type that loses its node name to the method below.\n"
+ "// Open is a type; a method of B below has the same name.\n"
"type Open struct{}\n"
"\n"
"// Open opens: a method named like the type.\n"
@@ -1295,7 +1317,7 @@ TEST(doc_mentions_go_forms) {
static const go_want_edge_t edges[] = {
/* [Name], [Recv.Method], an interface's method, a constant, a variable */
{"alpha.Widget", "alpha.New", "unique"},
- {"alpha.Widget", "alpha.Run", "unique"},
+ {"alpha.Widget", "alpha.Widget.Run", "unique"},
{"alpha.Widget", "alpha.Runner.Run", "unique"},
{"alpha.Widget", "alpha.Max", "unique"},
{"alpha.Widget", "alpha.Default", "unique"},
@@ -1311,7 +1333,7 @@ TEST(doc_mentions_go_forms) {
{"alpha.New", "sub.deep.Deep", "exact"},
{"alpha.New", "nested.pkg.Far", "exact"},
/* [pkg.Recv.Name]: a method, a field, an interface method */
- {"alpha.New", "beta.Do", "exact"},
+ {"alpha.New", "beta.Thing.Do", "exact"},
{"alpha.New", "beta.Thing.Field", "exact"},
{"alpha.New", "beta.Doer.Act", "exact"},
/* an identifier with letters beyond ASCII */
@@ -1395,7 +1417,7 @@ TEST(doc_mentions_go_plain_text) {
ASSERT_EQ(go_bad_rows(db, rows, GO_COUNT(rows)), 0);
/* and none of them is an edge: Run's doc holds rows, plain text and one
* link, to the field Widget.Size */
- ASSERT_EQ(dm_mentions_from(db, "alpha.Run"), 1);
+ ASSERT_EQ(dm_mentions_from(db, "alpha.Widget.Run"), 1);
ASSERT_EQ(dm_mentions_from(db, "alpha.Bare"), 0);
static const go_want_edge_t edges[] = {
{"alpha.Sibling", "alpha.Widget", "unique"},
@@ -1424,14 +1446,14 @@ TEST(doc_mentions_go_struct_fields) {
static const go_want_edge_t edges[] = {
/* the same package: a field named from a method's doc and from a
* sibling field's doc */
- {"alpha.Run", "alpha.Widget.Size", "exact"},
+ {"alpha.Widget.Run", "alpha.Widget.Size", "exact"},
{"alpha.Widget.Name", "alpha.Widget.Size", "exact"},
/* another package, by its import */
{"alpha.New", "beta.Thing.Field", "exact"},
{"gamma.ViaAlias", "alpha.Widget.Name", "exact"},
/* Depth is a field of beta.Embedded, which Widget embeds: it is no
* field of Widget */
- {"alpha.Run", "beta.Embedded.Depth", ""},
+ {"alpha.Widget.Run", "beta.Embedded.Depth", ""},
};
ASSERT_EQ(go_bad_edges(db, edges, GO_COUNT(edges)), 0);
/* the promoted field exists as a node: the test above is not vacuous */
@@ -1545,7 +1567,7 @@ TEST(doc_mentions_go_lookup_order) {
* file that imports "fmt", the repository's own in a file that imports
* example.com/demo/fmt */
ASSERT_STR_EQ(go_reason(db, "alpha/a.go", "fmt.Println"), "external");
- ASSERT_STR_EQ(go_tier(db, "alpha.Run", "fmt.Println"), "");
+ ASSERT_STR_EQ(go_tier(db, "alpha.Widget.Run", "fmt.Println"), "");
ASSERT_STR_EQ(go_tier(db, "gamma.Local", "fmt.Println"), "exact");
/* 5. the longest module path decides: a directory inside the root module
* that belongs to a nested module of another path is not reachable through
@@ -1555,17 +1577,17 @@ TEST(doc_mentions_go_lookup_order) {
ASSERT_STR_EQ(go_tier(db, "alpha.New", "sub.deep.Deep"), "exact");
/* 6. the bare name stops at the package: beta.Make is imported and still
* no [Make] */
- ASSERT_STR_EQ(go_tier(db, "alpha.Run", "beta.Make"), "");
+ ASSERT_STR_EQ(go_tier(db, "alpha.Widget.Run", "beta.Make"), "");
/* 7. members stop at the type: nothing promoted through embedding */
ASSERT_STR_EQ(go_reason(db, "gamma/k.go", "alpha.Widget.Promoted"), "missing");
- ASSERT_STR_EQ(go_tier(db, "gamma.ViaAlias", "beta.Promoted"), "");
+ ASSERT_STR_EQ(go_tier(db, "gamma.ViaAlias", "beta.Embedded.Promoted"), "");
/* 8. an alias is followed for a member written with its package, to the
* type that declares the member */
static const go_want_edge_t alias[] = {
- {"gamma.ViaAlias", "beta.Do", "exact"},
+ {"gamma.ViaAlias", "beta.Thing.Do", "exact"},
{"gamma.ViaAlias", "beta.Thing.Field", "exact"},
{"gamma.ViaAlias", "alpha.Alias", "exact"},
- {"gamma.ViaAlias", "alpha.End", "exact"}, /* seven aliases away */
+ {"gamma.ViaAlias", "alpha.Deep10.End", "exact"}, /* seven aliases away */
{"gamma.ViaAlias", "alpha.Widget.Name", "exact"},
};
ASSERT_EQ(go_bad_edges(db, alias, GO_COUNT(alias)), 0);
@@ -1638,28 +1660,42 @@ TEST(doc_mentions_go_node_owner) {
char tmp[256];
char db[512];
ASSERT_EQ(go_index_lookup(tmp, sizeof(tmp), db, sizeof(db)), 0);
- /* Go method nodes have a flat name: A.Close and B.Close share
- * alpha.Close, and the later declaration keeps it */
- ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE qualified_name LIKE '%.alpha.Close'"),
+ /* #1913: Go method nodes are receiver-qualified. A.Close and B.Close are
+ * two nodes (no shared alpha.Close), each a target of its own */
+ ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Method' AND "
+ "qualified_name LIKE '%.alpha.A.Close'"),
1);
- ASSERT_STR_EQ(go_tier(db, "alpha.Target", "alpha.Close"), "unique");
- ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "A.Close"), "graph_gap");
+ ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label = 'Method' AND "
+ "qualified_name LIKE '%.alpha.B.Close'"),
+ 1);
+ ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE qualified_name LIKE '%.alpha.Close'"),
+ 0);
+ ASSERT_STR_EQ(go_tier(db, "alpha.Target", "alpha.A.Close"), "unique");
+ ASSERT_STR_EQ(go_tier(db, "alpha.Target", "alpha.B.Close"), "unique");
+ ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "A.Close"), "");
ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "B.Close"), "");
- /* the doc of the method that lost the name is not attributed to the one
- * that kept it: B.Close's [Target] is the edge, A.Close's a row */
+ /* each method's doc is attributed to its own node: both [Target]s are
+ * edges, from the line of that method's doc, and neither is a row */
char props[512];
int n = 0;
- dm_edge(db, "alpha.Close", "alpha.Target", props, sizeof(props), &n);
+ dm_edge(db, "alpha.A.Close", "alpha.Target", props, sizeof(props), &n);
+ ASSERT_EQ(n, 1);
+ ASSERT_NOT_NULL(strstr(props, "\"line\":9,\"count\":1"));
+ dm_edge(db, "alpha.B.Close", "alpha.Target", props, sizeof(props), &n);
ASSERT_EQ(n, 1);
ASSERT_NOT_NULL(strstr(props, "\"line\":12,\"count\":1"));
- ASSERT_EQ(dm_count(db, "SELECT line FROM doc_link_unresolved WHERE rel_path = 'alpha/c.go' "
- "AND raw = 'Target'"),
- 9);
- /* a type that lost its name to a method of that name is no target, the
- * method is */
- ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "Open"), "graph_gap");
+ ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM doc_link_unresolved WHERE rel_path = "
+ "'alpha/c.go' AND raw = 'Target'"),
+ 0);
+ /* a type and a method of the same name no longer share a node: [Open]
+ * is the type, [B.Open] the method */
+ ASSERT_STR_EQ(go_reason(db, "alpha/c.go", "Open"), "");
+ ASSERT_EQ(dm_count(db, "SELECT COUNT(*) FROM nodes WHERE label <> 'Method' AND "
+ "qualified_name LIKE '%.alpha.Open'"),
+ 1);
ASSERT_STR_EQ(go_tier(db, "alpha.UsesOpen", "alpha.Open"), "unique");
- ASSERT_EQ(dm_mentions_from(db, "alpha.UsesOpen"), 1);
+ ASSERT_STR_EQ(go_tier(db, "alpha.UsesOpen", "alpha.B.Open"), "unique");
+ ASSERT_EQ(dm_mentions_from(db, "alpha.UsesOpen"), 2);
/* build variants are one entity: whichever file holds the node */
static const go_want_edge_t variants[] = {
@@ -1836,15 +1872,16 @@ TEST(doc_mentions_go_incremental_equals_full) {
ASSERT_EQ(dm_index(repo, inc_db, NULL), 0);
/* the preconditions the steps below move away from */
static const go_want_edge_t before[] = {
- {"a.Use", "b.Target", "exact"}, {"a.Use", "b.Gone", "exact"},
- {"a.Use", "b.M", "exact"}, /* T.M, in the first file of the directory, keeps b.M */
- {"a.Use", "b.Name", "exact"}, /* W.Name, the later declaration, keeps b.Name */
- {"a.Use", "a.Local", "unique"}, {"a.doc.go.__file__", "a.Local", "unique"},
+ {"a.Use", "b.Target", "exact"}, {"a.Use", "b.Gone", "exact"},
+ {"a.Use", "b.T.M", "exact"}, /* #1913: every method has its own node */
+ {"a.Use", "b.W.Name", "exact"}, {"a.Use", "b.V.Name", "exact"},
+ {"b.U.M", "b.Target", "unique"}, /* U.M is a source of its own */
+ {"a.Use", "a.Local", "unique"}, {"a.doc.go.__file__", "a.Local", "unique"},
{"a.Other", "b.Target", ""}, /* other.go does not import b */
};
ASSERT_EQ(go_bad_edges(inc_db, before, GO_COUNT(before)), 0);
- ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), "graph_gap");
- ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), "graph_gap"); /* U.M has no node */
+ ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), "");
+ ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), "");
/* a doc-comment edit re-extracts the file alone */
go_w(repo, "a/a.go", GO_INC_A_DOC_EDIT);
@@ -1880,23 +1917,27 @@ TEST(doc_mentions_go_incremental_equals_full) {
0);
ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.Gone"), "missing");
- /* the method that kept a shared node name goes: the other file's method of
- * that name takes the node over, and its doc becomes a source */
+ /* a method goes: links to it break, and another receiver's method of
+ * the same name, in another file, is untouched */
go_w(repo, "b/b.go", GO_INC_B_NO_M);
ASSERT_EQ(
dm_step(repo, inc_db, full_db, "name keeper removed", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR),
0);
ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.T.M"), "missing");
- ASSERT_STR_EQ(go_tier(inc_db, "b.M", "b.Target"), "unique");
+ ASSERT_STR_EQ(go_tier(inc_db, "b.U.M", "b.Target"), "unique");
ASSERT_STR_EQ(go_reason(inc_db, "b/c.go", "Target"), "");
- /* declaration order decides which declaration keeps a shared name:
- * swapping two methods moves the node, for files with no edge into it too */
+ /* declaration order no longer decides anything: swapping two methods of
+ * the same name keeps both nodes and both links */
go_w(repo, "b/d.go", GO_INC_D_SWAPPED);
- ASSERT_EQ(dm_step(repo, inc_db, full_db, "methods swapped", CBM_INCREMENTAL_ROUTE_FORCED_FULL),
- 0);
- ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.W.Name"), "graph_gap");
+ /* and the scope blob does not change (no method w moves), so the step
+ * is an ordinary closure repair, no longer a forced full rebuild */
+ ASSERT_EQ(
+ dm_step(repo, inc_db, full_db, "methods swapped", CBM_INCREMENTAL_ROUTE_CLOSURE_REPAIR), 0);
+ ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.W.Name"), "");
ASSERT_STR_EQ(go_reason(inc_db, "a/a.go", "b.V.Name"), "");
+ ASSERT_STR_EQ(go_tier(inc_db, "a.Use", "b.W.Name"), "exact");
+ ASSERT_STR_EQ(go_tier(inc_db, "a.Use", "b.V.Name"), "exact");
/* renaming a target */
go_w(repo, "b/b.go", GO_INC_B_RENAME);
@@ -2006,10 +2047,20 @@ TEST(doc_mentions_go_scope_delta_rules) {
GO_DELTA_HEAD GO_DELTA_F GO_DELTA_G GO_DELTA_T GO_DELTA_M GO_DELTA_I GO_DELTA_C
"var (\n\tV = 2\n)\n",
CBM_DOCLINK_DELTA_GLOBAL, NULL},
- /* the declaration that keeps a shared node name changes */
- {"same-named declarations swapped", twins,
- "package p\n\nfunc (T) Open() {}\nfunc Open() {}\n", CBM_DOCLINK_DELTA_GLOBAL, NULL},
- {"one of two same-named declarations removed", twins, "package p\n\nfunc Open() {}\n",
+ /* a method and a function of one name no longer share a node
+ * (#1913): swapping them changes no keeper */
+ {"method and function swapped", twins, "package p\n\nfunc (T) Open() {}\nfunc Open() {}\n",
+ CBM_DOCLINK_DELTA_LOCAL, ""},
+ /* the declaration that keeps a shared node name changes: two
+ * package-level declarations of one name still share . */
+ {"same-named declarations swapped", "package p\n\nfunc Open() {}\ntype Open struct{}\n",
+ "package p\n\ntype Open struct{}\nfunc Open() {}\n", CBM_DOCLINK_DELTA_GLOBAL, NULL},
+ /* the method of a method/function pair removed: a member left, the
+ * function keeps its node either way */
+ {"method of a same-named pair removed", twins, "package p\n\nfunc Open() {}\n",
+ CBM_DOCLINK_DELTA_LOCAL, "Open"},
+ {"one of two same-named declarations removed",
+ "package p\n\nfunc Open() {}\ntype Open struct{}\n", "package p\n\nfunc Open() {}\n",
CBM_DOCLINK_DELTA_GLOBAL, NULL},
/* other files follow `type X = q.Y` through this file's imports */
{"import changed under a qualified alias", alias,
@@ -2091,7 +2142,10 @@ TEST(doc_mentions_go_workers_agree) {
ASSERT_STR_EQ(go_tier(par_db, "p00.T0", "p01.T0"), "exact");
ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.Gone"), "missing");
ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "fmt.Println"), "external");
- ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.T3.Close"), "graph_gap");
+ /* #1913: [p01.T3.Close] lands on its own method node now; the graph_gap
+ * reason stays covered by Shared, one name declared by every file */
+ ASSERT_STR_EQ(go_reason(par_db, "p00/f0.go", "p01.T3.Close"), "");
+ ASSERT_STR_EQ(go_tier(par_db, "p00.T0", "p01.T3.Close"), "exact");
ASSERT_STR_EQ(go_reason(par_db, "p00/f3.go", "Shared"), "graph_gap");
ASSERT_TRUE(dm_count(par_db, "SELECT COUNT(*) FROM edges WHERE type = 'MENTIONS'") > 100);
dm_unlink_db(par_db);
diff --git a/tests/test_extraction.c b/tests/test_extraction.c
index 8c37e8c5d..4a8392205 100644
--- a/tests/test_extraction.c
+++ b/tests/test_extraction.c
@@ -6565,12 +6565,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);
@@ -7235,12 +7235,17 @@ TEST(extract_rust_generic_impl_call_scope_matches_def) {
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) {
- 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++) {
@@ -7398,6 +7403,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.
*
@@ -7503,6 +7535,91 @@ TEST(extract_python_bare_call_flag_is_depth_independent) {
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();
+}
+
+/* Templ shares Go's method grammar (method_declaration with a receiver), but
+ * only Go method QNs are receiver-qualified (#1913); templ's call-scope QN
+ * stays flat. The method's def QN and the enclosing-function QN of a call
+ * inside it must agree, or the call has no source node and degrades to the
+ * File node. */
+TEST(extract_templ_method_qn_matches_call_scope) {
+ CBMFileResult *r = extract("package views\n"
+ "\n"
+ "type Page struct{}\n"
+ "\n"
+ "func (p Page) Title() string {\n"
+ "\treturn helper()\n"
+ "}\n"
+ "\n"
+ "func helper() string { return \"x\" }\n"
+ "\n"
+ "templ hello(name string) {\n"
+ "\t{ name }
\n"
+ "}\n",
+ CBM_LANG_TEMPL, "t", "x.templ");
+ ASSERT_NOT_NULL(r);
+ const char *title_qn = NULL;
+ for (int i = 0; i < r->defs.count; i++) {
+ const CBMDefinition *d = &r->defs.items[i];
+ if (d->name && d->qualified_name && strcmp(d->name, "Title") == 0) {
+ title_qn = d->qualified_name;
+ }
+ }
+ const char *scope = NULL;
+ for (int i = 0; i < r->calls.count; i++) {
+ const CBMCall *c = &r->calls.items[i];
+ if (c->callee_name && strcmp(c->callee_name, "helper") == 0) {
+ scope = c->enclosing_func_qn;
+ }
+ }
+ ASSERT_NOT_NULL(title_qn);
+ ASSERT_NOT_NULL(scope);
+ ASSERT_STR_EQ(scope, title_qn);
+ 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
@@ -12574,6 +12691,9 @@ 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_go_method_receiver_qualified_qn);
+ RUN_TEST(extract_templ_method_qn_matches_call_scope);
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 fc97332e6..f5952baf2 100644
--- a/tests/test_parallel.c
+++ b/tests/test_parallel.c
@@ -4080,8 +4080,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 &&
strstr(edge->properties_json, "\"strategy\":\"lsp_type_dispatch\"");
diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c
index 9a756f688..4ebf40693 100644
--- a/tests/test_pipeline.c
+++ b/tests/test_pipeline.c
@@ -8761,6 +8761,283 @@ TEST(pipeline_html_embedded_member_call_stays_unbound) {
PASS();
}
+/* CALLS edges whose source and target QNs end with the given suffixes and
+ * whose serialized properties contain `props_fragment` (NULL: any edge). QN
+ * suffixes pin the exact node, not just a same-named one. */
+static int go_calls_between(cbm_store_t *s, const char *project, const char *caller_suffix,
+ const char *callee_suffix, const char *props_fragment) {
+ cbm_edge_t *edges = NULL;
+ int edge_count = 0;
+ cbm_store_find_edges_by_type(s, project, "CALLS", &edges, &edge_count);
+ size_t cs = strlen(caller_suffix);
+ size_t ts = strlen(callee_suffix);
+ int hits = 0;
+ for (int i = 0; i < edge_count; i++) {
+ cbm_node_t src = {0};
+ cbm_node_t tgt = {0};
+ if (cbm_store_find_node_by_id(s, edges[i].source_id, &src) == CBM_STORE_OK &&
+ cbm_store_find_node_by_id(s, edges[i].target_id, &tgt) == CBM_STORE_OK &&
+ src.qualified_name && tgt.qualified_name) {
+ size_t sl = strlen(src.qualified_name);
+ size_t tl = strlen(tgt.qualified_name);
+ if (sl >= cs && tl >= ts && strcmp(src.qualified_name + sl - cs, caller_suffix) == 0 &&
+ strcmp(tgt.qualified_name + tl - ts, callee_suffix) == 0 &&
+ (!props_fragment ||
+ (edges[i].properties_json && strstr(edges[i].properties_json, props_fragment)))) {
+ hits++;
+ }
+ }
+ cbm_node_free_fields(&src);
+ cbm_node_free_fields(&tgt);
+ }
+ if (edges) {
+ cbm_store_free_edges(edges, edge_count);
+ }
+ return hits;
+}
+
+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"));
+ /* Pinned to the receiver-qualified target in svc/helper.go and to an LSP
+ * strategy: a name-only binding, or one onto other.Decoy.helper, would
+ * still satisfy the by-name check above. */
+ ASSERT_GTE(go_calls_between(s, project, ".svc.Repo.Install", ".svc.Repo.helper",
+ "\"strategy\":\"lsp_"),
+ 1);
+ ASSERT_EQ(go_calls_between(s, project, ".svc.Repo.Install", ".other.Decoy.helper", NULL), 0);
+ cbm_store_close(s);
+ cbm_pipeline_free(p);
+ th_rmtree(tmp);
+ PASS();
+}
+
+/* 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");
+ /* 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");
+ /* 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];
+ 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);
+ 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"));
+ /* (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);
+ th_rmtree(tmp);
+ 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"));
+ /* 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);
+ 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
@@ -24830,6 +25107,9 @@ SUITE(pipeline) {
RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge);
RUN_TEST(pipeline_python_receiver_keeps_specific_unique_name_member_call);
RUN_TEST(pipeline_html_embedded_member_call_stays_unbound);
+ RUN_TEST(pipeline_go_method_caller_keeps_lsp_join);
+ 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_call_never_binds_another_language);
diff --git a/tests/test_registry.c b/tests/test_registry.c
index 0f3e805f2..391fd3b88 100644
--- a/tests/test_registry.c
+++ b/tests/test_registry.c
@@ -1673,10 +1673,61 @@ 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. 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;
+ 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();
}
+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));
+ /* 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();
+}
+
+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));
+ 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
@@ -2235,6 +2286,8 @@ SUITE(registry) {
RUN_TEST(python_import_binding_contradicts_only_foreign_chains);
RUN_TEST(python_external_base_contradicts_django_shapes);
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);
RUN_TEST(registry_python_function_local_is_exact_only_issue1548);
RUN_TEST(registry_resolve_lexical_walks_enclosing_defs_issue1548);
RUN_TEST(registry_python_local_bound_at_own_file_issue1548);