fix(extract): give each Go init() its own QN

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 <ilya.brykau@orca.security>
This commit is contained in:
Ilya Brykau committed 2026-09-02 11:46:57 +02:00
1 parent dbba757301
commit adff62e7f2
6 files changed
+188

No files matched your search

+19
View File
@@ -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
+16
View File
@@ -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
+36
View File
@@ -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];
+4
View File
@@ -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
+63
View File
@@ -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);
+50
View File
@@ -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);