fix(pipeline): skip the function sort when the graph has no functions

phase1_scan_functions() in pass_semantic_edges.c sorts the collected
Function/Method node pointers so the func order is deterministic. When
the graph has no Function or Method node at all -- a struct-only or
config-only project -- node_ptrs is never allocated and the call becomes
qsort(NULL, 0, ...). The func_count <= 0 early-out lives in phase 1b,
which runs only after phase 1 has returned, so it never guards this call.

A NULL qsort base is undefined behaviour. glibc declares the parameter
nonnull, so UBSan on the Linux leg reports "null pointer passed as
argument 1, which is declared to never be null" at the call. The macOS
SDK carries no such attribute, so neither the default sanitizer build nor
the Homebrew LLVM diag lane can flag it there. The existing
pipeline_docstring_go_class test already reaches the call on Linux; the
harness never gated on recoverable UBSan reports, so it went unnoticed.

Guard the sort on func_count > 0. The new test
pipeline_semantic_edges_no_functions indexes a Go struct-only fixture
through the full pipeline and pins the fixture to what it claims
(Struct >= 1, Function = Method = 0). On the Linux leg with
UBSAN_OPTIONS=halt_on_error=1 the pipeline suite aborts at the qsort
call before the fix and completes with it.

Distilled from #1810 with co-author credit.

Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
Co-authored-by: Yusuf S. Demirci <ysf-samet@outlook.com>
This commit is contained in:
Martin Vogel
2026-09-07 02:21:44 +02:00
co-authored by Yusuf S. Demirci
parent aa44c28ea5
commit 77a0c7d941
2 changed files with 55 additions and 2 deletions
+6 -2
View File
@@ -992,8 +992,12 @@ static int phase1_scan_functions(cbm_gbuf_t *gbuf, cbm_sem_func_t **out_funcs,
* sequence — so an unstable order changes WHICH semantic edges are
* emitted. Sort the cheap pointer array by qualified name (unique) and
* re-derive the three fields set so far; the heavy per-func payloads are
* filled in later phases, so no 12.7 KB structs are moved. */
qsort(node_ptrs, (size_t)func_count, sizeof(node_ptrs[0]), cmp_node_ptr_by_qn);
* filled in later phases, so no 12.7 KB structs are moved.
* A graph with no Function/Method nodes never allocates node_ptrs, and
* qsort's base is declared nonnull (glibc), so skip the sort outright. */
if (func_count > 0) {
qsort(node_ptrs, (size_t)func_count, sizeof(node_ptrs[0]), cmp_node_ptr_by_qn);
}
for (int k = 0; k < func_count; k++) {
funcs[k].node_id = node_ptrs[k]->id;
funcs[k].file_path = node_ptrs[k]->file_path;
+49
View File
@@ -13409,6 +13409,54 @@ TEST(pipeline_markdown_and_config_prose_reaches_fts_body) {
PASS();
}
/* A graph with no Function or Method nodes at all -- a struct-only or
* config-only project -- must run pass_semantic_edges cleanly. Its phase-1
* scan used to hand qsort() a NULL base with count 0, before the func_count
* early-out in phase 1b could run. glibc declares qsort's base nonnull, so
* UBSan on the Linux leg reports that call; the macOS SDK carries no such
* attribute, so the sanitizer is silent there. The label counts pin the
* fixture to what it claims: at least one Struct and zero Function/Method,
* i.e. the scan genuinely finds nothing to sort. */
TEST(pipeline_semantic_edges_no_functions) {
char tmp[256];
snprintf(tmp, sizeof(tmp), "/tmp/cbm_nofunc_XXXXXX");
ASSERT_NOT_NULL(cbm_mkdtemp(tmp));
char path[512];
char dbpath[512];
snprintf(dbpath, sizeof(dbpath), "%s/nofunc.db", tmp);
snprintf(path, sizeof(path), "%s/main.go", tmp);
ASSERT_EQ(th_write_file(path, "package main\n\ntype Widget struct{}\n"), 0);
cbm_pipeline_t *p = cbm_pipeline_new(tmp, dbpath, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
cbm_pipeline_free(p);
cbm_store_t *s = cbm_store_open_path(dbpath);
ASSERT_NOT_NULL(s);
sqlite3_stmt *st = NULL;
ASSERT_EQ(sqlite3_prepare_v2(cbm_store_get_db(s), "SELECT COUNT(*) FROM nodes WHERE label = ?1",
-1, &st, NULL),
SQLITE_OK);
const char *labels[] = {"Struct", "Function", "Method"};
int counts[3] = {0, 0, 0};
for (size_t i = 0; i < sizeof(labels) / sizeof(labels[0]); i++) {
sqlite3_reset(st);
sqlite3_bind_text(st, 1, labels[i], -1, SQLITE_TRANSIENT);
ASSERT_EQ(sqlite3_step(st), SQLITE_ROW);
counts[i] = sqlite3_column_int(st, 0);
}
sqlite3_finalize(st);
cbm_store_close(s);
ASSERT_GT(counts[0], 0);
ASSERT_EQ(counts[1], 0);
ASSERT_EQ(counts[2], 0);
th_rmtree(tmp);
PASS();
}
#if defined(CBM_COVERAGE_MARKER_TEST_API) && CBM_COVERAGE_MARKER_TEST_API
/* Join two Studio Export range strings and hand back the result. The caller
* owns nothing: the string lives in the aggregate's arena, so copy it out
@@ -13827,6 +13875,7 @@ SUITE(pipeline) {
RUN_TEST(pipeline_ensemble_routing_unterminated_item_is_safe);
RUN_TEST(pipeline_delta_patch_indexes_docstring_into_fts_body);
RUN_TEST(pipeline_markdown_and_config_prose_reaches_fts_body);
RUN_TEST(pipeline_semantic_edges_no_functions);
}
/* Focused semantic-manifest and publication contracts. Kept separate from the