From 4a78fe6a6b5135a4bcf1a4a28d890adf86908405 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Sat, 31 Oct 2026 14:16:05 +0100 Subject: [PATCH] fix(go): a bare init never binds to a package init or an init method Go gives `init` no binding in the package block: init functions cannot be referred to from anywhere in a program, and a method or field named init needs a selector. A bare `init` is therefore always a local name - a closure (`init := func(ctx) {...}; init(ctx)`), a parameter, or the `package init` clause of a directory named init. Before #1915 same_module bound such a name to the flat package init. With per-file init QNs it now falls through to the short-name fallback and binds unrelated methods named init. On kubernetes that minted 24 CALLS (22 e2e storage DefineTests closures among them), 28 WRITES, 7 USAGE and one derived TESTS edge, all false. cbm_go_suppress_bare_init_bind refuses, for a bare Go `init`, any registry binding onto a Function (every Go Function named init is a package init) and a weak short-name binding onto a Method or Field. lsp_* answers and selector calls (`w.init()`) are untouched. Wired at the CALLS, USAGE and READS/WRITES seams of both resolvers. Kubernetes, full index, main vs branch: only those 60 edges removed, nodes identical, no resolver edge added; the 14 LSP-typed selector CALLS into init methods are unchanged. Tests: pipeline_go_bare_init_binds_nothing (+ parallel twin) are RED with the predicate disabled (closure CALLS, parameter CALLS, closure WRITES, package-clause USAGE onto Watcher.init) and GREEN with it; the selector control keeps Watcher.Start/Boot -> Watcher.init. go_bare_init_never_binds_init_namesake pins the predicate. Refs #1915 Signed-off-by: Martin Vogel --- src/pipeline/pass_calls.c | 8 ++ src/pipeline/pass_parallel.c | 19 ++++ src/pipeline/pass_usages.c | 13 +++ src/pipeline/pipeline.h | 10 ++ src/pipeline/registry.c | 40 +++++++ tests/test_pipeline.c | 197 +++++++++++++++++++++++++++++++++++ tests/test_registry.c | 40 +++++++ 7 files changed, 327 insertions(+) diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 1df1fe155..3deca9622 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -1344,6 +1344,14 @@ static int resolve_single_call(cbm_pipeline_ctx_t *ctx, CBMCall *call, const CBM if (cbm_java_suppress_call_to_data_member(lang == CBM_LANG_JAVA, target_node->label)) { return 0; } + /* A bare Go `init(...)` calls a local closure or parameter: never a + * package init (unreferable in Go) and never a same-named method found + * by name alone — see cbm_go_suppress_bare_init_bind. MUST match + * pass_parallel.c exactly. */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name, + res.strategy, target_node->label)) { + return 0; + } evidence->candidate_qn = target_node->qualified_name; evidence->calls_emitted |= emit_classified_edge(ctx, call, source_node, target_node, &res, module_qn, imp_keys, diff --git a/src/pipeline/pass_parallel.c b/src/pipeline/pass_parallel.c index 4dbfae9e9..2c264b07d 100644 --- a/src/pipeline/pass_parallel.c +++ b/src/pipeline/pass_parallel.c @@ -3629,6 +3629,13 @@ static void resolve_file_calls(resolve_ctx_t *rc, resolve_worker_state_t *ws, CB * Variable or a Field. */ continue; } + if (target_node && source_node->id != target_node->id && + cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, call->is_method, call->callee_name, + res.strategy, target_node->label)) { + /* Same guard as pass_calls.c — a bare Go `init` is a local name, + * never a package init or a method found by name alone. */ + continue; + } if (!target_node || source_node->id == target_node->id) { /* HTTP/ASYNC calls to an EXTERNAL client library (`requests.get(url)`) * resolve to an unindexed QN (target_node == NULL), but their edge @@ -3805,6 +3812,12 @@ static void resolve_file_usages(resolve_ctx_t *rc, resolve_worker_state_t *ws, if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind). + * Mirrors the sequential twin (pass_usages.c). */ + if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access, + usage->ref_name, res.strategy, tgt->label)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -3933,6 +3946,12 @@ static void resolve_file_rw(resolve_ctx_t *rc, resolve_worker_state_t *ws, CBMFi if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local (cbm_go_suppress_bare_init_bind). + * Mirrors the sequential twin (pass_usages.c). */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name, + res.strategy, tgt->label)) { + continue; + } const char *etype = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ws->local_edge_buf, src->id, tgt->id, etype, "{}"); } diff --git a/src/pipeline/pass_usages.c b/src/pipeline/pass_usages.c index b51573deb..e29290c74 100644 --- a/src/pipeline/pass_usages.c +++ b/src/pipeline/pass_usages.c @@ -288,6 +288,12 @@ static int resolve_usage_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *res if (tgt && cbm_suppress_bare_field_ref(lang, usage->is_member_access, tgt->label)) { continue; } + /* A bare Go `init` names a local, never a package init or an init + * method (cbm_go_suppress_bare_init_bind). Mirrors pass_parallel.c. */ + if (tgt && cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, usage->is_member_access, + usage->ref_name, res.strategy, tgt->label)) { + continue; + } if (usage->semantic_reference_blocked && (usage->semantic_reference_local_shadow || cbm_pipeline_node_is_callable_target(tgt))) { continue; @@ -420,6 +426,13 @@ static int resolve_rw_edges(cbm_pipeline_ctx_t *ctx, const CBMFileResult *result if (cbm_suppress_bare_field_ref(lang, rw->is_member_access, tgt->label)) { continue; } + /* `init := func(...)`: a bare Go `init` names a local, never a package + * init or an init method (cbm_go_suppress_bare_init_bind). Mirrors + * pass_parallel.c. */ + if (cbm_go_suppress_bare_init_bind(lang == CBM_LANG_GO, rw->is_member_access, rw->var_name, + res.strategy, tgt->label)) { + continue; + } const char *edge_type = rw->is_write ? "WRITES" : "READS"; cbm_gbuf_insert_edge(ctx->gbuf, src->id, tgt->id, edge_type, "{}"); diff --git a/src/pipeline/pipeline.h b/src/pipeline/pipeline.h index 34261302d..9e90b049c 100644 --- a/src/pipeline/pipeline.h +++ b/src/pipeline/pipeline.h @@ -459,6 +459,16 @@ bool cbm_suppress_external_import_shadow(const char *callee_name, const char *st bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *strategy, double confidence); +/* A bare Go `init` (a call or reference that is not the member half of a + * selector) is always a local name: Go forbids referring to a package init + * function, and a method or field needs a selector. True when such a name + * resolved onto a Function (every Go Function named init is a package init; + * any registry strategy) or onto a Method or Field found by a weak short-name + * strategy. lsp_* answers are kept. Go only. Pure; unit-tested in + * test_registry.c; wired in pass_calls.c, pass_usages.c and pass_parallel.c. */ +bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name, + const char *strategy, const char *target_label); + /* 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 ee1eeb943..997d00194 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -1015,6 +1015,46 @@ bool cbm_go_suppress_weak_method_match(bool is_go, bool is_method, const char *s return confidence < CONF_UNIQUE_NAME; } +bool cbm_go_suppress_bare_init_bind(bool is_go, bool is_member, const char *name, + const char *strategy, const char *target_label) { + /* The Go spec gives `init` no binding in the package block: the + * identifier declares init functions and nothing else, and "init + * functions cannot be referred to from anywhere in a program". A method + * or a field named init is reached only through a selector (`x.init()`, + * `e.init`), which arrives here as is_member and is left alone. So a + * BARE `init` is always a local name: a closure (`init := + * func(ctx) {...}; init(ctx)`, 22 kubernetes e2e storage suites), a + * parameter (`func sum(init func() ref.Val)`), or the `package init` + * clause of a directory named init. None of them is a project node. + * + * Every Go Function named init is a package init (no other package-level + * `init` can be declared), so that target is refused for every registry + * strategy. Before #1915 the flat QN made same_module bind the bare name + * to its own package's init; after it, the per-file QN misses there and + * the name fallback binds unrelated init methods instead. A Method or + * Field namesake is refused when only the name found it (the shared weak + * drop-list), which is how every such edge was minted. lsp_* answers are + * untouched: the sequential resolver emits them before its registry + * guards run, so refusing one here would split the two resolvers. + * + * Go-gated: elsewhere `init` is an ordinary name. A bare `init()` in a + * Java, C#, C++, Kotlin or Ruby method calls the class's own method, and + * a C, Python or JS `init` function is called by its bare name. */ + if (!is_go || is_member || !name || strcmp(name, "init") != 0 || !target_label) { + return false; + } + if (strategy && strncmp(strategy, "lsp_", strlen("lsp_")) == 0) { + return false; + } + if (strcmp(target_label, "Function") == 0) { + return true; + } + if (strcmp(target_label, "Method") == 0 || strcmp(target_label, "Field") == 0) { + return weak_short_name_strategy(strategy); + } + return false; +} + /* 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/tests/test_pipeline.c b/tests/test_pipeline.c index 303af90f7..20a9bf0dc 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -11071,6 +11071,201 @@ TEST(pipeline_go_init_lsp_edges_per_file_parallel) { PASS(); } +/* Fixture for the bare-`init` probes (sequential and parallel twins), the + * shapes the kubernetes census found after #1915: a local closure `init := + * func(ctx)` that is called, written and passed by value (the e2e storage + * suites), a parameter named init (`func sum(init func() ref.Val)`), and a + * `package init` directory. The decoys are a package init in the caller's + * own package (what same_module bound before #1915) and an unrelated + * Watcher.init method (what the name fallback binds after it). Watcher.Start + * and Boot call that method through a selector, the control that must keep + * resolving. pad_files > 0 adds filler Go files past MIN_FILES_FOR_PARALLEL. */ +static void write_go_bare_init_fixture(const char *tmp, int pad_files) { + write_temp_file(tmp, "go.mod", "module example.com/fxbareinit\n\ngo 1.22\n"); + write_temp_file(tmp, "suites/suite.go", + "package suites\n" + "\n" + "import \"context\"\n" + "\n" + "var registered []string\n" + "\n" + "func init() { registered = append(registered, \"volumes\") }\n" + "\n" + "func wrap(fn func(context.Context)) { _ = fn }\n" + "\n" + "func DefineTests(ctx context.Context) {\n" + "\tinit := func(ctx context.Context) {\n" + "\t\t_ = ctx\n" + "\t}\n" + "\tinit(ctx)\n" + "\twrap(init)\n" + "}\n" + "\n" + "func Sum(init func() int) int {\n" + "\treturn init()\n" + "}\n"); + write_temp_file(tmp, "watcher/watcher.go", + "package watcher\n" + "\n" + "type Watcher struct{ ready bool }\n" + "\n" + "func NewWatcher() *Watcher { return &Watcher{} }\n" + "\n" + "func (w *Watcher) init() { w.ready = true }\n" + "\n" + "func (w *Watcher) Start() {\n" + "\tw.init()\n" + "}\n" + "\n" + "func Boot() {\n" + "\tw := NewWatcher()\n" + "\tw.init()\n" + "}\n"); + write_temp_file(tmp, "boot/init/init.go", + "// Package init installs defaults for its importers.\n" + "package init\n" + "\n" + "func init() { setup() }\n" + "\n" + "func setup() {}\n"); + for (int i = 0; i < pad_files; i++) { + char name[64]; + char body[128]; + snprintf(name, sizeof(name), "pad/filler%d.go", i); + snprintf(body, sizeof(body), "package pad\n\nfunc filler%d() int { return %d }\n", i, i); + write_temp_file(tmp, name, body); + } +} + +static bool bare_init_qn_ends_with(const char *qn, const char *suffix) { + size_t ql = strlen(qn); + size_t sl = strlen(suffix); + return ql >= sl && strcmp(qn + ql - sl, suffix) == 0; +} + +/* Resolver edges into any node named `init` whose source is not one of the + * two selector controls. Every such edge in the fixture binds a bare `init`, + * which in Go is always a local name. */ +static int go_bare_init_false_edges(cbm_store_t *s, const char *project) { + static const char *const types[] = {"CALLS", "USAGE", "WRITES", "READS", "CALL_REFERENCE"}; + int hits = 0; + for (size_t t = 0; t < sizeof(types) / sizeof(types[0]); t++) { + cbm_edge_t *edges = NULL; + int edge_count = 0; + cbm_store_find_edges_by_type(s, project, types[t], &edges, &edge_count); + 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.name && strcmp(tgt.name, "init") == 0 && + !bare_init_qn_ends_with(src.qualified_name, ".watcher.Watcher.Start") && + !bare_init_qn_ends_with(src.qualified_name, ".watcher.Boot")) { + printf(" bare init bound: %s %s -> %s\n", types[t], src.qualified_name, + tgt.qualified_name ? tgt.qualified_name : "?"); + hits++; + } + cbm_node_free_fields(&src); + cbm_node_free_fields(&tgt); + } + if (edges) { + cbm_store_free_edges(edges, edge_count); + } + } + return hits; +} + +static int assert_go_bare_init_edges(cbm_store_t *s, const char *project) { + /* The decoys exist, so a zero below is a refused binding and not an + * absent target: two package inits and the Watcher method. */ + cbm_node_t *inits = NULL; + int ic = 0; + cbm_store_find_nodes_by_name(s, project, "init", &inits, &ic); + int functions = 0; + int methods = 0; + for (int i = 0; i < ic; i++) { + functions += inits[i].label && strcmp(inits[i].label, "Function") == 0; + methods += inits[i].label && strcmp(inits[i].label, "Method") == 0; + } + cbm_store_free_nodes(inits, ic); + ASSERT_EQ(functions, 2); + ASSERT_EQ(methods, 1); + /* NEGATIVE: no bare `init` (closure call, closure write, closure passed + * by value, parameter call, `package init`) binds a package init or the + * unrelated method. */ + ASSERT_EQ(go_bare_init_false_edges(s, project), 0); + /* CONTROL: a selector call `w.init()` is not bare and still resolves to + * the method, from a method body and from a free function. */ + ASSERT_GTE( + go_calls_between(s, project, ".watcher.Watcher.Start", ".watcher.Watcher.init", NULL), 1); + ASSERT_GTE(go_calls_between(s, project, ".watcher.Boot", ".watcher.Watcher.init", NULL), 1); + return 0; +} + +/* R45 (follow-up to #1915): a bare Go `init` never binds a package init or, + * by name alone, an init method (cbm_go_suppress_bare_init_bind). */ +TEST(pipeline_go_bare_init_binds_nothing) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_bare_init_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_bare_init_fixture(tmp, 0); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_bare_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); + ASSERT_EQ(assert_go_bare_init_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + +/* Parallel twin: >= MIN_FILES_FOR_PARALLEL (50) files plus CBM_WORKERS=4 + * take the parallel resolver, which carries its own copy of each guard. */ +TEST(pipeline_go_bare_init_binds_nothing_parallel) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_bare_init_par_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + write_go_bare_init_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_bare_init_par.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + ASSERT_EQ(assert_go_bare_init_edges(s, project), 0); + + cbm_store_close(s); + cbm_pipeline_free(p); + if (saved) { + cbm_setenv("CBM_WORKERS", saved, 1); + free(saved); + } else { + cbm_unsetenv("CBM_WORKERS"); + } + th_rmtree(tmp); + PASS(); +} + /* Fixture for the #1928 cross-language reference-guard probes (sequential and * parallel twins). pad_files > 0 adds filler files to push the index over the * parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per @@ -29621,6 +29816,8 @@ SUITE(pipeline) { RUN_TEST(pipeline_go_multi_init_nodes_survive); RUN_TEST(pipeline_go_init_lsp_edges_per_file); RUN_TEST(pipeline_go_init_lsp_edges_per_file_parallel); + RUN_TEST(pipeline_go_bare_init_binds_nothing); + RUN_TEST(pipeline_go_bare_init_binds_nothing_parallel); RUN_TEST(pipeline_go_crossfile_type_alias_method); RUN_TEST(pipeline_go_crossfile_type_alias_method_parallel); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); diff --git a/tests/test_registry.c b/tests/test_registry.c index 9e99376ba..b711e32ed 100644 --- a/tests/test_registry.c +++ b/tests/test_registry.c @@ -1260,6 +1260,45 @@ TEST(go_bare_ref_never_binds_field) { PASS(); } +TEST(go_bare_init_never_binds_init_namesake) { + /* Go cannot refer to a package init function, and a method or field + * named init needs a selector, so a bare `init` (a closure, a parameter, + * a `package init` clause) is always a local name. */ + /* Every Go Function named init is a package init: refused for every + * registry strategy, same_module included (the pre-#1915 binding). */ + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Function")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", NULL, "Function")); + /* A Method or Field namesake found by the name alone is refused. */ + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Method")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "unique_name", "Method")); + ASSERT_TRUE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Field")); + /* ... but not one that an import- or module-aware strategy produced. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "same_module", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "import_map", "Method")); + /* lsp_* answers are kept: the sequential resolver emits them before its + * registry guards run, so refusing one here would split the resolvers. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "lsp_direct", "Method")); + /* The member half of a selector (`w.init()`, `e.init`) is not bare. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, true, "init", "suffix_match", "Field")); + /* Other names and other target kinds are untouched. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "initAll", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "Init", "suffix_match", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Variable")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", "Module")); + /* Go only: elsewhere a bare `init()` is an ordinary call (a Java or C++ + * method body calls its own init, a C or Python init is a function). */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "suffix_match", "Method")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(false, false, "init", "same_module", "Function")); + /* Degenerate input → nothing to judge. */ + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, NULL, "suffix_match", "Function")); + ASSERT_FALSE(cbm_go_suppress_bare_init_bind(true, false, "init", "suffix_match", NULL)); + PASS(); +} + TEST(c_bare_ref_never_binds_field) { /* #2666: a C struct member is named only as the member half of `a.b`, * `a->b` or a designator `.b` (field_identifier → is_member_access). A @@ -2656,6 +2695,7 @@ SUITE(registry) { RUN_TEST(registry_tie_prefers_csharp_implementation_over_ref_stub); RUN_TEST(cross_language_ref_drops_go_vs_c); RUN_TEST(go_bare_ref_never_binds_field); + RUN_TEST(go_bare_init_never_binds_init_namesake); RUN_TEST(c_bare_ref_never_binds_field); RUN_TEST(call_onto_field_follows_language_and_shape); RUN_TEST(c_bare_call_does_not_weigh_field_namesakes);