Skip to content

Commit d4c7f01

Browse files
fix(extract): give Go build-constrained twin files distinct QNs
Go build-constrained twin files (//go:build lines, GOOS/GOARCH filename suffixes) legally define the same symbols in one package. The indexer ignored the constraints, so the graph upsert kept ONE node per QN by the smallest-file-path rule: a 2-line stub beat the 63-line real implementation and took all 25 inbound CALLS; a bpf2go GOARCH twin pair lost 39 of 40 nodes; parse coverage stayed clean so nothing flagged it. Fold the per-file build constraint tau into Go func/method QNs with the same #-suffix move Rust cfg twins got in #495: tau is the compacted //go:build expression when present, else the official GOOS/GOARCH filename suffix (cbm_go_build_tau in helpers.c, computed once per file in cbm_extract_file). The scope builder mirrors the def formula (go_tau_scope_qn) so body calls in constrained files stay attributed. Types and vars stay plain, keeping parent_class / DEFINES_METHOD joins intact. Two #-aware seams make the suffixed defs resolvable (both also close a latent gap for Rust cfg twins, which already carry such QNs): - cbm_registry_add indexes the simple name as the part before '#' (identifiers cannot contain one), so callers keep resolving by name. - The LSP registry's exact pkg.name lookup falls back to a SOLE #-suffixed variant (lookup_func_sole_tau_variant) - the dominant real-world shape is a constrained file with no in-tree twin, and its callers must keep their lsp_direct/lsp_strategy_cross_file edges. Two or more variants are genuinely ambiguous without the caller's build configuration and fail closed; tau-aware preference for the caller's own constraint is the declared follow-up. The pipeline's LSP-join leaf gate learns the same rule. Reproduce-first tests: extract_go_buildtag_tau_in_func_qns (//go:build compaction, GOOS_GOARCH and _test filename forms, types stay plain, unconstrained files carry no '#') and pipeline_go_buildtag_twins_both_survive (RED on main: count_nodes_named == 1, expected 2; plus the sole-variant caller edge staying alive). 854 tests green across extraction/pipeline/registry/ lsp probes/go_lsp/parallel suites. Part of #1911 (minimal PR: QN disambiguation; tau-aware same-module resolution is the follow-up) Signed-off-by: Ilya Brykau <ilya.brykau@orca.security>
1 parent 3de05cd commit d4c7f01

10 files changed

Lines changed: 351 additions & 6 deletions

File tree

internal/cbm/cbm.c

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,6 +1311,10 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua
13111311
.root = root,
13121312
.macro_table = macro_table,
13131313
.return_type_table = return_type_table,
1314+
/* #1911: per-file Go build constraint, folded into func/method QNs by
1315+
* the def and scope builders so build-tag twin files stop colliding. */
1316+
.go_build_tau =
1317+
language == CBM_LANG_GO ? cbm_go_build_tau(a, source, source_len, rel_path) : NULL,
13141318
};
13151319

13161320
// Run extractors: defs + imports use separate walks (unique recursion patterns),

internal/cbm/cbm.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -599,8 +599,21 @@ typedef struct {
599599
* class-body variable def records which class declares it (parent_class)
600600
* without changing its module-level qualified name. NULL elsewhere. */
601601
const char *var_parent_class;
602+
/* #1911: the Go file's build constraint τ (compacted //go:build expression,
603+
* else the GOOS/GOARCH filename suffix), or NULL for an unconstrained
604+
* file. Folded into func/method QNs by extract_defs.c and mirrored by the
605+
* scope builder in extract_unified.c so build-tag twin files stop
606+
* colliding in the graph upsert. Set once in cbm_extract_file. */
607+
const char *go_build_tau;
602608
} CBMExtractCtx;
603609

610+
/* #1911: resolve a Go file's build constraint τ — the compacted //go:build
611+
* expression when present (constraint lines precede the package clause), else
612+
* the official GOOS/GOARCH filename suffix (name_GOOS.go, name_GOARCH.go,
613+
* name_GOOS_GOARCH.go, each optionally followed by _test) — or NULL for an
614+
* unconstrained file. Defined in helpers.c. */
615+
const char *cbm_go_build_tau(CBMArena *a, const char *source, int source_len, const char *rel_path);
616+
604617
// --- Public API ---
605618

606619
// Bind third-party allocators (tree-sitter, sqlite3) to mimalloc as

internal/cbm/extract_defs.c

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3778,6 +3778,16 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec
37783778
def.is_test = rust_def_is_test(def.decorators);
37793779
}
37803780

