From adff62e7f20dd439129d17c5dc028c7069af92f2 Mon Sep 17 00:00:00 2001 From: Ilya Brykau Date: Sat, 29 Aug 2026 18:35:32 +0200 Subject: [PATCH] fix(extract): give each Go init() its own QN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go allows any number of init() functions per package — even several in one file — and all of them run at start-up. On the flat QN (proj.pkg.init) they all collided and the graph upsert kept ONE node per package, silently dropping the rest: measured on a real Go repo, an event-source package registering each decoder in its own file's init() collapsed 21 init functions into one node (29 in source, 7 in the graph), erasing the whole registration pattern. Disambiguate with the #495 cfg-twin pattern: fold the file basename and line into the QN (proj.pkg.init#a.go:L5). Calling init explicitly is illegal in Go, so nothing ever joins on the plain QN; the call-scope side (compute_func_qn) mirrors the exact formula so init-body calls keep their source attribution instead of degrading to the calls_find_source File fallback. Reproduce-first: pipeline_go_multi_init_nodes_survive is RED without the def-side change (node count 1, expected 2) and GREEN with it; extract_go_multiple_init_disambiguated pins two same-file inits to distinct suffixed QNs. Fixes #1910 Signed-off-by: Ilya Brykau --- internal/cbm/extract_defs.c | 19 ++++++++++ internal/cbm/extract_unified.c | 16 +++++++++ internal/cbm/helpers.c | 36 +++++++++++++++++++ internal/cbm/helpers.h | 4 +++ tests/test_extraction.c | 63 ++++++++++++++++++++++++++++++++++ tests/test_pipeline.c | 50 +++++++++++++++++++++++++++ 6 files changed, 188 insertions(+) diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index b298ec50c..af49ef485 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3739,6 +3739,25 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec } } + /* Go allows any number of init() functions per package — even several in + * one file — and they all run at start-up. On the flat QN they all + * collided and the graph upsert kept ONE node per package, silently + * dropping the rest (#1910). Disambiguate with the #495 cfg-twin pattern: + * fold the file basename and the init's per-file ordinal into the QN. + * The ordinal (not the line) keeps the QN stable when code is edited + * above the function — the QN is node identity, and a line suffix would + * churn nodes on every unrelated insertion. Nothing ever joins on the + * plain QN — calling init explicitly is illegal in Go — and the + * call-scope side (compute_func_qn in extract_unified.c) computes the + * same cbm_go_init_ordinal so init-body calls keep their attribution. */ + if (ctx->language == CBM_LANG_GO && strcmp(def.label, "Function") == 0 && + strcmp(name, "init") == 0) { + const char *base = strrchr(ctx->rel_path, '/'); + base = base ? base + 1 : ctx->rel_path; + def.qualified_name = cbm_arena_sprintf(a, "%s#%s:%d", def.qualified_name, base, + cbm_go_init_ordinal(node, ctx->source)); + } + // C++/CUDA: out-of-line method definition (`Foo::bar` in a .cc/.cpp). The // class body in the header is declaration-only, so without this the // definition is recorded as a free Function. Promote it to a Method whose QN diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index 8502c37b8..1bcbd5753 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -918,6 +918,22 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan } } + /* Go init(): the def extractor folds the file basename and the init's + * per-file ordinal into the QN so every init in a package survives the + * upsert (#1910, the #495 cfg-twin pattern; ordinal, not line, so the QN + * is stable under edits above the function). Mirror the exact formula + * here, or init-body calls fall back to File-node attribution + * (calls_find_source). */ + if (ctx->language == CBM_LANG_GO && strcmp(name, "init") == 0 && + strcmp(ts_node_type(node), "function_declaration") == 0) { + const char *base_qn = cbm_fqn_compute_source_lang(ctx->arena, ctx->project, ctx->rel_path, + name, ctx->language); + const char *base = strrchr(ctx->rel_path, '/'); + base = base ? base + 1 : ctx->rel_path; + return cbm_arena_sprintf(ctx->arena, "%s#%s:%d", base_qn, base, + cbm_go_init_ordinal(node, ctx->source)); + } + /* Nix: a binding's own attrpath contributes scope (`a.b.fn = …`), and the def * extractor bakes it into the def QN. Compose it identically here — otherwise * an in-body call sources to a QN one or more segments short of the def, and diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index e8a6eb8ec..5ac97f610 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -1734,6 +1734,42 @@ int cbm_classify_string(const char *str, int len) { * canonical parameter shape of server-side route paths * (`/things/${id}/x` -> "/things/{}/x"). Returns NULL when the node yields * no text or exceeds the route-sized buffer. */ +/* 1-based source-order ordinal of a top-level Go `init` function_declaration + * among the file's `init`s. Both the definition extractor and compute_func_qn + * fold it into the QN (`pkg.init#file.go:N`, #1910), so it must be computed + * from the tree on both sides with this one formula. Unlike a line number, + * the ordinal is stable under edits above the function — QNs are node + * identity, and a line-based suffix would churn nodes on every unrelated + * insertion (the #495 cfg suffix and the GoogleTest derivation are both + * edit-stable for the same reason). */ +int cbm_go_init_ordinal(TSNode fn_node, const char *source) { + TSNode root = fn_node; + for (TSNode p = ts_node_parent(root); !ts_node_is_null(p); p = ts_node_parent(p)) { + root = p; + } + int ordinal = 0; + uint32_t n = ts_node_named_child_count(root); + for (uint32_t i = 0; i < n; i++) { + TSNode child = ts_node_named_child(root, i); + if (strcmp(ts_node_type(child), "function_declaration") != 0) { + continue; + } + TSNode name = ts_node_child_by_field_name(child, TS_FIELD("name")); + if (ts_node_is_null(name)) { + continue; + } + uint32_t start = ts_node_start_byte(name); + if (ts_node_end_byte(name) - start != 4 || memcmp(source + start, "init", 4) != 0) { + continue; + } + ordinal++; + if (ts_node_eq(child, fn_node)) { + return ordinal; + } + } + return ordinal; +} + const char *cbm_template_string_text(CBMArena *a, TSNode node, const char *source) { enum { TPL_BUF = 512 }; char buf[TPL_BUF]; diff --git a/internal/cbm/helpers.h b/internal/cbm/helpers.h index 29a858328..e2f148ecf 100644 --- a/internal/cbm/helpers.h +++ b/internal/cbm/helpers.h @@ -197,6 +197,10 @@ bool cbm_is_loop_node_type(const char *kind); // Is this a module-level node? (not nested inside function/class body) bool cbm_is_module_level(TSNode node, CBMLanguage lang); +/* 1-based source-order ordinal of a Go `init` among its file's top-level + * `init` function_declarations (QN disambiguation, #1910). */ +int cbm_go_init_ordinal(TSNode fn_node, const char *source); + // Same check, but the node's PARENT is supplied directly — avoids the // O(n) ts_node_parent rescan. Use at call sites iterating a known // parent's children (the common case). `parent` is the parent of the diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 8f5fbf2d2..edecba47d 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -4899,6 +4899,68 @@ TEST(extract_go_method_receiver_qualified_qn) { PASS(); } +TEST(extract_go_multiple_init_disambiguated) { + /* Go allows several init() per package (even per file); each gets a + * file+ordinal-suffixed QN (#495 cfg-twin pattern) so none is lost to the + * same-QN upsert. The ordinal — unlike a line number — must not move when + * code is inserted above the function: the QN is node identity, and a + * line-based suffix would churn nodes on every unrelated edit. */ + CBMFileResult *r = extract("package m\n" + "func init() { a() }\n" + "func init() { b() }\n" + "func a() {}\n" + "func b() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + const char *first = NULL; + const char *second = NULL; + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (!d->name || strcmp(d->name, "init") != 0) { + continue; + } + if (!first) { + first = d->qualified_name; + } else { + second = d->qualified_name; + } + } + ASSERT_NOT_NULL(first); + ASSERT_NOT_NULL(second); + ASSERT_STR_EQ(first, "t.init#x.go:1"); + ASSERT_STR_EQ(second, "t.init#x.go:2"); + + /* Stability: an insertion ABOVE both inits must not change either QN. */ + CBMFileResult *r2 = extract("package m\n" + "import \"fmt\"\n" + "var shifted = fmt.Sprint(\"pad\")\n" + "func init() { a() }\n" + "func init() { b() }\n" + "func a() {}\n" + "func b() {}\n", + CBM_LANG_GO, "t", "x.go"); + ASSERT_NOT_NULL(r2); + ASSERT_FALSE(r2->has_error); + int seen = 0; + for (int i = 0; i < r2->defs.count; i++) { + const CBMDefinition *d = &r2->defs.items[i]; + if (!d->name || strcmp(d->name, "init") != 0) { + continue; + } + seen++; + if (seen == 1) { + ASSERT_STR_EQ(d->qualified_name, "t.init#x.go:1"); + } else { + ASSERT_STR_EQ(d->qualified_name, "t.init#x.go:2"); + } + } + ASSERT_EQ(seen, 2); + cbm_free_result(r2); + cbm_free_result(r); + PASS(); +} + /* TS/JS/TSX receiver-aware flag (#592/#606; same intent as the Perl flag above). * A member call x.foo() with a non-this/super receiver is flagged is_method so * the resolver can suppress a weak short-name match (`re.test()` must not bind a @@ -6580,6 +6642,7 @@ SUITE(extraction) { RUN_TEST(extract_python_member_call_flags_is_method); RUN_TEST(extract_go_selector_call_flags_is_method); RUN_TEST(extract_go_method_receiver_qualified_qn); + RUN_TEST(extract_go_multiple_init_disambiguated); RUN_TEST(extract_ts_member_call_flags_is_method); RUN_TEST(extract_ts_this_super_receiver_not_flagged); RUN_TEST(extract_js_member_call_flags_is_method); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 96eac8714..a5d5b545f 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4837,6 +4837,55 @@ TEST(pipeline_go_receiver_suppresses_weak_method_edge) { PASS(); } +TEST(pipeline_go_multi_init_nodes_survive) { + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_init_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + /* Two files, one package, one init() each — Go runs both at start-up. + * Pre-fix both collapsed onto one QN and the upsert kept one node + * (reproduce-first: RED asserts node count == 2). */ + write_temp_file(tmp, "go.mod", "module example.com/reg\n\ngo 1.22\n"); + write_temp_file(tmp, "reg/a.go", + "package reg\n" + "\n" + "var handlers = map[string]func(){}\n" + "\n" + "func init() { handlers[\"a\"] = handleA }\n" + "\n" + "func handleA() {}\n"); + write_temp_file(tmp, "reg/b.go", + "package reg\n" + "\n" + "func init() { handlers[\"b\"] = handleB }\n" + "\n" + "func handleB() {}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_init.db", tmp); + cbm_pipeline_t *p = cbm_pipeline_new(tmp, db_path, CBM_MODE_FULL); + ASSERT_NOT_NULL(p); + ASSERT_EQ(cbm_pipeline_run(p), 0); + const char *project = cbm_pipeline_project_name(p); + + cbm_store_t *s = cbm_store_open_path(db_path); + ASSERT_NOT_NULL(s); + + /* Both init functions survive as distinct nodes. */ + cbm_node_t *inits = NULL; + int ic = 0; + cbm_store_find_nodes_by_name(s, project, "init", &inits, &ic); + ASSERT_EQ(ic, 2); + cbm_store_free_nodes(inits, ic); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Fixture for the #1928 cross-language reference-guard probes (sequential and * parallel twins). pad_files > 0 adds filler files to push the index over the * parallel-pipeline threshold, since USAGE/WRITES/READS have one resolver per @@ -13261,6 +13310,7 @@ SUITE(pipeline) { RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_go_method_caller_keeps_lsp_join); RUN_TEST(pipeline_go_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_go_multi_init_nodes_survive); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c); RUN_TEST(pipeline_go_rw_usage_never_cross_into_c_parallel); RUN_TEST(pipeline_go_bare_ref_never_binds_field);