From 77a0c7d94156a43f19047330c7356be4ee12cba5 Mon Sep 17 00:00:00 2001 From: Martin Vogel Date: Mon, 7 Sep 2026 02:21:44 +0200 Subject: [PATCH] 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 Co-authored-by: Yusuf S. Demirci --- src/pipeline/pass_semantic_edges.c | 8 +++-- tests/test_pipeline.c | 49 ++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/pipeline/pass_semantic_edges.c b/src/pipeline/pass_semantic_edges.c index 6d86407e94..2b69385da7 100644 --- a/src/pipeline/pass_semantic_edges.c +++ b/src/pipeline/pass_semantic_edges.c @@ -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; diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 5608ee5590..fddbe644f7 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -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