3781+
// Go: same move for build-constrained twin files (//go:build lines,
3782+
// GOOS/GOARCH filename suffixes) — fold the per-file constraint τ into
3783+
// func/method QNs so both variants survive the upsert (#1911). Types and
3784+
// vars stay plain, keeping parent_class / DEFINES_METHOD joins intact.
3785+
// MUST mirror go_tau_scope_qn in extract_unified.c exactly, or body calls
3786+
// in constrained files detach to the File node.
3787+
if (ctx->language == CBM_LANG_GO && ctx->go_build_tau) {
3788+
def.qualified_name = cbm_arena_sprintf(a, "%s#%s", def.qualified_name, ctx->go_build_tau);
3789+
}
3790+
37813791
// C++/CUDA: GoogleTest macros are test functions (#1266).
37823792
if (is_gtest) {
37833793
def.is_test = true;

internal/cbm/extract_unified.c

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,17 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan
920920
ctx->language);
921921
}
922922

923+
/* #1911: fold the per-file Go build constraint into the scope QN. MUST mirror
924+
* the def-side formula in extract_defs.c exactly — the two produce the same
925+
* string for the same function, or body calls in build-constrained files
926+
* detach to the File node. */
927+
static const char *go_tau_scope_qn(CBMExtractCtx *ctx, const char *fqn) {
928+
if (!fqn || ctx->language != CBM_LANG_GO || !ctx->go_build_tau) {
929+
return fqn;
930+
}
931+
return cbm_arena_sprintf(ctx->arena, "%s#%s", fqn, ctx->go_build_tau);
932+
}
933+
923934
// Compute class QN for scope tracking.
924935
static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const WalkState *state) {
925936
if (ctx->language == CBM_LANG_OBJECTSCRIPT_UDL) {
@@ -2069,7 +2080,7 @@ static bool push_pre_node_scope(CBMExtractCtx *ctx, TSNode node, const CBMLangSp
20692080
if (ts_node_is_null(label)) {
20702081
return false;
20712082
}
2072-
const char *fqn = compute_func_qn(ctx, label, spec, state);
2083+
const char *fqn = go_tau_scope_qn(ctx, compute_func_qn(ctx, label, spec, state));
20732084
if (!fqn) {
20742085
return false;
20752086
}
@@ -2199,7 +2210,7 @@ static void push_boundary_scopes(CBMExtractCtx *ctx, TSNode node, const CBMLangS
21992210
}
22002211
}
22012212
if (!skip_nested) {
2202-
const char *fqn = compute_func_qn(ctx, node, spec, state);
2213+
const char *fqn = go_tau_scope_qn(ctx, compute_func_qn(ctx, node, spec, state));
22032214
if (fqn && push_function_scope(state, depth, fqn, node)) {
22042215
const char *node_kind = ts_node_type(node);
22052216
bool split_signature = (ctx->language == CBM_LANG_DART &&

internal/cbm/helpers.c

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,111 @@ bool cbm_is_keyword(const char *name, CBMLanguage lang) {
311311
return false;
312312
}
313313

314+
/* Official GOOS / GOARCH tokens recognized as build-constraint filename
315+
* suffixes (go/build's lists; hurd and legacy nacl included for completeness). */
316+
static const char *const GO_TAU_GOOS[] = {
317+
"aix", "android", "darwin", "dragonfly", "freebsd", "hurd", "illumos", "ios", "js", "linux",
318+
"nacl", "netbsd", "openbsd", "plan9", "solaris", "wasip1", "windows", "zos", NULL};
319+
static const char *const GO_TAU_GOARCH[] = {
320+
"386", "amd64", "amd64p32", "arm", "arm64", "loong64", "mips",
321+
"mipsle", "mips64", "mips64le", "ppc", "ppc64", "ppc64le", "riscv",
322+
"riscv64", "s390", "s390x", "sparc", "sparc64", "wasm", NULL};
323+
324+
static bool go_tau_token(const char *s, size_t len, const char *const *table) {
325+
for (int i = 0; table[i]; i++) {
326+
if (strlen(table[i]) == len && strncmp(s, table[i], len) == 0) {
327+
return true;
328+
}
329+
}
330+
return false;
331+
}
332+
333+
const char *cbm_go_build_tau(CBMArena *a, const char *source, int source_len,
334+
const char *rel_path) {
335+
/* A //go:build line wins over the filename suffix (both may be present,
336+
* and the directive is the authoritative constraint since Go 1.17). */
337+
if (source && source_len > 0) {
338+
static const char kPrefix[] = "//go:build";
339+
const char *p = source;
340+
const char *end = source + source_len;
341+
while (p < end) {
342+
const char *nl = memchr(p, '\n', (size_t)(end - p));
343+
size_t linelen = nl ? (size_t)(nl - p) : (size_t)(end - p);
344+
if (linelen >= SLEN("package ") && strncmp(p, "package ", SLEN("package ")) == 0) {
345+
break; /* constraints cannot appear after the package clause */
346+
}
347+
if (linelen > SLEN(kPrefix) && strncmp(p, kPrefix, SLEN(kPrefix)) == 0 &&
348+
(p[SLEN(kPrefix)] == ' ' || p[SLEN(kPrefix)] == '\t')) {
349+
/* Compact the expression: drop whitespace and CR so the QN
350+
* suffix stays readable and stable (the #495 Rust move). */
351+
char buf[CBM_SZ_256];
352+
size_t bi = 0;
353+
for (size_t i = SLEN(kPrefix); i < linelen && bi + SKIP_ONE < sizeof(buf); i++) {
354+
char c = p[i];
355+
if (c == ' ' || c == '\t' || c == '\r') {
356+
continue;
357+
}
358+
buf[bi++] = c;
359+
}
360+
buf[bi] = '\0';
361+
return bi > 0 ? cbm_arena_sprintf(a, "%s", buf) : NULL;
362+
}
363+
if (!nl) {
364+
break;
365+
}
366+
p = nl + SKIP_ONE;
367+
}
368+
}
369+
370+
/* GOOS/GOARCH filename suffix: name_GOOS.go, name_GOARCH.go,
371+
* name_GOOS_GOARCH.go, each optionally followed by _test. */
372+
if (!rel_path || !rel_path[0]) {
373+
return NULL;
374+
}
375+
const char *base = rel_path;
376+
for (const char *pb = rel_path; *pb; pb++) {
377+
if (*pb == '/' || *pb == '\\') {
378+
base = pb + SKIP_ONE;
379+
}
380+
}
381+
size_t blen = strlen(base);
382+
if (blen <= SLEN(".go") || strcmp(base + blen - SLEN(".go"), ".go") != 0) {
383+
return NULL;
384+
}
385+
size_t stem_len = blen - SLEN(".go");
386+
if (stem_len > SLEN("_test") &&
387+
strncmp(base + stem_len - SLEN("_test"), "_test", SLEN("_test")) == 0) {
388+
stem_len -= SLEN("_test");
389+
}
390+
/* Walk the last two '_'-separated segments. */
391+
size_t last = stem_len;
392+
while (last > 0 && base[last - SKIP_ONE] != '_') {
393+
last--;
394+
}
395+
if (last == 0) {
396+
return NULL; /* no '_' — unconstrained */
397+
}
398+
const char *seg2 = base + last;
399+
size_t seg2_len = stem_len - last;
400+
size_t prev_end = last - SKIP_ONE; /* the '_' before seg2 */
401+
if (go_tau_token(seg2, seg2_len, GO_TAU_GOARCH)) {
402+
size_t prev = prev_end;
403+
while (prev > 0 && base[prev - SKIP_ONE] != '_') {
404+
prev--;
405+
}
406+
const char *seg1 = base + prev;
407+
size_t seg1_len = prev_end - prev;
408+
if (prev_end > 0 && go_tau_token(seg1, seg1_len, GO_TAU_GOOS)) {
409+
return cbm_arena_sprintf(a, "%.*s_%.*s", (int)seg1_len, seg1, (int)seg2_len, seg2);
410+
}
411+
return cbm_arena_sprintf(a, "%.*s", (int)seg2_len, seg2);
412+
}
413+
if (go_tau_token(seg2, seg2_len, GO_TAU_GOOS)) {
414+
return cbm_arena_sprintf(a, "%.*s", (int)seg2_len, seg2);
415+
}
416+
return NULL;
417+
}
418+
314419
// Builtins that appear in the keyword set above (so they are suppressed as bare
315420
// usages) but for which we mint a real graph node and an LSP resolution, so a
316421
// CALL to them must still be extracted. MUST stay in sync with kPyBuiltinNodes

internal/cbm/lsp/type_registry.c

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,30 @@ const CBMRegisteredFunc *cbm_registry_lookup_method_aliased(const CBMTypeRegistr
652652
return NULL;
653653
}
654654

655+
/* #495/#1911: a build-constrained twin carries a `#`-suffixed QN
656+
* (`pkg.name#unix`, Rust cfg / Go //go:build), so the exact `pkg.name` key
657+
* misses it. A SOLE suffixed variant is still an exact symbol — return it.
658+
* Several variants are genuinely ambiguous without the caller's own build
659+
* configuration — fail closed and let the multi-candidate registry path
660+
* handle the call at its honest confidence. */
661+
static const CBMRegisteredFunc *lookup_func_sole_tau_variant(const CBMTypeRegistry *reg,
662+
const char *qn, size_t qn_len) {
663+
const CBMRegisteredFunc *sole = NULL;
664+
for (; reg; reg = reg->fallback) {
665+
for (int i = 0; i < reg->func_count; i++) {
666+
const char *cand = reg->funcs[i].qualified_name;
667+
if (!cand || strncmp(cand, qn, qn_len) != 0 || cand[qn_len] != '#') {
668+
continue;
669+
}
670+
if (sole) {
671+
return NULL; /* two constrained twins — ambiguous */
672+
}
673+
sole = &reg->funcs[i];
674+
}
675+
}
676+
return sole;
677+
}
678+
655679
const CBMRegisteredFunc *cbm_registry_lookup_symbol(const CBMTypeRegistry *reg,
656680
const char *package_qn, const char *name) {
657681
if (!reg || !package_qn || !name)
@@ -671,7 +695,11 @@ const CBMRegisteredFunc *cbm_registry_lookup_symbol(const CBMTypeRegistry *reg,
671695
memcpy(buf + pkg_len + 1, name, name_len);
672696
buf[total_len] = '\0';
673697

674-
return cbm_registry_lookup_func(reg, buf);
698+
const CBMRegisteredFunc *r = cbm_registry_lookup_func(reg, buf);
699+
if (r) {
700+
return r;
701+
}
702+
return lookup_func_sole_tau_variant(reg, buf, total_len);
675703
}
676704

677705
// Count parameters in a FUNC signature.

src/pipeline/lsp_resolve.h

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -337,8 +337,18 @@ static inline bool cbm_pipeline_invocation_leaf_matches(const CBMResolvedCall *r
337337
}
338338
const char *resolved_leaf = cbm_lsp_bare_segment(resolved->callee_qn);
339339
const char *call_leaf = cbm_lsp_bare_segment(call->callee_name);
340-
if (resolved_leaf && call_leaf && strcmp(resolved_leaf, call_leaf) == 0) {
341-
return true;
340+
if (resolved_leaf && call_leaf) {
341+
if (strcmp(resolved_leaf, call_leaf) == 0) {
342+
return true;
343+
}
344+
/* #495/#1911: a build-constrained twin QN carries a `#τ` suffix
345+
* (`FlushDisk#linux`); its callable leaf is the part before the '#'
346+
* (identifiers cannot contain one). */
347+
size_t call_leaf_len = strlen(call_leaf);
348+
if (strncmp(resolved_leaf, call_leaf, call_leaf_len) == 0 &&
349+
resolved_leaf[call_leaf_len] == '#') {
350+
return true;
351+
}
342352
}
343353

344354
/* Destructors intentionally join by their exact delete-expression

src/pipeline/registry.c

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -590,8 +590,23 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified
590590
const char *owned_qn = cbm_ht_get_key(r->exact, qualified_name);
591591

592592
/* Index by simple name.
593-
* No array dedup needed: exact-map check above guarantees uniqueness. */
593+
* No array dedup needed: exact-map check above guarantees uniqueness.
594+
* #495/#1911: a build-constrained twin QN carries a `#τ` suffix
595+
* (`pkg.Flush#linux`, Rust cfg / Go //go:build); the SIMPLE name is the
596+
* part before it — identifiers cannot contain '#' — or callers looking up
597+
* `Flush` would never find the constrained definition. */
594598
const char *simple = simple_name(qualified_name);
599+
char simple_buf[CBM_SZ_256];
600+
const char *hash = strchr(simple, '#');
601+
if (hash) {
602+
size_t n = (size_t)(hash - simple);
603+
if (n == 0 || n >= sizeof(simple_buf)) {
604+
return; /* degenerate `#`-leaf — nothing callable to index */
605+
}
606+
memcpy(simple_buf, simple, n);
607+
simple_buf[n] = '\0';
608+
simple = simple_buf;
609+
}
595610
qn_array_t *arr = cbm_ht_get(r->by_name, simple);
596611
if (!arr) {
597612
arr = calloc(CBM_ALLOC_ONE, sizeof(qn_array_t));

tests/test_extraction.c

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3015,6 +3015,76 @@ TEST(go_imports) {
30153015
PASS();
30163016
}
30173017

3018+
/* #1911: Go build-constrained twin files (//go:build lines, GOOS/GOARCH
3019+
* filename suffixes) legally define the same symbols; fold the per-file
3020+
* constraint τ into func/method QNs (#495's Rust cfg `#`-suffix move) so the
3021+
* twins stop colliding in the graph upsert. Types/vars stay plain — the
3022+
* callable surface is what the call graph needs, and plain type QNs keep
3023+
* parent_class/DEFINES_METHOD joins working. */
3024+
static int def_qn_has_suffix(CBMFileResult *r, const char *name, const char *suffix) {
3025+
for (int i = 0; i < r->defs.count; i++) {
3026+
const CBMDefinition *d = &r->defs.items[i];
3027+
if (!d->name || strcmp(d->name, name) != 0 || !d->qualified_name) {
3028+
continue;
3029+
}
3030+
size_t qlen = strlen(d->qualified_name);
3031+
size_t slen = strlen(suffix);
3032+
if (qlen >= slen && strcmp(d->qualified_name + (qlen - slen), suffix) == 0) {
3033+
return 1;
3034+
}
3035+
}
3036+
return 0;
3037+
}
3038+
3039+
TEST(extract_go_buildtag_tau_in_func_qns) {
3040+
/* //go:build expression wins and is compacted into the suffix. */
3041+
CBMFileResult *r = extract("//go:build linux && amd64\n\n"
3042+
"package mirror\n\n"
3043+
"type porter struct{ n int }\n\n"
3044+
"func MirrorConfig(path string) string { return path }\n\n"
3045+
"func (p *porter) Flush() {}\n",
3046+
CBM_LANG_GO, "t", "mirror_impl.go");
3047+
ASSERT_NOT_NULL(r);
3048+
ASSERT_FALSE(r->has_error);
3049+
ASSERT_TRUE(def_qn_has_suffix(r, "MirrorConfig", "#linux&&amd64"));
3050+
ASSERT_TRUE(def_qn_has_suffix(r, "Flush", "#linux&&amd64"));
3051+
/* The type stays plain so DEFINES_METHOD / parent_class joins keep working. */
3052+
ASSERT_FALSE(def_qn_has_suffix(r, "porter", "#linux&&amd64"));
3053+
cbm_free_result(r);
3054+
3055+
/* GOOS/GOARCH filename suffix when no //go:build line is present. */
3056+
r = extract("package mirror\n\n"
3057+
"func MirrorConfig(path string) string { return \"\" }\n",
3058+
CBM_LANG_GO, "t", "mirror_windows_amd64.go");
3059+
ASSERT_NOT_NULL(r);
3060+
ASSERT_FALSE(r->has_error);
3061+
ASSERT_TRUE(def_qn_has_suffix(r, "MirrorConfig", "#windows_amd64"));
3062+
cbm_free_result(r);
3063+
3064+
/* _test suffix is stripped before the GOOS check. */
3065+
r = extract("package mirror\n\n"
3066+
"func helperLinux() int { return 1 }\n",
3067+
CBM_LANG_GO, "t", "mirror_linux_test.go");
3068+
ASSERT_NOT_NULL(r);
3069+
ASSERT_FALSE(r->has_error);
3070+
ASSERT_TRUE(def_qn_has_suffix(r, "helperLinux", "#linux"));
3071+
cbm_free_result(r);
3072+
3073+
/* Unconstrained file → no τ anywhere. */
3074+
r = extract("package mirror\n\n"
3075+
"func Plain() int { return 1 }\n",
3076+
CBM_LANG_GO, "t", "mirror.go");
3077+
ASSERT_NOT_NULL(r);
3078+
ASSERT_FALSE(r->has_error);
3079+
for (int i = 0; i < r->defs.count; i++) {
3080+
if (r->defs.items[i].qualified_name) {
3081+
ASSERT_TRUE(strchr(r->defs.items[i].qualified_name, '#') == NULL);
3082+
}
3083+
}
3084+
cbm_free_result(r);
3085+
PASS();
3086+
}
3087+
30183088
TEST(java_imports) {
30193089
CBMFileResult *r = extract(
30203090
"import java.util.List;\nimport java.util.ArrayList;\nimport static java.lang.Math.PI;\n"
@@ -6699,6 +6769,7 @@ SUITE(extraction) {
66996769
RUN_TEST(python_imports);
67006770
RUN_TEST(js_imports);
67016771
RUN_TEST(go_imports);
6772+
RUN_TEST(extract_go_buildtag_tau_in_func_qns);
67026773
RUN_TEST(java_imports);
67036774
RUN_TEST(rust_imports);
67046775
RUN_TEST(c_imports);

0 commit comments

Comments
 (0)