diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index da6e720a2..b07723069 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -1311,6 +1311,10 @@ CBMFileResult *cbm_extract_file_ex(const char *source, int source_len, CBMLangua .root = root, .macro_table = macro_table, .return_type_table = return_type_table, + /* #1911: per-file Go build constraint, folded into func/method QNs by + * the def and scope builders so build-tag twin files stop colliding. */ + .go_build_tau = + language == CBM_LANG_GO ? cbm_go_build_tau(a, source, source_len, rel_path) : NULL, }; // Run extractors: defs + imports use separate walks (unique recursion patterns), diff --git a/internal/cbm/cbm.h b/internal/cbm/cbm.h index 4f06bebb7..d4dfc319e 100644 --- a/internal/cbm/cbm.h +++ b/internal/cbm/cbm.h @@ -599,8 +599,21 @@ typedef struct { * class-body variable def records which class declares it (parent_class) * without changing its module-level qualified name. NULL elsewhere. */ const char *var_parent_class; + /* #1911: the Go file's build constraint τ (compacted //go:build expression, + * else the GOOS/GOARCH filename suffix), or NULL for an unconstrained + * file. Folded into func/method QNs by extract_defs.c and mirrored by the + * scope builder in extract_unified.c so build-tag twin files stop + * colliding in the graph upsert. Set once in cbm_extract_file. */ + const char *go_build_tau; } CBMExtractCtx; +/* #1911: resolve a Go file's build constraint τ — the compacted //go:build + * expression when present (constraint lines precede the package clause), else + * the official GOOS/GOARCH filename suffix (name_GOOS.go, name_GOARCH.go, + * name_GOOS_GOARCH.go, each optionally followed by _test) — or NULL for an + * unconstrained file. Defined in helpers.c. */ +const char *cbm_go_build_tau(CBMArena *a, const char *source, int source_len, const char *rel_path); + // --- Public API --- // Bind third-party allocators (tree-sitter, sqlite3) to mimalloc as diff --git a/internal/cbm/extract_defs.c b/internal/cbm/extract_defs.c index 15f96ba17..fd24a5639 100644 --- a/internal/cbm/extract_defs.c +++ b/internal/cbm/extract_defs.c @@ -3778,6 +3778,16 @@ static void extract_func_def(CBMExtractCtx *ctx, TSNode node, const CBMLangSpec def.is_test = rust_def_is_test(def.decorators); } + // Go: same move for build-constrained twin files (//go:build lines, + // GOOS/GOARCH filename suffixes) — fold the per-file constraint τ into + // func/method QNs so both variants survive the upsert (#1911). Types and + // vars stay plain, keeping parent_class / DEFINES_METHOD joins intact. + // MUST mirror go_tau_scope_qn in extract_unified.c exactly, or body calls + // in constrained files detach to the File node. + if (ctx->language == CBM_LANG_GO && ctx->go_build_tau) { + def.qualified_name = cbm_arena_sprintf(a, "%s#%s", def.qualified_name, ctx->go_build_tau); + } + // C++/CUDA: GoogleTest macros are test functions (#1266). if (is_gtest) { def.is_test = true; diff --git a/internal/cbm/extract_unified.c b/internal/cbm/extract_unified.c index a5e314272..09b1cbfcd 100644 --- a/internal/cbm/extract_unified.c +++ b/internal/cbm/extract_unified.c @@ -920,6 +920,17 @@ static const char *compute_func_qn(CBMExtractCtx *ctx, TSNode node, const CBMLan ctx->language); } +/* #1911: fold the per-file Go build constraint into the scope QN. MUST mirror + * the def-side formula in extract_defs.c exactly — the two produce the same + * string for the same function, or body calls in build-constrained files + * detach to the File node. */ +static const char *go_tau_scope_qn(CBMExtractCtx *ctx, const char *fqn) { + if (!fqn || ctx->language != CBM_LANG_GO || !ctx->go_build_tau) { + return fqn; + } + return cbm_arena_sprintf(ctx->arena, "%s#%s", fqn, ctx->go_build_tau); +} + // Compute class QN for scope tracking. static const char *compute_class_qn(CBMExtractCtx *ctx, TSNode node, const WalkState *state) { if (ctx->language == CBM_LANG_OBJECTSCRIPT_UDL) { @@ -2069,7 +2080,7 @@ static bool push_pre_node_scope(CBMExtractCtx *ctx, TSNode node, const CBMLangSp if (ts_node_is_null(label)) { return false; } - const char *fqn = compute_func_qn(ctx, label, spec, state); + const char *fqn = go_tau_scope_qn(ctx, compute_func_qn(ctx, label, spec, state)); if (!fqn) { return false; } @@ -2199,7 +2210,7 @@ static void push_boundary_scopes(CBMExtractCtx *ctx, TSNode node, const CBMLangS } } if (!skip_nested) { - const char *fqn = compute_func_qn(ctx, node, spec, state); + const char *fqn = go_tau_scope_qn(ctx, compute_func_qn(ctx, node, spec, state)); if (fqn && push_function_scope(state, depth, fqn, node)) { const char *node_kind = ts_node_type(node); bool split_signature = (ctx->language == CBM_LANG_DART && diff --git a/internal/cbm/helpers.c b/internal/cbm/helpers.c index 5ea8c6cef..3984428e6 100644 --- a/internal/cbm/helpers.c +++ b/internal/cbm/helpers.c @@ -311,6 +311,111 @@ bool cbm_is_keyword(const char *name, CBMLanguage lang) { return false; } +/* Official GOOS / GOARCH tokens recognized as build-constraint filename + * suffixes (go/build's lists; hurd and legacy nacl included for completeness). */ +static const char *const GO_TAU_GOOS[] = { + "aix", "android", "darwin", "dragonfly", "freebsd", "hurd", "illumos", "ios", "js", "linux", + "nacl", "netbsd", "openbsd", "plan9", "solaris", "wasip1", "windows", "zos", NULL}; +static const char *const GO_TAU_GOARCH[] = { + "386", "amd64", "amd64p32", "arm", "arm64", "loong64", "mips", + "mipsle", "mips64", "mips64le", "ppc", "ppc64", "ppc64le", "riscv", + "riscv64", "s390", "s390x", "sparc", "sparc64", "wasm", NULL}; + +static bool go_tau_token(const char *s, size_t len, const char *const *table) { + for (int i = 0; table[i]; i++) { + if (strlen(table[i]) == len && strncmp(s, table[i], len) == 0) { + return true; + } + } + return false; +} + +const char *cbm_go_build_tau(CBMArena *a, const char *source, int source_len, + const char *rel_path) { + /* A //go:build line wins over the filename suffix (both may be present, + * and the directive is the authoritative constraint since Go 1.17). */ + if (source && source_len > 0) { + static const char kPrefix[] = "//go:build"; + const char *p = source; + const char *end = source + source_len; + while (p < end) { + const char *nl = memchr(p, '\n', (size_t)(end - p)); + size_t linelen = nl ? (size_t)(nl - p) : (size_t)(end - p); + if (linelen >= SLEN("package ") && strncmp(p, "package ", SLEN("package ")) == 0) { + break; /* constraints cannot appear after the package clause */ + } + if (linelen > SLEN(kPrefix) && strncmp(p, kPrefix, SLEN(kPrefix)) == 0 && + (p[SLEN(kPrefix)] == ' ' || p[SLEN(kPrefix)] == '\t')) { + /* Compact the expression: drop whitespace and CR so the QN + * suffix stays readable and stable (the #495 Rust move). */ + char buf[CBM_SZ_256]; + size_t bi = 0; + for (size_t i = SLEN(kPrefix); i < linelen && bi + SKIP_ONE < sizeof(buf); i++) { + char c = p[i]; + if (c == ' ' || c == '\t' || c == '\r') { + continue; + } + buf[bi++] = c; + } + buf[bi] = '\0'; + return bi > 0 ? cbm_arena_sprintf(a, "%s", buf) : NULL; + } + if (!nl) { + break; + } + p = nl + SKIP_ONE; + } + } + + /* GOOS/GOARCH filename suffix: name_GOOS.go, name_GOARCH.go, + * name_GOOS_GOARCH.go, each optionally followed by _test. */ + if (!rel_path || !rel_path[0]) { + return NULL; + } + const char *base = rel_path; + for (const char *pb = rel_path; *pb; pb++) { + if (*pb == '/' || *pb == '\\') { + base = pb + SKIP_ONE; + } + } + size_t blen = strlen(base); + if (blen <= SLEN(".go") || strcmp(base + blen - SLEN(".go"), ".go") != 0) { + return NULL; + } + size_t stem_len = blen - SLEN(".go"); + if (stem_len > SLEN("_test") && + strncmp(base + stem_len - SLEN("_test"), "_test", SLEN("_test")) == 0) { + stem_len -= SLEN("_test"); + } + /* Walk the last two '_'-separated segments. */ + size_t last = stem_len; + while (last > 0 && base[last - SKIP_ONE] != '_') { + last--; + } + if (last == 0) { + return NULL; /* no '_' — unconstrained */ + } + const char *seg2 = base + last; + size_t seg2_len = stem_len - last; + size_t prev_end = last - SKIP_ONE; /* the '_' before seg2 */ + if (go_tau_token(seg2, seg2_len, GO_TAU_GOARCH)) { + size_t prev = prev_end; + while (prev > 0 && base[prev - SKIP_ONE] != '_') { + prev--; + } + const char *seg1 = base + prev; + size_t seg1_len = prev_end - prev; + if (prev_end > 0 && go_tau_token(seg1, seg1_len, GO_TAU_GOOS)) { + return cbm_arena_sprintf(a, "%.*s_%.*s", (int)seg1_len, seg1, (int)seg2_len, seg2); + } + return cbm_arena_sprintf(a, "%.*s", (int)seg2_len, seg2); + } + if (go_tau_token(seg2, seg2_len, GO_TAU_GOOS)) { + return cbm_arena_sprintf(a, "%.*s", (int)seg2_len, seg2); + } + return NULL; +} + // Builtins that appear in the keyword set above (so they are suppressed as bare // usages) but for which we mint a real graph node and an LSP resolution, so a // CALL to them must still be extracted. MUST stay in sync with kPyBuiltinNodes diff --git a/internal/cbm/lsp/type_registry.c b/internal/cbm/lsp/type_registry.c index 9a3fbfaa1..2d6f94736 100644 --- a/internal/cbm/lsp/type_registry.c +++ b/internal/cbm/lsp/type_registry.c @@ -652,6 +652,30 @@ const CBMRegisteredFunc *cbm_registry_lookup_method_aliased(const CBMTypeRegistr return NULL; } +/* #495/#1911: a build-constrained twin carries a `#`-suffixed QN + * (`pkg.name#unix`, Rust cfg / Go //go:build), so the exact `pkg.name` key + * misses it. A SOLE suffixed variant is still an exact symbol — return it. + * Several variants are genuinely ambiguous without the caller's own build + * configuration — fail closed and let the multi-candidate registry path + * handle the call at its honest confidence. */ +static const CBMRegisteredFunc *lookup_func_sole_tau_variant(const CBMTypeRegistry *reg, + const char *qn, size_t qn_len) { + const CBMRegisteredFunc *sole = NULL; + for (; reg; reg = reg->fallback) { + for (int i = 0; i < reg->func_count; i++) { + const char *cand = reg->funcs[i].qualified_name; + if (!cand || strncmp(cand, qn, qn_len) != 0 || cand[qn_len] != '#') { + continue; + } + if (sole) { + return NULL; /* two constrained twins — ambiguous */ + } + sole = ®->funcs[i]; + } + } + return sole; +} + const CBMRegisteredFunc *cbm_registry_lookup_symbol(const CBMTypeRegistry *reg, const char *package_qn, const char *name) { if (!reg || !package_qn || !name) @@ -671,7 +695,11 @@ const CBMRegisteredFunc *cbm_registry_lookup_symbol(const CBMTypeRegistry *reg, memcpy(buf + pkg_len + 1, name, name_len); buf[total_len] = '\0'; - return cbm_registry_lookup_func(reg, buf); + const CBMRegisteredFunc *r = cbm_registry_lookup_func(reg, buf); + if (r) { + return r; + } + return lookup_func_sole_tau_variant(reg, buf, total_len); } // Count parameters in a FUNC signature. diff --git a/src/pipeline/lsp_resolve.h b/src/pipeline/lsp_resolve.h index 96f08e84e..e1a485917 100644 --- a/src/pipeline/lsp_resolve.h +++ b/src/pipeline/lsp_resolve.h @@ -337,8 +337,18 @@ static inline bool cbm_pipeline_invocation_leaf_matches(const CBMResolvedCall *r } const char *resolved_leaf = cbm_lsp_bare_segment(resolved->callee_qn); const char *call_leaf = cbm_lsp_bare_segment(call->callee_name); - if (resolved_leaf && call_leaf && strcmp(resolved_leaf, call_leaf) == 0) { - return true; + if (resolved_leaf && call_leaf) { + if (strcmp(resolved_leaf, call_leaf) == 0) { + return true; + } + /* #495/#1911: a build-constrained twin QN carries a `#τ` suffix + * (`FlushDisk#linux`); its callable leaf is the part before the '#' + * (identifiers cannot contain one). */ + size_t call_leaf_len = strlen(call_leaf); + if (strncmp(resolved_leaf, call_leaf, call_leaf_len) == 0 && + resolved_leaf[call_leaf_len] == '#') { + return true; + } } /* Destructors intentionally join by their exact delete-expression diff --git a/src/pipeline/registry.c b/src/pipeline/registry.c index 5126bcbfe..427025188 100644 --- a/src/pipeline/registry.c +++ b/src/pipeline/registry.c @@ -590,8 +590,23 @@ void cbm_registry_add(cbm_registry_t *r, const char *name, const char *qualified const char *owned_qn = cbm_ht_get_key(r->exact, qualified_name); /* Index by simple name. - * No array dedup needed: exact-map check above guarantees uniqueness. */ + * No array dedup needed: exact-map check above guarantees uniqueness. + * #495/#1911: a build-constrained twin QN carries a `#τ` suffix + * (`pkg.Flush#linux`, Rust cfg / Go //go:build); the SIMPLE name is the + * part before it — identifiers cannot contain '#' — or callers looking up + * `Flush` would never find the constrained definition. */ const char *simple = simple_name(qualified_name); + char simple_buf[CBM_SZ_256]; + const char *hash = strchr(simple, '#'); + if (hash) { + size_t n = (size_t)(hash - simple); + if (n == 0 || n >= sizeof(simple_buf)) { + return; /* degenerate `#`-leaf — nothing callable to index */ + } + memcpy(simple_buf, simple, n); + simple_buf[n] = '\0'; + simple = simple_buf; + } qn_array_t *arr = cbm_ht_get(r->by_name, simple); if (!arr) { arr = calloc(CBM_ALLOC_ONE, sizeof(qn_array_t)); diff --git a/tests/test_extraction.c b/tests/test_extraction.c index 5b7e6a8f7..1ce890314 100644 --- a/tests/test_extraction.c +++ b/tests/test_extraction.c @@ -3015,6 +3015,76 @@ TEST(go_imports) { PASS(); } +/* #1911: Go build-constrained twin files (//go:build lines, GOOS/GOARCH + * filename suffixes) legally define the same symbols; fold the per-file + * constraint τ into func/method QNs (#495's Rust cfg `#`-suffix move) so the + * twins stop colliding in the graph upsert. Types/vars stay plain — the + * callable surface is what the call graph needs, and plain type QNs keep + * parent_class/DEFINES_METHOD joins working. */ +static int def_qn_has_suffix(CBMFileResult *r, const char *name, const char *suffix) { + for (int i = 0; i < r->defs.count; i++) { + const CBMDefinition *d = &r->defs.items[i]; + if (!d->name || strcmp(d->name, name) != 0 || !d->qualified_name) { + continue; + } + size_t qlen = strlen(d->qualified_name); + size_t slen = strlen(suffix); + if (qlen >= slen && strcmp(d->qualified_name + (qlen - slen), suffix) == 0) { + return 1; + } + } + return 0; +} + +TEST(extract_go_buildtag_tau_in_func_qns) { + /* //go:build expression wins and is compacted into the suffix. */ + CBMFileResult *r = extract("//go:build linux && amd64\n\n" + "package mirror\n\n" + "type porter struct{ n int }\n\n" + "func MirrorConfig(path string) string { return path }\n\n" + "func (p *porter) Flush() {}\n", + CBM_LANG_GO, "t", "mirror_impl.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_TRUE(def_qn_has_suffix(r, "MirrorConfig", "#linux&&amd64")); + ASSERT_TRUE(def_qn_has_suffix(r, "Flush", "#linux&&amd64")); + /* The type stays plain so DEFINES_METHOD / parent_class joins keep working. */ + ASSERT_FALSE(def_qn_has_suffix(r, "porter", "#linux&&amd64")); + cbm_free_result(r); + + /* GOOS/GOARCH filename suffix when no //go:build line is present. */ + r = extract("package mirror\n\n" + "func MirrorConfig(path string) string { return \"\" }\n", + CBM_LANG_GO, "t", "mirror_windows_amd64.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_TRUE(def_qn_has_suffix(r, "MirrorConfig", "#windows_amd64")); + cbm_free_result(r); + + /* _test suffix is stripped before the GOOS check. */ + r = extract("package mirror\n\n" + "func helperLinux() int { return 1 }\n", + CBM_LANG_GO, "t", "mirror_linux_test.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + ASSERT_TRUE(def_qn_has_suffix(r, "helperLinux", "#linux")); + cbm_free_result(r); + + /* Unconstrained file → no τ anywhere. */ + r = extract("package mirror\n\n" + "func Plain() int { return 1 }\n", + CBM_LANG_GO, "t", "mirror.go"); + ASSERT_NOT_NULL(r); + ASSERT_FALSE(r->has_error); + for (int i = 0; i < r->defs.count; i++) { + if (r->defs.items[i].qualified_name) { + ASSERT_TRUE(strchr(r->defs.items[i].qualified_name, '#') == NULL); + } + } + cbm_free_result(r); + PASS(); +} + TEST(java_imports) { CBMFileResult *r = extract( "import java.util.List;\nimport java.util.ArrayList;\nimport static java.lang.Math.PI;\n" @@ -6699,6 +6769,7 @@ SUITE(extraction) { RUN_TEST(python_imports); RUN_TEST(js_imports); RUN_TEST(go_imports); + RUN_TEST(extract_go_buildtag_tau_in_func_qns); RUN_TEST(java_imports); RUN_TEST(rust_imports); RUN_TEST(c_imports); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index a45541e57..8a57ee086 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -4688,6 +4688,83 @@ TEST(pipeline_python_receiver_suppresses_weak_method_edge) { PASS(); } +static int count_nodes_named(cbm_store_t *s, const char *project, const char *name); + +TEST(pipeline_go_buildtag_twins_both_survive) { + /* #1911: build-constrained twin files define the same symbols; without a + * constraint discriminator in the QN the upsert keeps ONE node (smallest + * file path wins — the 2-line stub beat the 63-line implementation on the + * measured repo, taking all 25 inbound CALLS with it). With τ folded into + * func QNs both variants survive, and callers keep resolving by simple + * name. */ + char tmp[256]; + snprintf(tmp, sizeof(tmp), "/tmp/cbm_go_tau_XXXXXX"); + if (!cbm_mkdtemp(tmp)) { + FAIL("tmpdir"); + } + + write_temp_file(tmp, "go.mod", "module example.com/fxtau\n\ngo 1.22\n"); + write_temp_file(tmp, "mirror/mirror_unix.go", + "//go:build unix\n" + "\n" + "package mirror\n" + "\n" + "func MirrorConfig(path string) (string, error) {\n" + "\tdst := path + \".bak\"\n" + "\treturn dst, nil\n" + "}\n"); + write_temp_file(tmp, "mirror/mirror_other.go", + "//go:build !unix\n" + "\n" + "package mirror\n" + "\n" + "func MirrorConfig(path string) (string, error) { return \"\", nil }\n"); + write_temp_file(tmp, "mirror/boot.go", + "package mirror\n" + "\n" + "func Boot() (string, error) {\n" + "\treturn MirrorConfig(\"cfg\")\n" + "}\n"); + /* The far more common shape: a constrained file with NO in-tree twin + * (unsupported platforms simply have no file). Its callers must keep + * resolving — the sole `#`-suffixed variant is still an exact symbol. */ + write_temp_file(tmp, "sync/fsync_linux.go", + "//go:build linux\n" + "\n" + "package sync\n" + "\n" + "func FlushDisk(fd int) error { return nil }\n"); + write_temp_file(tmp, "sync/use.go", + "package sync\n" + "\n" + "func UseFlush() error {\n" + "\treturn FlushDisk(3)\n" + "}\n"); + + char db_path[512]; + snprintf(db_path, sizeof(db_path), "%s/go_tau.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); + + /* Reproduce-first: RED before the fix — one node, the other twin gone. */ + ASSERT_EQ(count_nodes_named(s, project, "MirrorConfig"), 2); + /* Callers of a sole constrained variant keep their edge (the dominant + * real-world shape). Calls into a genuine twin PAIR from an unconstrained + * file are ambiguous without the caller's build configuration; τ-aware + * resolution is the declared follow-up. */ + ASSERT_TRUE(cross_file_call_exists(s, project, "UseFlush", "FlushDisk")); + + cbm_store_close(s); + cbm_pipeline_free(p); + th_rmtree(tmp); + PASS(); +} + /* Count nodes with the given exact name in the project (e.g. a Route path). */ static int count_nodes_named(cbm_store_t *s, const char *project, const char *name) { cbm_node_t *ns = NULL; @@ -12805,6 +12882,7 @@ SUITE(pipeline) { #endif RUN_TEST(pipeline_tsjs_receiver_suppresses_weak_method_edge); RUN_TEST(pipeline_python_receiver_suppresses_weak_method_edge); + RUN_TEST(pipeline_go_buildtag_twins_both_survive); RUN_TEST(pipeline_tsjs_receiver_parallel_keeps_service_edges); RUN_TEST(pipeline_python_receiver_parallel_suppresses_weak_method_edges); RUN_TEST(pipeline_parallel_python_cross_only_dunder_gets_synthetic_carrier);