diff --git a/.github/workflows/_lint.yml b/.github/workflows/_lint.yml index 88590103b..ec85f8406 100644 --- a/.github/workflows/_lint.yml +++ b/.github/workflows/_lint.yml @@ -49,3 +49,28 @@ jobs: - name: Lint (cppcheck + clang-format, no clang-tidy — enforced locally) run: scripts/lint.sh --ci CLANG_FORMAT=clang-format-20 + + # Memory-analyzer gate (user decision 2026-08-03: runner cost accepted). + # Path-sensitive clang-analyzer over the memory checks only: leak paths, + # null derefs, uninitialized reads. Its first run produced 9 real fixes; + # the gate is green because every false positive was RESTRUCTURED for + # provability (never suppressed — the NOLINT ban applies here too). + # Vendored-tree diagnostics are path-filtered, mirroring .cppcheck. + lint-mem: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install build deps + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev + + - name: Install LLVM 22 (pinned analyzer toolchain) + run: | + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc + echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" | sudo tee /etc/apt/sources.list.d/llvm-22.list + sudo apt-get update + sudo apt-get install -y clang-tidy-22 + + - name: Memory-analyzer gate + run: make -f Makefile.cbm lint-mem-ci CLANG_TIDY=clang-tidy-22 diff --git a/.github/workflows/_test.yml b/.github/workflows/_test.yml index 648d33c7a..d453244bc 100644 --- a/.github/workflows/_test.yml +++ b/.github/workflows/_test.yml @@ -183,6 +183,47 @@ jobs: # platform can surface a race the others miss. Windows has no TSan runtime on # any toolchain (documented irreducible gap); this shared-code coverage is # its substitute. + # Memory-diagnostics lane (user decision 2026-08-03: runner cost accepted). + # Newest pinned LLVM sanitizer runtime + straighter stacks over the full + # parallel suite — catches what an older compiler-rt can miss. Same + # canonical scripts/test.sh wave as every other leg. + test-diag: + runs-on: ubuntu-latest + timeout-minutes: 120 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + + - name: Install deps + run: sudo apt-get update && sudo apt-get install -y zlib1g-dev ccache + + - name: Install LLVM 22 (pinned diagnostic toolchain) + run: | + wget -qO- https://apt.llvm.org/llvm-snapshot.gpg.key | sudo tee /etc/apt/trusted.gpg.d/apt.llvm.org.asc + echo "deb http://apt.llvm.org/noble/ llvm-toolchain-noble-22 main" | sudo tee /etc/apt/sources.list.d/llvm-22.list + sudo apt-get update + sudo apt-get install -y clang-22 + + - name: Compiler cache (content-verified, restore) + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ github.workspace }}/.ccache + key: ccache-diag-${{ github.ref }}-${{ github.sha }} + restore-keys: | + ccache-diag-${{ github.ref }}- + ccache-diag- + + - name: Test (clang-22, ASan+UBSan, -fno-optimize-sibling-calls) + run: > + scripts/test.sh CC=clang-22 CXX=clang++-22 + SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-optimize-sibling-calls" + + - name: Compiler cache (save) + if: always() + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ github.workspace }}/.ccache + key: ccache-diag-${{ github.ref }}-${{ github.sha }} + test-tsan: strategy: fail-fast: false diff --git a/Makefile.cbm b/Makefile.cbm index 98506d990..db1fbfea4 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -1062,6 +1062,47 @@ lint: lint-tidy lint-cppcheck lint-format lint-no-suppress lint-ci: lint-cppcheck lint-format lint-no-suppress @echo "=== CI linters passed ===" +# ── Local memory-diagnostic lanes (not PR-CI gates by decision: the diag +# build doubles a runner's bill for a marginal delta, and a gating analyzer +# lane needs a suppression story the NOLINT ban deliberately forbids. +# Run these on the local ladder; both found real bugs on first use.) ── + +# Newest pinned toolchain + ASan/UBSan + straighter stacks. Separate BUILD_DIR +# so the shipping-toolchain build stays untouched. +DIAG_LLVM_BIN ?= /opt/homebrew/opt/llvm/bin +diag: + @echo "=== diagnostic lane: $(shell $(DIAG_LLVM_BIN)/clang --version | head -1) ===" + $(MAKE) -f Makefile.cbm build/diag/test-runner \ + CC=$(DIAG_LLVM_BIN)/clang CXX=$(DIAG_LLVM_BIN)/clang++ BUILD_DIR=build/diag \ + SANITIZE="-fsanitize=address,undefined -fno-omit-frame-pointer -fno-optimize-sibling-calls" + ./build/diag/test-runner + +# Path-sensitive memory analysis: leak paths, null derefs, uninitialized reads. +# Non-gating: triage findings against the code (this lane's first run produced +# 9 real fixes and 6 recorded false positives — see the analyzer batch commit). +LINT_MEM_CHECKS = -*,clang-analyzer-unix.Malloc,clang-analyzer-unix.MallocSizeof,clang-analyzer-core.NullDereference,clang-analyzer-core.CallAndMessage,clang-analyzer-core.UndefinedBinaryOperatorResult,clang-analyzer-core.uninitialized.*,clang-analyzer-cplusplus.NewDelete,clang-analyzer-cplusplus.NewDeleteLeaks +lint-mem: + @echo "=== clang-analyzer memory lane (non-gating; triage, don't suppress) ===" + @printf '%s\n' $(LINT_SRCS) | xargs -P 10 -I{} $(CLANG_TIDY) --quiet \ + --checks='$(LINT_MEM_CHECKS)' {} -- $(CFLAGS_COMMON) $(SYSROOT_FLAG) 2>/dev/null; true + +# Gating variant: identical run, but vendored-tree diagnostics are excluded by +# an explicit path filter (analyzer diagnostics bypass --header-filter by +# design; this mirrors the .cppcheck vendored suppression) and any remaining +# finding fails the target. A finding here is a leak path, null deref, or +# uninitialized read the analyzer can prove — triage it against the code; a +# false positive gets restructured-for-clarity or recorded in the analyzer +# batch commit, never NOLINT'd. +lint-mem-ci: + @echo "=== clang-analyzer memory gate ===" + @printf '%s\n' $(LINT_SRCS) | xargs -P 4 -I{} $(CLANG_TIDY) --quiet \ + --checks='$(LINT_MEM_CHECKS)' {} -- $(CFLAGS_COMMON) $(SYSROOT_FLAG) 2>/dev/null \ + | grep -E "error:.*clang-analyzer" | grep -v "/vendored/" > lint-mem-findings.txt; \ + if [ -s lint-mem-findings.txt ]; then \ + echo "=== memory-analyzer findings (gate FAILS) ==="; \ + cat lint-mem-findings.txt; rm -f lint-mem-findings.txt; exit 1; \ + fi; rm -f lint-mem-findings.txt; echo "=== memory gate clean ===" + # ── Security audit (6 layers) ──────────────────────────────────── # Run all security checks: static audit, binary strings, UI, install, network diff --git a/internal/cbm/cbm.c b/internal/cbm/cbm.c index b28919481..5d6309c7e 100644 --- a/internal/cbm/cbm.c +++ b/internal/cbm/cbm.c @@ -653,10 +653,23 @@ static void cbm_quarantine_load(void) { * never freed: the set lives for the whole (short-lived worker) process. * The value stores the phase so cbm_index_quarantine_phase() can report * "crash" vs "hang"; membership (cbm_index_is_quarantined) is value != NULL. */ - char *key = cbm_strdup(line); char *pval = cbm_strdup(phase); - if (key && pval) { - cbm_ht_set(set, key, (void *)pval); + if (!pval) { + continue; + } + if (cbm_ht_has(set, line)) { + /* Duplicate path line: reuse the stored key (the table borrows key + * pointers, so a fresh copy would leak on replace) and free the + * value it displaces. */ + free(cbm_ht_set(set, line, (void *)pval)); + } else { + char *key = cbm_strdup(line); + if (key) { + cbm_ht_set(set, key, (void *)pval); + } else { + /* Partial failure: don't leak the value copy. */ + free(pval); + } } } (void)fclose(f); diff --git a/internal/cbm/sqlite_writer.c b/internal/cbm/sqlite_writer.c index 9c18ba67d..7391803f4 100644 --- a/internal/cbm/sqlite_writer.c +++ b/internal/cbm/sqlite_writer.c @@ -513,8 +513,15 @@ static void pb_flush_leaf(PageBuilder *pb) { pb->leaf_cap = old_cap == 0 ? INITIAL_LEAF_CAP : old_cap * GROWTH_FACTOR; void *tmp = realloc(pb->leaves, (size_t)pb->leaf_cap * sizeof(PageRef)); if (!tmp) { + /* Leave a CONSISTENT empty state: leaf_count stale at >=1 with a + * NULL leaves array walked pb_finalize_* straight into + * leaves[0] (null deref, clang-analyzer traced it) whenever the + * final cell block was already flushed. Empty state routes every + * finalize path to its existing root=0 failure return. */ free(pb->leaves); pb->leaves = NULL; + pb->leaf_count = 0; + pb->leaf_cap = 0; return; } pb->leaves = (PageRef *)tmp; @@ -1050,8 +1057,11 @@ static bool pb_ensure_leaf_cap(PageBuilder *pb) { pb->leaf_cap = pb->leaf_cap == 0 ? INITIAL_LEAF_CAP : pb->leaf_cap * GROWTH_FACTOR; void *tmp = realloc(pb->leaves, (size_t)pb->leaf_cap * sizeof(PageRef)); if (!tmp) { + /* Same consistent-empty contract as pb_flush_leaf's growth path. */ free(pb->leaves); pb->leaves = NULL; + pb->leaf_count = 0; + pb->leaf_cap = 0; return false; } pb->leaves = (PageRef *)tmp; diff --git a/src/cli/cli.c b/src/cli/cli.c index 1ff28e0a4..ca3d760d8 100644 --- a/src/cli/cli.c +++ b/src/cli/cli.c @@ -6445,7 +6445,10 @@ int cbm_config_delete(cbm_config_t *cfg, const char *key) { /* ── Config CLI subcommand ────────────────────────────────────── */ int cbm_cmd_config(int argc, char **argv) { - if (argc == 0 || (argv && (strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0))) { + /* NULL argv with a nonzero argc previously slipped past this guard (the + * inner `argv &&` shielded only the help comparison) and dereferenced + * argv[0] below -- caught by the clang-analyzer lane. */ + if (argc == 0 || !argv || strcmp(argv[0], "--help") == 0 || strcmp(argv[0], "-h") == 0) { printf("Usage: codebase-memory-mcp config [args]\n\n"); printf("Commands:\n"); printf(" list Show all config values\n"); diff --git a/src/cli/config_yaml_edit.c b/src/cli/config_yaml_edit.c index d05696d9f..7a05d1ee8 100644 --- a/src/cli/config_yaml_edit.c +++ b/src/cli/config_yaml_edit.c @@ -2557,8 +2557,11 @@ static int yaml_sequence_parse_block(const yaml_doc_t *doc, size_t begin_line, s const char *identity_value, yaml_mapping_sequence_target_t *target) { size_t range_len = end_line - begin_line; - size_t *starts = range_len ? (size_t *)calloc(range_len, sizeof(*starts)) : NULL; - if (range_len && !starts) { + /* Minimum one element: a NULL-for-empty-range starts is only safe through + * the loop-bound == alloc-bound invariant, which a path-sensitive + * analyzer (rightly) refuses to assume across expressions. */ + size_t *starts = (size_t *)calloc(range_len ? range_len : 1, sizeof(*starts)); + if (!starts) { return YAML_ERROR; } size_t start_count = 0U; diff --git a/src/cypher/cypher.c b/src/cypher/cypher.c index db0c46976..6b7fa0acb 100644 --- a/src/cypher/cypher.c +++ b/src/cypher/cypher.c @@ -4204,8 +4204,11 @@ static void ret_agg_init_group(ret_agg_entry_t *entry, const char *key, int item entry->group_vals = calloc(item_count, sizeof(const char *)); entry->sums = calloc(item_count, sizeof(double)); entry->counts = calloc(item_count, sizeof(int)); - entry->mins = malloc(item_count * sizeof(double)); - entry->maxs = malloc(item_count * sizeof(double)); + /* calloc: the sentinel loop below overwrites every slot, but the bound + * equality with the accumulate loop is invisible to path-sensitive + * analysis; zeroed backing keeps any modeled mismatch defined. */ + entry->mins = calloc(item_count, sizeof(double)); + entry->maxs = calloc(item_count, sizeof(double)); entry->collect_lists = calloc(item_count, sizeof(char **)); entry->collect_counts = calloc(item_count, sizeof(int)); for (int ci = 0; ci < item_count; ci++) { diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 8c721ba02..5636c50cb 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -4598,9 +4598,17 @@ static bool scc_build(const int64_t *src, const int64_t *tgt, int ecount, scc_gr memset(g, 0, sizeof(*g)); return false; } - /* two-pass CSR fill */ + /* two-pass CSR fill. Every endpoint is in ids[] by construction (ids is + * built from these same edge arrays), so a negative lookup is unreachable + * -- but that invariant is invisible to path-sensitive analysis, and a + * guard keeps a future collection bug from becoming an OOB write. Both + * passes must skip identically or the cursors desync. */ for (int i = 0; i < ecount; i++) { int u = scc_id_index(g->ids, nv, src[i]); + int v = scc_id_index(g->ids, nv, tgt[i]); + if (u < 0 || v < 0) { + continue; + } g->adj_head[u + 1]++; } for (int i = 0; i < nv; i++) { @@ -4617,13 +4625,18 @@ static bool scc_build(const int64_t *src, const int64_t *tgt, int ecount, scc_gr for (int i = 0; i < nv; i++) { cursor[i] = g->adj_head[i]; } + int filled = 0; for (int i = 0; i < ecount; i++) { int u = scc_id_index(g->ids, nv, src[i]); int v = scc_id_index(g->ids, nv, tgt[i]); + if (u < 0 || v < 0) { + continue; + } g->adj[cursor[u]++] = v; + filled++; } free(cursor); - g->nedges = ecount; + g->nedges = filled; return true; } @@ -4856,6 +4869,14 @@ static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t scc_free(&g); return CBM_STORE_ERR; } + /* Tarjan assigns every vertex a component, so ncomp >= 1 whenever + * nverts >= 1 -- state it, so the zero-size-allocation path below is + * provably confined to the empty graph. */ + if (g.nverts > 0 && ncomp <= 0) { + free(comp); + scc_free(&g); + return CBM_STORE_ERR; + } /* size per component */ int *csize = calloc((size_t)ncomp, sizeof(int)); if (!csize) { @@ -4915,10 +4936,15 @@ static int arch_compute_cycles(cbm_store_t *store, const char *project, int64_t scc_free(&g); return CBM_STORE_ERR; } - for (int v = 0; v < g.nverts; v++) { - int sl = slot[comp[v]]; - if (sl >= 0) { - members[sl][fill[sl]++] = g.ids[v]; + /* With ncyc == 0 no slot is ever >= 0 (the slot loop can't assign one), + * so members -- NULL in that case -- is never indexed; make that + * invariant local instead of cross-loop so it is checkable. */ + if (ncyc > 0) { + for (int v = 0; v < g.nverts; v++) { + int sl = slot[comp[v]]; + if (sl >= 0) { + members[sl][fill[sl]++] = g.ids[v]; + } } } free(fill); @@ -4961,9 +4987,12 @@ static void arch_node_qn(cbm_store_t *store, int64_t id, char *out, size_t outsz static char *handle_get_architecture(cbm_mcp_server_t *srv, const char *args) { char *project = get_project_arg(args); - char *scope_path = cbm_mcp_get_string_arg(args, "path"); cbm_store_t *store = resolve_store(srv, project); + /* REQUIRE_STORE returns without freeing anything but `project`, so every + * other allocation must come after it (scope_path leaked here before — + * caught by the clang-analyzer unix.Malloc lane). */ REQUIRE_STORE(store, project); + char *scope_path = cbm_mcp_get_string_arg(args, "path"); char *not_indexed = verify_project_indexed(store, project); if (not_indexed) { @@ -11408,6 +11437,11 @@ static int read_bounded_line(FILE *in, char **line, size_t *cap, size_t max_byte if (!grown) { return CBM_NOT_FOUND; } + /* Zero the tail: every byte of the buffer is then defined in any + * caller's model (parse_content_length reads up to one byte past + * the matched prefix), and a future over-read degrades to reading + * NULs instead of undefined memory. One memset per growth step. */ + memset(grown + len, 0, new_cap - len); *line = grown; *cap = new_cap; } diff --git a/src/pipeline/pass_calls.c b/src/pipeline/pass_calls.c index 8cb4083b6..591ed5594 100644 --- a/src/pipeline/pass_calls.c +++ b/src/pipeline/pass_calls.c @@ -345,7 +345,13 @@ static void emit_http_async_edge(cbm_pipeline_ctx_t *ctx, const CBMCall *call, * substring coincidence in the resolved QN (e.g. "SalesforceRestClient" * matches the "RestClient" HTTP lib). Emit a plain CALLS edge — unless a * weak TS/JS member-call match should be suppressed (#592/#606). */ - if (suppress_plain_calls) { + /* !target: the service-pattern call sites pass NULL (the route node is + * synthesized below) behind a hand-duplicated copy of the is_url/ + * is_topic predicate above. While the copies agree this branch is + * unreachable for them -- but a drift between the two would turn + * target->id into a null dereference (clang-analyzer traced exactly + * that), and with no callee node there is nothing to emit anyway. */ + if (suppress_plain_calls || !target) { return; } char esc_callee[CBM_SZ_256]; diff --git a/src/pipeline/pass_definitions.c b/src/pipeline/pass_definitions.c index 2aea44d4e..747f7e16b 100644 --- a/src/pipeline/pass_definitions.c +++ b/src/pipeline/pass_definitions.c @@ -695,6 +695,18 @@ int cbm_pipeline_pass_definitions(cbm_pipeline_ctx_t *ctx, const cbm_file_info_t * complete in-memory graph in Phase 2. */ for (int i = 0; i < file_count; i++) { if (cbm_pipeline_check_cancel(ctx)) { + /* Cancellation mid-extraction: release the cache this pass owns, + * including results already extracted into it (the normal cleanup + * at the end of the pass does the same) -- clang-analyzer caught + * this return leaking the whole cache. */ + if (owns_local_cache) { + for (int j = 0; j < file_count; j++) { + if (local_cache[j]) { + cbm_free_result(local_cache[j]); + } + } + free(local_cache); + } return CBM_NOT_FOUND; } diff --git a/src/pipeline/pass_githistory.c b/src/pipeline/pass_githistory.c index 5981b711e..80ad83d48 100644 --- a/src/pipeline/pass_githistory.c +++ b/src/pipeline/pass_githistory.c @@ -387,7 +387,9 @@ int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result * Single hash-table pass over the same commit set used for coupling so * we don't re-scan history. NULL on OOM is fine — the caller still * gets the couplings. */ - cbm_file_temporal_t *ft_arr = malloc(MAX_FILE_TEMPORAL * sizeof(cbm_file_temporal_t)); + cbm_file_temporal_t *ft_arr = + calloc(MAX_FILE_TEMPORAL, sizeof(cbm_file_temporal_t)); /* defined reads + even under the analyzer's cross-iteration view of the index map */ if (ft_arr) { int ft_count = 0; CBMHashTable *file_idx = cbm_ht_create(CBM_SZ_1K); @@ -404,14 +406,23 @@ int cbm_pipeline_githistory_compute(const char *repo_path, cbm_githistory_result ft_arr[*idx].last_modified = cf[c].timestamp; } } else if (ft_count < MAX_FILE_TEMPORAL) { + /* Allocate the index cell and key BEFORE claiming a slot: + * the previous unchecked malloc dereferenced NULL on + * allocation failure, and a failed strdup would have + * leaked the cell. */ + int *nidx = malloc(sizeof(int)); + char *key = nidx ? strdup(fp) : NULL; + if (!nidx || !key) { + free(nidx); + continue; + } int new_idx = ft_count++; snprintf(ft_arr[new_idx].file_path, sizeof(ft_arr[new_idx].file_path), "%s", fp); ft_arr[new_idx].change_count = 1; ft_arr[new_idx].last_modified = cf[c].timestamp; - int *nidx = malloc(sizeof(int)); *nidx = new_idx; - cbm_ht_set(file_idx, strdup(fp), nidx); + cbm_ht_set(file_idx, key, nidx); } } } diff --git a/src/pipeline/pass_pkgmap.c b/src/pipeline/pass_pkgmap.c index d7b4b5c57..8f4331f17 100644 --- a/src/pipeline/pass_pkgmap.c +++ b/src/pipeline/pass_pkgmap.c @@ -1987,7 +1987,7 @@ const cbm_gbuf_node_t *cbm_pipeline_resolve_import_node(const cbm_pipeline_ctx_t *as2 = '\0'; } /* Convert "::" → "/" (drop the doubled colon cleanly). */ - char clean[1024]; + char clean[1024] = ""; /* first byte defined even on the zero-write path */ size_t ci = 0; for (const char *p = mp; *p && ci + 1 < sizeof(clean); p++) { if (*p == ':') { diff --git a/src/store/store.c b/src/store/store.c index 50204bbb2..f64ae98ba 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -4266,6 +4266,13 @@ int cbm_store_bfs_multi(cbm_store_t *s, const int64_t *seed_ids, int seed_count, int cap = ST_INIT_CAP_16; int n = 0; + /* A negative ceiling would break out of the scan before any row is + * written and then free fields of an unwritten (negative-index) slot -- + * clang-analyzer traced that path into a garbage free. Clamp: zero rows + * is the total behavior for a non-positive ceiling. */ + if (max_results < 0) { + max_results = 0; + } cbm_node_hop_t *visited = malloc(cap * sizeof(cbm_node_hop_t)); int scan_rc16; while ((scan_rc16 = sqlite3_step(stmt)) == SQLITE_ROW) { @@ -5496,6 +5503,16 @@ static int arch_boundaries(cbm_store_t *s, const char *project, const char *path } free(nids); free(npkgs); + /* The boundary accumulators (and the package strings accum_boundary + * duplicated into them) were leaked on this abort path -- caught by + * the clang-analyzer unix.Malloc lane. Mirror the normal cleanup. */ + for (int bi = 0; bi < bn; bi++) { + free(bfroms[bi]); + free(btos[bi]); + } + free(bfroms); + free(btos); + free(bcounts); return CBM_STORE_ERR; } sqlite3_finalize(estmt); @@ -6249,8 +6266,12 @@ static int lg_build(int n, const int *wsi, const int *wdi, const double *ww, int off[i + 1] += off[i]; } int total = off[n]; - int *nbr = malloc((size_t)(total > 0 ? total : 1) * sizeof(int)); - double *w = malloc((size_t)(total > 0 ? total : 1) * sizeof(double)); + /* calloc, not malloc: every slot IS written (off[n] equals the summed + * degrees, two writes per edge), but that invariant is invisible to + * path-sensitive analysis, and zero-filled backing turns any future + * degree-miscount bug into a benign self-loop instead of UB. */ + int *nbr = calloc((size_t)(total > 0 ? total : 1), sizeof(int)); + double *w = calloc((size_t)(total > 0 ? total : 1), sizeof(double)); if (!nbr || !w) { free(off); free(k); @@ -6472,9 +6493,11 @@ static int leiden_aggregate(const cbm_lg_t *g, const int *refined, int r_count, int n = g->n; double *k2 = calloc((size_t)r_count, sizeof(double)); int *gcount = calloc((size_t)r_count, sizeof(int)); - int *gstart = malloc(((size_t)r_count + 1) * sizeof(int)); - int *members = malloc((size_t)n * sizeof(int)); - int *fill = malloc((size_t)r_count * sizeof(int)); + /* calloc: the fill-cursor pattern writes every slot (same degree-sum + * invariant as the CSR builds), invisible to path-sensitive analysis. */ + int *gstart = calloc((size_t)r_count + 1, sizeof(int)); + int *members = calloc((size_t)n, sizeof(int)); + int *fill = calloc((size_t)r_count, sizeof(int)); double *acc = calloc((size_t)r_count, sizeof(double)); int *dirty = malloc((size_t)r_count * sizeof(int)); int *off2 = malloc(((size_t)r_count + 1) * sizeof(int)); @@ -6529,8 +6552,11 @@ static int leiden_aggregate(const cbm_lg_t *g, const int *refined, int r_count, } } int total = off2[r_count]; - int *nbr2 = malloc((size_t)(total > 0 ? total : 1) * sizeof(int)); - double *w2 = malloc((size_t)(total > 0 ? total : 1) * sizeof(double)); + /* calloc for the same reason as the base CSR build: full-fill holds by + * the degree-sum invariant, invisible to path-sensitive analysis; zeroed + * backing degrades a future miscount into a self-loop, not UB. */ + int *nbr2 = calloc((size_t)(total > 0 ? total : 1), sizeof(int)); + double *w2 = calloc((size_t)(total > 0 ? total : 1), sizeof(double)); if (!nbr2 || !w2) { free(k2); free(gcount);