/* * repro_issue495.c — Reproduce-first case for issue #495: * "cfg-gated twin functions collapse into one node; get_code_snippet * returns the inactive branch's body" * * ROOT CAUSE (extraction layer): * extract_func_def() computes: * def.qualified_name = cbm_fqn_compute(project, rel_path, name) * for every Rust function_item it visits. Two same-named functions * guarded by mutually-exclusive #[cfg(...)] attributes both parse as * distinct function_item nodes and both pass through extract_func_def, * but they receive the SAME qualified_name (no cfg predicate is folded * in). When the graph store upserts them it hits the UNIQUE(project, * qualified_name) constraint and the second write silently overwrites * the first — one branch is lost entirely. * * EXPECTED (correct) behavior: * Each cfg-gated twin must receive a DISTINCT qualified_name that * encodes its cfg predicate, e.g. * "t.src.try_extract_pdf_text" (active / feature branch) * "t.src.try_extract_pdf_text#cfg(not(feature=\"rag-pdf\"))" (stub) * So that the graph can keep BOTH nodes and get_code_snippet can return * the correct body for the requested cfg context. * * ACTUAL (buggy) behavior: * Both defs carry identical qualified_name "t.src.try_extract_pdf_text". * The assertion `qn_a != qn_b` FAILS (both equal the same string), so * this test is RED on unpatched code. * * SECONDARY assertions (also RED until fixed, targeting the same root * cause from different angles): * • The REAL-body function has param name "bytes" (no underscore); * the STUB has "_bytes". Each def's signature must correspond to its * own branch — i.e. BOTH signatures must appear in the result, one * containing "bytes" without a leading underscore and one with "_bytes". * • Each def's decorators[0] must contain the cfg predicate of ITS OWN * branch (not the other's), so that a fixer can easily scope-qualify * the QN from the already-captured decorator text. * * Why these assertions are RED on current code: * All three assertions require distinguishing the two defs by their QN. * Since both QNs are currently identical, any loop looking for "the * active branch" finds the SAME node twice, and the body-token / * decorator checks collapse to checking ONE def against itself. */ #include "test_framework.h" #include "cbm.h" /* ── Helpers ──────────────────────────────────────────────────────── */ /* Extract a Rust source string and return the raw CBMFileResult. * Caller must cbm_free_result() the returned pointer. */ static CBMFileResult *rx(const char *src, const char *proj, const char *path) { return cbm_extract_file(src, (int)strlen(src), CBM_LANG_RUST, proj, path, 0, NULL, NULL); } /* Count how many defs in r have exactly this label AND name. */ static int count_defs_named(CBMFileResult *r, const char *label, const char *name) { int n = 0; for (int i = 0; i < r->defs.count; i++) { CBMDefinition *d = &r->defs.items[i]; if (label && (!d->label || strcmp(d->label, label) != 0)) continue; if (name && (!d->name || strcmp(d->name, name) != 0)) continue; n++; } return n; } /* Return the Nth (0-based) def matching label + name, or NULL. */ static CBMDefinition *nth_def_named(CBMFileResult *r, const char *label, const char *name, int nth) { int seen = 0; for (int i = 0; i < r->defs.count; i++) { CBMDefinition *d = &r->defs.items[i]; if (label && (!d->label || strcmp(d->label, label) != 0)) continue; if (name && (!d->name || strcmp(d->name, name) != 0)) continue; if (seen == nth) return d; seen++; } return NULL; } /* ── Test ─────────────────────────────────────────────────────────── */ /* * Rust source with two mutually-exclusive cfg-gated definitions of the * same function. Tree-sitter sees both function_item nodes regardless * of which cfg is active (it does not preprocess). The correct fix must * emit two DISTINCT graph nodes — one per branch — so that * get_code_snippet can return the right body for the right build. * * The "real" branch (feature = "rag-pdf") has: * - parameter name "bytes" (no underscore) * - a non-trivial body (returns Some(String::new())) * - starts at line 2 * * The "stub" branch (not(feature = "rag-pdf")) has: * - parameter name "_bytes" (underscore = unused) * - a trivial body (returns None) * - starts at line 7 */ TEST(repro_issue495_cfg_gated_twins_distinct) { static const char *src = "#[cfg(feature = \"rag-pdf\")]\n" "fn try_extract_pdf_text(bytes: &[u8]) -> Option {\n" " if bytes.is_empty() { return None; }\n" " Some(String::new())\n" "}\n" "\n" "#[cfg(not(feature = \"rag-pdf\"))]\n" "fn try_extract_pdf_text(_bytes: &[u8]) -> Option { None }\n"; CBMFileResult *r = rx(src, "t", "src.rs"); ASSERT_NOT_NULL(r); ASSERT_FALSE(r->has_error); /* ── Part 1: both defs must be present in the extraction output ── */ int twin_count = count_defs_named(r, "Function", "try_extract_pdf_text"); /* Both function_item nodes are in the tree-sitter parse; both must * be emitted. This should already pass on current code (extraction * visits both nodes) and acts as a precondition for Parts 2 & 3. */ ASSERT_GTE(twin_count, 2); /* ── Part 2 (PRIMARY RED): distinct qualified_names per twin ───── */ /* Retrieve the two defs. On buggy code both have the same QN, so * even picking them by index 0 and 1 is meaningful: the pair MUST * carry two DIFFERENT qualified_name strings. */ CBMDefinition *d0 = nth_def_named(r, "Function", "try_extract_pdf_text", 0); CBMDefinition *d1 = nth_def_named(r, "Function", "try_extract_pdf_text", 1); ASSERT_NOT_NULL(d0); ASSERT_NOT_NULL(d1); ASSERT_NOT_NULL(d0->qualified_name); ASSERT_NOT_NULL(d1->qualified_name); /* ROOT CAUSE ASSERTION: the two cfg-gated twins must have DISTINCT * qualified_names so the graph upsert can store them as separate * nodes. On current (buggy) code both equal "t.src.try_extract_pdf_text" * and this assertion FAILS → RED. */ ASSERT_STR_NEQ(d0->qualified_name, d1->qualified_name); /* ── Part 3 (SECONDARY RED): each def carries its own cfg predicate */ /* The decorator text for each function_item is already captured by * extract_decorators() into def.decorators[0]. The fix can use this * captured text to build the disambiguating QN suffix. We verify * that the right predicate lives on the right def: * * - the def whose signature contains "bytes" (no underscore, real * body) must have a decorator containing "feature" but NOT "not(" * - the def whose signature contains "_bytes" (stub) must have a * decorator containing "not(" * * On buggy code: d0 and d1 have identical QN so we cannot distinguish * which is the real and which is the stub — the pair-identity check * in Part 2 already failed. Parts 2 and 3 together pin the root * cause at extract_func_def() failing to fold the cfg predicate into * the qualified_name. */ CBMDefinition *real_def = NULL; /* #[cfg(feature = "rag-pdf")] */ CBMDefinition *stub_def = NULL; /* #[cfg(not(feature = "rag-pdf"))] */ for (int i = 0; i < r->defs.count; i++) { CBMDefinition *d = &r->defs.items[i]; if (!d->name || strcmp(d->name, "try_extract_pdf_text") != 0) continue; if (!d->qualified_name) continue; /* Identify by the cfg predicate baked into the (fixed) QN. * On unpatched code both QNs are identical so neither branch * is reachable via a unique QN → real_def / stub_def stay NULL * → the ASSERT_NOT_NULLs below fire as a second RED signal. */ if (strstr(d->qualified_name, "not(") != NULL) { stub_def = d; } else { real_def = d; } } /* On fixed code: two distinct QNs → both pointers set. */ ASSERT_NOT_NULL(real_def); /* RED on current code */ ASSERT_NOT_NULL(stub_def); /* RED on current code */ /* Decorator text must survive and identify each branch. */ ASSERT_NOT_NULL(real_def->decorators); ASSERT_NOT_NULL(real_def->decorators[0]); ASSERT_TRUE(strstr(real_def->decorators[0], "cfg") != NULL); ASSERT_TRUE(strstr(real_def->decorators[0], "not(") == NULL); ASSERT_NOT_NULL(stub_def->decorators); ASSERT_NOT_NULL(stub_def->decorators[0]); ASSERT_TRUE(strstr(stub_def->decorators[0], "not(") != NULL); /* Line ranges must not overlap (both trees are in-source). */ ASSERT_TRUE(real_def->start_line != stub_def->start_line); ASSERT_TRUE(real_def->end_line < stub_def->start_line || stub_def->end_line < real_def->start_line); cbm_free_result(r); PASS(); } /* ── Suite ────────────────────────────────────────────────────────── */ SUITE(repro_issue495) { RUN_TEST(repro_issue495_cfg_gated_twins_distinct); }