Files
codebase-memory-mcp/src/store/store.c
T
LynxBay eef798bff7 fix(store): distinguish transient SQLITE_BUSY from corruption before quarantine (#1206, #1037)
The quarantine path in resolve_store_internal() used the shallow
cbm_store_check_integrity() to decide whether to rename a project DB
to .corrupt and rebuild it. That check has two flaws that together
cause healthy databases to be destroyed under concurrent access:

1. It treats ANY sqlite3_prepare_v2 failure as corruption. Under
   concurrent MCP instances (#1206), a writer lock held by another
   instance makes the schema lookup return SQLITE_BUSY/LOCKED — a
   transient condition, not damage. The losing instance quarantines
   a perfectly healthy DB and rebuilds a sparse replacement.

2. It never runs PRAGMA quick_check, so it cannot catch real
   page-level corruption either. A DB with a torn node/edge btree
   but an intact projects table passes the check and keeps being
   served (#1037) — the inverse failure.

This change introduces cbm_store_check_integrity_verdict(), which
returns a tri-state (OK / CORRUPT / TRANSIENT) and runs the shallow
check plus PRAGMA quick_check, classifying BUSY/LOCKED/IO-lock codes
as TRANSIENT. The quarantine decision in mcp.c now quarantines ONLY
on a confirmed CORRUPT verdict; TRANSIENT releases the mutation lease
and retries on the next resolve, never touching the DB file.

The existing cbm_store_check_integrity() and _deep() are unchanged,
so all other call sites (hot opens, artifact import, maintenance)
keep their current behavior.

Verified: builds clean with -Wall -Wextra -Werror; cli list_projects
and index_status run correctly against the patched binary.

Signed-off-by: LynxBay <187708458+LynxBay@users.noreply.github.com>
2026-08-11 21:23:14 +08:00

8553 lines
309 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* store.c — SQLite graph store implementation.
*
* Implements the opaque cbm_store_t handle with prepared statement caching,
* schema initialization, and all CRUD operations for nodes, edges, projects,
* file hashes, search, BFS traversal, and schema introspection.
*/
// for ISO timestamp
#include <stdint.h>
#include "foundation/constants.h"
#include "foundation/hash_table.h"
#include "foundation/sha256.h"
#include <math.h>
enum {
ST_COL_1 = 1,
ST_COL_2 = 2,
ST_COL_3 = 3,
ST_COL_4 = 4,
ST_COL_5 = 5,
ST_COL_6 = 6,
ST_COL_7 = 7,
ST_COL_8 = 8,
ST_COL_9 = 9,
ST_FOUND = -1,
ST_BUF_16 = 16,
ST_BUF_64 = 64,
/* #1083: warn when a PASSIVE checkpoint sees a WAL this large (frames) but
* can't reset it — ~1 GiB at a 4 KiB page, far past the healthy ~1000-frame
* autocheckpoint, so it only fires under genuine starvation. */
ST_WAL_STARVE_WARN_FRAMES = 262144,
/* file: URI for the immutable read-only fallback. A path is at most
* CBM_SZ_1K; percent-encoding can triple it, plus the "file://" prefix,
* the "?immutable=1" suffix and a leading '/'. */
ST_QUERY_URI_MAX = 3 * 1024 + 64,
ST_GROWTH = 2,
ST_MAX_DEGREE = 8192,
ST_HALF_SEC = 500000,
ST_RETRY_WAIT_US = 1000,
ST_INIT_CAP_8 = 8,
ST_INIT_CAP_16 = 16,
ST_SQL_BUF = 8192,
ST_MAX_ROW_CHECK = 5,
ST_QN_MAX_DOTS = 5,
ST_QN_MIN_DOTS = 3,
ST_IN_CLAUSE_MARGIN = 4,
ST_GLOB_MIN_LEN = 3,
ST_GLOB_SKIP = 2,
ST_MAX_LANG = 10,
ST_SEARCH_MAX_BINDS = 32, /* increased: LIKE pre-filter adds binds per pattern */
ST_LIKE_POOL_MAX = 12, /* max malloc'd LIKE strings alive during one search */
ST_LIKE_HINT_MAX = 2, /* max LIKE hints extracted per regex pattern */
ST_MAX_PKGS = 64,
ST_INIT_CAP_4 = 4,
ST_HEADER_PREFIX = 3,
ST_MIN_INDEGREE = 3,
ST_MAX_PATH_DEPTH = 3,
ST_MAX_ITERATIONS = 10,
ST_MAX_SECTIONS = 16,
ST_METHOD_PROP_LEN = 8,
ST_PATH_PROP_LEN = 6,
ST_HANDLER_PROP_LEN = 9,
};
#define SLEN(s) (sizeof(s) - 1)
#include "store/store.h"
#include <stdatomic.h>
#include "foundation/compat_fs.h"
#include "foundation/platform.h"
#include "foundation/compat.h"
#include "foundation/log.h"
#include "foundation/compat_regex.h"
#include "foundation/str_util.h"
#define XXH_INLINE_ALL
#include "xxhash/xxhash.h"
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <time.h>
#include <unistd.h>
/* ── SQLite bind helpers ───────────────────────────────────────── */
/* Isolate the int-to-ptr cast that SQLITE_TRANSIENT expands to.
A union type-pun avoids the performance-no-int-to-ptr diagnostic. */
static sqlite3_destructor_type make_transient(void) {
union {
uintptr_t i;
sqlite3_destructor_type fn;
} u;
u.i = (uintptr_t)CBM_NOT_FOUND;
return u.fn;
}
#define BIND_TRANSIENT (make_transient())
static int bind_text(sqlite3_stmt *s, int col, const char *v) {
return sqlite3_bind_text(s, col, v, CBM_NOT_FOUND, BIND_TRANSIENT);
}
/* ── Internal store structure ───────────────────────────────────── */
struct cbm_store {
sqlite3 *db;
const char *db_path; /* heap-allocated, or NULL for :memory: */
char errbuf[CBM_SZ_512];
/* Prepared statements (lazily initialized, cached for lifetime) */
sqlite3_stmt *stmt_upsert_node;
sqlite3_stmt *stmt_find_node_by_id;
sqlite3_stmt *stmt_find_node_by_qn;
sqlite3_stmt *stmt_find_node_by_qn_any; /* QN lookup without project filter */
sqlite3_stmt *stmt_find_nodes_by_name;
sqlite3_stmt *stmt_find_nodes_by_name_any; /* name lookup without project filter */
sqlite3_stmt *stmt_find_nodes_by_label;
sqlite3_stmt *stmt_find_nodes_by_file;
sqlite3_stmt *stmt_count_nodes;
sqlite3_stmt *stmt_delete_nodes_by_project;
sqlite3_stmt *stmt_delete_nodes_by_file;
sqlite3_stmt *stmt_delete_nodes_by_label;
sqlite3_stmt *stmt_insert_edge;
sqlite3_stmt *stmt_find_edges_by_source;
sqlite3_stmt *stmt_find_edges_by_target;
sqlite3_stmt *stmt_find_edges_by_source_type;
sqlite3_stmt *stmt_find_edges_by_target_type;
sqlite3_stmt *stmt_find_edges_by_type;
sqlite3_stmt *stmt_count_edges;
sqlite3_stmt *stmt_count_edges_by_type;
sqlite3_stmt *stmt_delete_edges_by_project;
sqlite3_stmt *stmt_delete_edges_by_type;
sqlite3_stmt *stmt_upsert_project;
sqlite3_stmt *stmt_get_project;
sqlite3_stmt *stmt_list_projects;
sqlite3_stmt *stmt_delete_project;
sqlite3_stmt *stmt_upsert_file_hash;
sqlite3_stmt *stmt_get_file_hashes;
sqlite3_stmt *stmt_delete_file_hash;
sqlite3_stmt *stmt_delete_file_hashes;
};
/* ── Helpers ────────────────────────────────────────────────────── */
static void store_set_error(cbm_store_t *s, const char *msg) {
snprintf(s->errbuf, sizeof(s->errbuf), "%s", msg);
}
static void store_set_error_sqlite(cbm_store_t *s, const char *prefix) {
snprintf(s->errbuf, sizeof(s->errbuf), "%s: %s", prefix, sqlite3_errmsg(s->db));
}
static int exec_sql(cbm_store_t *s, const char *sql) {
if (!s || !s->db) {
return CBM_STORE_ERR;
}
char *err = NULL;
int rc = sqlite3_exec(s->db, sql, NULL, NULL, &err);
if (rc != SQLITE_OK) {
snprintf(s->errbuf, sizeof(s->errbuf), "exec: %s", err ? err : "unknown");
sqlite3_free(err);
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
/* Safe string: returns "" if NULL. */
static const char *safe_str(const char *s) {
return s ? s : "";
}
/* Safe properties: returns "{}" if NULL. */
static const char *safe_props(const char *s) {
return (s && s[0]) ? s : "{}";
}
/* Duplicate a string onto the heap. */
static char *heap_strdup(const char *s) {
if (!s) {
return NULL;
}
size_t len = strlen(s);
char *d = malloc(len + SKIP_ONE);
if (d) {
memcpy(d, s, len + SKIP_ONE);
}
return d;
}
/* Prepare a statement (cached). If already prepared, reset+clear. */
static sqlite3_stmt *prepare_cached(cbm_store_t *s, sqlite3_stmt **slot, const char *sql) {
if (!s || !s->db) {
return NULL;
}
if (*slot) {
sqlite3_reset(*slot);
sqlite3_clear_bindings(*slot);
return *slot;
}
int rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, slot, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "prepare");
return NULL;
}
return *slot;
}
/* Get ISO-8601 timestamp. */
static void iso_now(char *buf, size_t sz) {
time_t t = time(NULL);
struct tm tm;
cbm_gmtime_r(&t, &tm);
(void)strftime(buf, sz, "%Y-%m-%dT%H:%M:%SZ",
&tm); // cert-err33-c: strftime only fails if buffer is too small — 21-byte ISO
// timestamp always fits in caller-provided buffers
}
/* ── Schema ─────────────────────────────────────────────────────── */
static int init_schema(cbm_store_t *s) {
const char *ddl =
"CREATE TABLE IF NOT EXISTS projects ("
" name TEXT PRIMARY KEY,"
" indexed_at TEXT NOT NULL,"
" root_path TEXT NOT NULL"
");"
"CREATE TABLE IF NOT EXISTS file_hashes ("
" project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE,"
" rel_path TEXT NOT NULL,"
" sha256 TEXT NOT NULL,"
" mtime_ns INTEGER NOT NULL DEFAULT 0,"
" size INTEGER NOT NULL DEFAULT 0,"
" PRIMARY KEY (project, rel_path)"
");"
"CREATE TABLE IF NOT EXISTS nodes ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE,"
" label TEXT NOT NULL,"
" name TEXT NOT NULL,"
" qualified_name TEXT NOT NULL,"
" file_path TEXT DEFAULT '',"
" start_line INTEGER DEFAULT 0,"
" end_line INTEGER DEFAULT 0,"
" properties TEXT DEFAULT '{}',"
" UNIQUE(project, qualified_name)"
");"
/* local_name_gen (#768): IMPORTS edges carry one imported symbol's
* local_name each, so uniqueness must discriminate on it — two named
* imports from the same specifier are distinct edges. Non-IMPORTS
* edges get '' (NOT NULL: NULLs never conflict in a UNIQUE index,
* which would break their dedup entirely). Mirrors the graph-buffer
* dedup key (make_edge_key) and the raw dump writer's DDL + hand-
* built sqlite_autoindex_edges_1 (internal/cbm/sqlite_writer.c) —
* keep all three in sync. */
"CREATE TABLE IF NOT EXISTS edges ("
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
" project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE,"
" source_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,"
" target_id INTEGER NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,"
" type TEXT NOT NULL,"
" properties TEXT DEFAULT '{}',"
" url_path_gen TEXT GENERATED ALWAYS AS (json_extract(properties,'$.url_path')),"
" local_name_gen TEXT GENERATED ALWAYS AS (CASE WHEN type='IMPORTS'"
" THEN coalesce(json_extract(properties,'$.local_name'),'') ELSE '' END),"
" UNIQUE(source_id, target_id, type, local_name_gen)"
");"
"CREATE TABLE IF NOT EXISTS project_summaries ("
" project TEXT PRIMARY KEY,"
" summary TEXT NOT NULL,"
" source_hash TEXT NOT NULL,"
" created_at TEXT NOT NULL,"
" updated_at TEXT NOT NULL"
");"
/* One row per file: its serialized cross-file LSP surface + the
* closure-repair metadata (surface hash, referenced-name bloom,
* governing-config context). Written at publication from exactly the
* def set pass_lsp_cross registered, so an incremental run can
* rehydrate the cross registries for files it does not re-parse.
* Deliberately SEPARATE from the graph tables (like index_coverage):
* this is resolution input, not part of the graph. */
"CREATE TABLE IF NOT EXISTS lsp_surface ("
" project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE,"
" rel_path TEXT NOT NULL,"
" surface_sha TEXT NOT NULL,"
" defs_json TEXT NOT NULL,"
" ref_bloom BLOB,"
" config_ctx TEXT NOT NULL DEFAULT '',"
" PRIMARY KEY (project, rel_path)"
");"
/* Best-effort indexing-coverage signal (#963). One row per file the
* indexer could not fully cover: kind "parse_partial" (indexed, but the
* parse tree had ERROR/MISSING regions — detail = 1-based line ranges)
* or a skip phase ("read"/"extract"/"oversized" — detail = reason).
* Deliberately SEPARATE from the graph tables: coverage is metadata
* about the graph, not part of it. */
"CREATE TABLE IF NOT EXISTS index_coverage ("
" project TEXT NOT NULL,"
" rel_path TEXT NOT NULL,"
" kind TEXT NOT NULL,"
" detail TEXT DEFAULT '',"
" PRIMARY KEY (project, rel_path, kind)"
");"
/* One row per completed coverage persistence attempt. Kept separate
* from projects so existing graph/artifact schema stays compatible and
* a missing row unambiguously means coverage metadata is unavailable. */
"CREATE TABLE IF NOT EXISTS index_coverage_meta ("
" project TEXT PRIMARY KEY REFERENCES projects(name) ON DELETE CASCADE,"
" generation TEXT NOT NULL,"
" index_mode TEXT NOT NULL,"
" recorded_at TEXT NOT NULL,"
" recording_status TEXT NOT NULL,"
" ignored_files_stored INTEGER NOT NULL DEFAULT 0,"
" ignored_files_total INTEGER NOT NULL DEFAULT 0,"
" coverage_version INTEGER NOT NULL DEFAULT 1,"
" hash_records_complete INTEGER NOT NULL DEFAULT 0"
");";
int rc = exec_sql(s, ddl);
if (rc != CBM_STORE_OK) {
return rc;
}
/* Schema-compat probe (#768): DBs created before the local_name_gen
* discriminator still enforce UNIQUE(source_id,target_id,type) and lack
* the column — the widened upsert in cbm_store_insert_edge can neither
* prepare nor let two named imports coexist against them. SQLite cannot
* ALTER a table-level UNIQUE constraint in place, so fail the open:
* callers already treat an unopenable DB as incompatible (a full index
* deletes + rebuilds it, artifact import refuses and falls back to a
* reindex). Read-only query opens skip init_schema and keep working. */
{
sqlite3_stmt *probe = NULL;
if (sqlite3_prepare_v2(s->db, "SELECT local_name_gen FROM edges LIMIT 0;", CBM_NOT_FOUND,
&probe, NULL) != SQLITE_OK) {
cbm_log_warn("store.schema", "result", "incompatible", "missing",
"edges.local_name_gen");
return CBM_STORE_ERR;
}
sqlite3_finalize(probe);
}
/* FTS5 contentless virtual table for BM25 full-text search.
* Contentless (content='') means FTS5 stores only the inverted index,
* not a copy of the source text — required for camelCase tokenization
* because we feed it `cbm_camel_split(name)` at insert time but want
* queries to match against the split tokens, not the original.
* Fails silently if FTS5 is not compiled in (SQLITE_ENABLE_FTS5). */
{
char *fts_err = NULL;
int fts_rc = sqlite3_exec(s->db,
"CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5("
" name, qualified_name, label, file_path,"
" content='',"
" tokenize='unicode61 remove_diacritics 2'"
");",
NULL, NULL, &fts_err);
if (fts_rc != SQLITE_OK && fts_err) {
sqlite3_free(fts_err);
}
}
return CBM_STORE_OK;
}
static int create_user_indexes(cbm_store_t *s) {
const char *sql =
"CREATE INDEX IF NOT EXISTS idx_nodes_label ON nodes(project, label);"
"CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(project, name);"
"CREATE INDEX IF NOT EXISTS idx_nodes_file ON nodes(project, file_path);"
"CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source_id, type);"
"CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target_id, type);"
"CREATE INDEX IF NOT EXISTS idx_edges_type ON edges(project, type);"
"CREATE INDEX IF NOT EXISTS idx_edges_target_type ON edges(project, target_id, type);"
"CREATE INDEX IF NOT EXISTS idx_edges_source_type ON edges(project, source_id, type);"
"CREATE INDEX IF NOT EXISTS idx_edges_url_path ON edges(project, url_path_gen);";
/* NOTE: a partial expression index on json_extract(properties,'$.is_entry_point')
* was tried for arch_entry_points and REVERTED: json_extract in an index WHERE
* aborts CREATE INDEX (and thus store open) on any row whose properties JSON is
* malformed — and pre-fix databases contain such rows (see
* pipeline_def_props_valid_json_when_oversized). Revisit only with a
* json_valid()-guarded expression once legacy DBs have aged out. */
return exec_sql(s, sql);
}
int64_t cbm_store_resolve_mmap_size(void) {
enum { MMAP_DEFAULT = 67108864, BASE_10 = 10 }; /* default 64 MB; decimal radix */
char buf[ST_BUF_64];
if (cbm_safe_getenv("CBM_SQLITE_MMAP_SIZE", buf, sizeof(buf), NULL) == NULL) {
return (int64_t)MMAP_DEFAULT;
}
char *end = NULL;
long long parsed = strtoll(buf, &end, BASE_10);
if (end == buf || *end != '\0') {
/* Malformed — fall back to default rather than fail the store open. */
return (int64_t)MMAP_DEFAULT;
}
if (parsed < 0) {
return 0;
}
return (int64_t)parsed;
}
/* Configure connection pragmas.
* in_memory — :memory: DB (synchronous OFF, no journal file).
* read_only — query-only connection opened SQLITE_OPEN_READONLY. Runs
* ONLY non-writing pragmas (foreign_keys, temp_store,
* busy_timeout, mmap_size) and SKIPS journal_mode=WAL,
* wal_checkpoint and synchronous — those WRITE to the DB/WAL
* and would (a) mutate the DB on every read query and (b)
* fail on a read-only DB file / filesystem.
* in_memory and read_only are mutually exclusive. */
static int configure_pragmas(cbm_store_t *s, bool in_memory, bool read_only) {
int rc;
rc = exec_sql(s, "PRAGMA foreign_keys = ON;");
if (rc != CBM_STORE_OK) {
return rc;
}
rc = exec_sql(s, "PRAGMA temp_store = MEMORY;");
if (rc != CBM_STORE_OK) {
return rc;
}
if (in_memory) {
rc = exec_sql(s, "PRAGMA synchronous = OFF;");
} else if (read_only) {
/* Non-writing pragmas only — see the function comment. */
rc = exec_sql(s, "PRAGMA busy_timeout = 10000;");
if (rc != CBM_STORE_OK) {
return rc;
}
char mmap_sql[ST_BUF_64];
snprintf(mmap_sql, sizeof(mmap_sql), "PRAGMA mmap_size = %lld;",
(long long)cbm_store_resolve_mmap_size());
rc = exec_sql(s, mmap_sql);
} else {
rc = exec_sql(s, "PRAGMA busy_timeout = 10000;");
if (rc != CBM_STORE_OK) {
return rc;
}
rc = exec_sql(s, "PRAGMA journal_mode = WAL;");
if (rc != CBM_STORE_OK) {
return rc;
}
/* Recover stale WAL from previous crash (best-effort).
* PASSIVE never blocks readers and never ftruncates.
* May fail with SQLITE_BUSY if another process holds a lock. */
(void)sqlite3_exec(s->db, "PRAGMA wal_checkpoint(PASSIVE)", NULL, NULL, NULL);
rc = exec_sql(s, "PRAGMA synchronous = NORMAL;");
if (rc != CBM_STORE_OK) {
return rc;
}
/* #1083: bound the WAL file so a checkpoint-starved log is physically
* reclaimed the next time a checkpoint can reset it. Shared/live
* checkpoints are PASSIVE (they never ftruncate — see
* cbm_store_checkpoint's SIGBUS note), so without a size limit the -wal
* file only ever grows; journal_size_limit truncates it back to N bytes
* on the next successful
* reset. N is far above the healthy WAL (~4 MiB under the default
* 1000-page autocheckpoint), so normal indexing never triggers
* truncate/regrow churn — it only fires after abnormal growth.
* Shared/live paths do NOT use a TRUNCATE checkpoint: truncating the WAL
* to zero can raise SIGBUS in a sibling process that has the DB mmap'd
* on macOS. Exclusive staging publication seals separately below. */
rc = exec_sql(s, "PRAGMA journal_size_limit = 268435456;"); /* 256 MiB */
if (rc != CBM_STORE_OK) {
return rc;
}
char mmap_sql[ST_BUF_64];
snprintf(mmap_sql, sizeof(mmap_sql), "PRAGMA mmap_size = %lld;",
(long long)cbm_store_resolve_mmap_size());
rc = exec_sql(s, mmap_sql);
}
return rc;
}
/* ── camelCase splitter for FTS5 indexing ───────────────────────── */
/* Emits the original identifier plus a space-separated split version, so FTS5's
* whitespace tokenizer produces both `updateCloudClient` (exact match) and the
* word tokens `update`, `cloud`, `client`. Rules:
* 1. insert space before an uppercase letter preceded by a lowercase letter
* ("updateCloud" → "update Cloud")
* 2. insert space before an uppercase letter preceded by another uppercase
* but followed by a lowercase letter ("XMLParser" → "XML Parser", not
* "X M L Parser")
* snake_case is already split by FTS5's unicode61 tokenizer on `_`. */
enum {
CAMEL_SPLIT_BUF = 2048,
SQLITE_AUTO_LEN = -1, /* sqlite3_result_text sentinel: use strlen(). */
CAMEL_BUF_GUARD = 2, /* reserve room for inserted space + NUL terminator. */
};
/* Denominator epsilon guard for double-precision cosine. */
#define CBM_STORE_DENOM_EPS_D 1e-10
#define CBM_STORE_DENOM_EPS_F 1e-10F
#define CBM_STORE_INT8_MAX 127.0F
#define CBM_STORE_UNIT_POS_F 1.0F
#define CBM_STORE_UNIT_POS_D 1.0
/* Module-local copy of SQLite's SQLITE_TRANSIENT sentinel ((void*)-1).
* We construct it via memcpy from a volatile intptr_t sentinel so the
* resulting expression isn't syntactically a direct int-to-ptr cast —
* clang-tidy's performance-no-int-to-ptr sees the memcpy boundary and
* doesn't flag it per-use-site. */
static sqlite3_destructor_type cbm_sqlite_transient_destructor(void) {
static const volatile intptr_t raw = -1;
sqlite3_destructor_type dtor = NULL;
memcpy(&dtor, (const void *)&raw, sizeof(dtor));
return dtor;
}
#define CBM_SQLITE_TRANSIENT (cbm_sqlite_transient_destructor())
/* True if we should insert a space BEFORE input[i] to split a camelCase word
* boundary (lowercase→uppercase or uppercase-run→uppercase-then-lowercase). */
static bool camel_should_split(const char *input, int i) {
if (i <= 0) {
return false;
}
char curr = input[i];
char prev = input[i - SKIP_ONE];
char next = input[i + SKIP_ONE];
if (curr >= 'A' && curr <= 'Z' && prev >= 'a' && prev <= 'z') {
return true;
}
if (curr >= 'A' && curr <= 'Z' && prev >= 'A' && prev <= 'Z' && next >= 'a' && next <= 'z') {
return true;
}
return false;
}
static void sqlite_camel_split(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
(void)argc;
const char *input = (const char *)sqlite3_value_text(argv[0]);
if (!input || !input[0]) {
sqlite3_result_text(ctx, input ? input : "", SQLITE_AUTO_LEN, CBM_SQLITE_TRANSIENT);
return;
}
char buf[CAMEL_SPLIT_BUF];
int len = snprintf(buf, sizeof(buf), "%s ", input);
if (len < 0 || len >= (int)sizeof(buf)) {
/* Input too long — fall back to the original string unmodified. */
sqlite3_result_text(ctx, input, SQLITE_AUTO_LEN, CBM_SQLITE_TRANSIENT);
return;
}
for (int i = 0; input[i] && len < (int)sizeof(buf) - CAMEL_BUF_GUARD; i++) {
if (camel_should_split(input, i)) {
buf[len++] = ' ';
}
buf[len++] = input[i];
}
buf[len] = '\0';
sqlite3_result_text(ctx, buf, len, CBM_SQLITE_TRANSIENT);
}
/* ── REGEXP function for SQLite ──────────────────────────────────── */
/* Destructor passed to sqlite3_set_auxdata — frees the cached compiled regex. */
static void regex_free_cb(void *p) {
cbm_regex_t *re = (cbm_regex_t *)p;
cbm_regfree(re);
free(re);
}
/* Cache the compiled regex on argument slot 0 for the lifetime of the statement.
* sqlite3_get_auxdata returns the cached pointer on subsequent rows (same parameter
* value), so cbm_regcomp is called exactly once per statement instead of once per row. */
static void sqlite_regexp(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
(void)argc;
const char *pattern = (const char *)sqlite3_value_text(argv[0]);
const char *text = (const char *)sqlite3_value_text(argv[SKIP_ONE]);
if (!pattern || !text) {
sqlite3_result_int(ctx, 0);
return;
}
cbm_regex_t *re = (cbm_regex_t *)sqlite3_get_auxdata(ctx, 0);
if (!re) {
re = malloc(sizeof(cbm_regex_t));
if (!re) {
sqlite3_result_error_nomem(ctx);
return;
}
if (cbm_regcomp(re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB) != 0) {
free(re);
sqlite3_result_error(ctx, "invalid regex", CBM_NOT_FOUND);
return;
}
sqlite3_set_auxdata(ctx, 0, re, regex_free_cb);
}
sqlite3_result_int(ctx, cbm_regexec(re, text, 0, NULL, 0) == 0 ? SKIP_ONE : 0);
}
/* Case-insensitive REGEXP variant — same auxdata caching strategy. */
static void sqlite_iregexp(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
(void)argc;
const char *pattern = (const char *)sqlite3_value_text(argv[0]);
const char *text = (const char *)sqlite3_value_text(argv[SKIP_ONE]);
if (!pattern || !text) {
sqlite3_result_int(ctx, 0);
return;
}
cbm_regex_t *re = (cbm_regex_t *)sqlite3_get_auxdata(ctx, 0);
if (!re) {
re = malloc(sizeof(cbm_regex_t));
if (!re) {
sqlite3_result_error_nomem(ctx);
return;
}
if (cbm_regcomp(re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB | CBM_REG_ICASE) != 0) {
free(re);
sqlite3_result_error(ctx, "invalid regex", CBM_NOT_FOUND);
return;
}
sqlite3_set_auxdata(ctx, 0, re, regex_free_cb);
}
sqlite3_result_int(ctx, cbm_regexec(re, text, 0, NULL, 0) == 0 ? SKIP_ONE : 0);
}
/* Cosine similarity between two int8 BLOB vectors.
* Returns float in [-1, 1]. Used for vector search at query time. */
static void sqlite_cosine_i8(sqlite3_context *ctx, int argc, sqlite3_value **argv) {
(void)argc;
if (sqlite3_value_type(argv[0]) != SQLITE_BLOB ||
sqlite3_value_type(argv[SKIP_ONE]) != SQLITE_BLOB) {
sqlite3_result_double(ctx, 0.0);
return;
}
int len_a = sqlite3_value_bytes(argv[0]);
int len_b = sqlite3_value_bytes(argv[SKIP_ONE]);
if (len_a != len_b || len_a == 0) {
sqlite3_result_double(ctx, 0.0);
return;
}
const int8_t *a = (const int8_t *)sqlite3_value_blob(argv[0]);
const int8_t *b = (const int8_t *)sqlite3_value_blob(argv[SKIP_ONE]);
int32_t dot = 0;
int32_t mag_a = 0;
int32_t mag_b = 0;
for (int i = 0; i < len_a; i++) {
dot += (int32_t)a[i] * (int32_t)b[i];
mag_a += (int32_t)a[i] * (int32_t)a[i];
mag_b += (int32_t)b[i] * (int32_t)b[i];
}
double denom = sqrt((double)mag_a) * sqrt((double)mag_b);
sqlite3_result_double(ctx, denom > CBM_STORE_DENOM_EPS_D ? (double)dot / denom : 0.0);
}
/* ── Lifecycle ──────────────────────────────────────────────────── */
/* SQLite authorizer: deny dangerous operations that could be exploited via
* SQL injection through the Cypher→SQL translation layer. */
static int store_authorizer(void *user_data, int action, const char *p3, const char *p4,
const char *p5, const char *p6) {
(void)user_data;
(void)p3;
(void)p4;
(void)p5;
(void)p6;
switch (action) {
case SQLITE_ATTACH: /* ATTACH DATABASE — could create/read arbitrary files */
case SQLITE_DETACH: /* DETACH DATABASE */
return SQLITE_DENY;
default:
return SQLITE_OK;
}
}
static cbm_store_t *store_open_internal(const char *path, bool in_memory, bool create) {
cbm_store_t *s = calloc(CBM_ALLOC_ONE, sizeof(cbm_store_t));
if (!s) {
return NULL;
}
int flags = SQLITE_OPEN_READWRITE | (create ? SQLITE_OPEN_CREATE : 0);
if (in_memory) {
flags |= SQLITE_OPEN_MEMORY;
}
char open_path[4096];
const char *effective_path = path;
if (path && !in_memory) {
if (!cbm_path_for_file_api(path, open_path, sizeof(open_path))) {
free(s);
return NULL;
}
effective_path = open_path;
}
int rc = sqlite3_open_v2(effective_path, &s->db, flags, NULL);
if (rc != SQLITE_OK) {
sqlite3_close(s->db);
free(s);
return NULL;
}
if (path && !in_memory) {
s->db_path = heap_strdup(path);
}
/* Security: block ATTACH/DETACH to prevent file creation via SQL injection.
* The authorizer runs inside SQLite's query planner — no string-level bypass. */
sqlite3_set_authorizer(s->db, store_authorizer, NULL);
/* Register REGEXP function (SQLite doesn't have one built-in) */
sqlite3_create_function(s->db, "regexp", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL,
sqlite_regexp, NULL, NULL);
/* Case-insensitive variant for search with case_sensitive=false */
sqlite3_create_function(s->db, "iregexp", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL,
sqlite_iregexp, NULL, NULL);
/* Int8 cosine similarity for vector search */
sqlite3_create_function(s->db, "cbm_cosine_i8", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC,
NULL, sqlite_cosine_i8, NULL, NULL);
/* camelCase splitter for FTS5 BM25 indexing */
sqlite3_create_function(s->db, "cbm_camel_split", SKIP_ONE, SQLITE_UTF8 | SQLITE_DETERMINISTIC,
NULL, sqlite_camel_split, NULL, NULL);
if (configure_pragmas(s, in_memory, false) != CBM_STORE_OK || init_schema(s) != CBM_STORE_OK ||
create_user_indexes(s) != CBM_STORE_OK) {
sqlite3_close(s->db);
safe_str_free(&s->db_path);
free(s);
return NULL;
}
return s;
}
/* ── Shared page-cache slab ──────────────────────────────────────────
*
* Every request opens its own short-lived read-only store, and SQLite serves
* that connection's page cache from the general allocator. Measured on native
* Windows (#581): those blocks land in the allocator's MEDIUM size class, and a
* handful of survivors pin a 512 KiB page each — 140 pages held ~1.4 MiB of
* live data, roughly 2% occupancy, which no purge can reclaim because the pages
* are in use rather than free. The arena census confirmed the allocator was
* behaving correctly: zero free-committed slices, 58% already returned to the
* OS. The problem was allocation SHAPE, not retention.
*
* Giving SQLite one contiguous slab to serve page cache from makes that memory
* dense and bounded, and — because the slab is reused across connections —
* removes the per-request churn that did the pinning. Costs a fixed upfront
* commit, which is the point: bounded beats unbounded.
*
* Must run before SQLite initialises, so it is done once on the first open. */
cbm_store_t *cbm_store_open_memory(void) {
return store_open_internal(":memory:", true, true);
}
cbm_store_t *cbm_store_open_path(const char *db_path) {
if (!db_path) {
return NULL;
}
return store_open_internal(db_path, false, true);
}
cbm_store_t *cbm_store_open_path_existing(const char *db_path) {
if (!db_path) {
return NULL;
}
return store_open_internal(db_path, false, false);
}
const char *cbm_store_db_path(const cbm_store_t *s) {
return s ? s->db_path : NULL;
}
/* Build a SQLite "file:" URI with immutable=1 from a filesystem path.
* immutable=1 bypasses WAL and locking and reads the main DB file directly —
* used only as a fallback for read-only filesystems where the wal-index
* (-shm) cannot be created. URI-special characters are percent-encoded.
* Windows backslashes are normalized to '/', and a leading '/' is inserted
* for drive-letter paths so the drive is not parsed as a URI authority.
* Returns false if the output buffer is too small. */
static bool build_immutable_uri(const char *path, char *out, size_t out_sz) {
static const char PREFIX[] = "file://";
static const char SUFFIX[] = "?immutable=1";
static const char HEX[] = "0123456789ABCDEF";
size_t prefix_len = sizeof(PREFIX) - 1;
size_t suffix_len = sizeof(SUFFIX) - 1;
if (prefix_len + 1 > out_sz) {
return false;
}
memcpy(out, PREFIX, prefix_len);
size_t pos = prefix_len;
/* Ensure the path component begins with '/' (POSIX absolute paths already
* do; Windows "C:\..." gets a leading '/' -> "/C:/..."). */
if (path[0] != '/') {
if (pos + 1 >= out_sz) {
return false;
}
out[pos++] = '/';
}
for (const unsigned char *p = (const unsigned char *)path; *p != '\0'; p++) {
unsigned char c = *p;
if (c == '\\') {
c = '/'; /* normalize Windows separators */
}
bool safe = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
c == '/' || c == '.' || c == '-' || c == '_' || c == '~' || c == ':';
if (safe) {
if (pos + 1 >= out_sz) {
return false;
}
out[pos++] = (char)c;
} else {
if (pos + 3 >= out_sz) {
return false;
}
out[pos++] = '%';
out[pos++] = HEX[(c >> 4) & 0xF];
out[pos++] = HEX[c & 0xF];
}
}
if (pos + suffix_len + 1 > out_sz) {
return false;
}
memcpy(out + pos, SUFFIX, suffix_len);
pos += suffix_len;
out[pos] = '\0';
return true;
}
cbm_store_t *cbm_store_open_path_query(const char *db_path) {
if (!db_path) {
return NULL;
}
cbm_store_t *s = calloc(CBM_ALLOC_ONE, sizeof(cbm_store_t));
if (!s) {
return NULL;
}
/* Query tools open the project DB READ-ONLY: a read query must never
* mutate the DB (the previous READWRITE open + WAL write-pragmas did),
* and must work on a read-only DB file / filesystem.
*
* Try a plain READONLY open first — on a normal writable filesystem this
* reads WAL frames correctly via the -shm wal-index. SQLite opens lazily,
* so a read-only-filesystem failure (cannot create -shm for a WAL-mode
* DB) surfaces on first access, not at open time; we probe with a trivial
* read to force it. If the probe fails, retry once with an immutable URI
* that bypasses WAL and reads the main DB file directly.
*
* No SQLITE_OPEN_CREATE on either path — a missing DB must return NULL
* (no ghost .db for unknown/unindexed projects). */
char open_path[4096];
if (!cbm_path_for_file_api(db_path, open_path, sizeof(open_path))) {
free(s);
return NULL;
}
int rc = sqlite3_open_v2(open_path, &s->db, SQLITE_OPEN_READONLY, NULL);
if (rc == SQLITE_OK) {
/* Force first DB access so a read-only-FS WAL failure surfaces now. */
if (sqlite3_exec(s->db, "SELECT 1 FROM sqlite_master LIMIT 1;", NULL, NULL, NULL) !=
SQLITE_OK) {
sqlite3_close(s->db);
s->db = NULL;
rc = SQLITE_CANTOPEN; /* trigger immutable fallback */
}
}
if (rc != SQLITE_OK) {
sqlite3_close(s->db); /* no-op if already NULL */
s->db = NULL;
/* A genuinely missing DB must return NULL without creating anything —
* only retry with the immutable URI when the file exists but could not
* be opened (the read-only-filesystem case). This also keeps the
* common "project not found" path to a single open attempt. */
if (!cbm_file_exists(db_path)) {
free(s);
return NULL;
}
char uri[ST_QUERY_URI_MAX];
if (!build_immutable_uri(db_path, uri, sizeof(uri))) {
free(s);
return NULL;
}
rc = sqlite3_open_v2(uri, &s->db, SQLITE_OPEN_READONLY | SQLITE_OPEN_URI, NULL);
if (rc != SQLITE_OK) {
/* sqlite3_open_v2 allocates a handle even on failure — must close it. */
sqlite3_close(s->db);
free(s);
return NULL;
}
}
s->db_path = heap_strdup(db_path);
/* Security: block ATTACH/DETACH to prevent file creation via SQL injection. */
sqlite3_set_authorizer(s->db, store_authorizer, NULL);
/* Register REGEXP functions. */
sqlite3_create_function(s->db, "regexp", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL,
sqlite_regexp, NULL, NULL);
sqlite3_create_function(s->db, "iregexp", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL,
sqlite_iregexp, NULL, NULL);
sqlite3_create_function(s->db, "cbm_cosine_i8", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC,
NULL, sqlite_cosine_i8, NULL, NULL);
sqlite3_create_function(s->db, "cbm_camel_split", SKIP_ONE, SQLITE_UTF8 | SQLITE_DETERMINISTIC,
NULL, sqlite_camel_split, NULL, NULL);
if (configure_pragmas(s, false, true) != CBM_STORE_OK) {
sqlite3_close(s->db);
safe_str_free(&s->db_path);
free(s);
return NULL;
}
return s;
}
/* ── Integrity check ───────────────────────────────────────────── */
/* Deep integrity: the shallow check above only sanity-checks the projects
* table, so page-level corruption (torn artifacts, #895) sails through.
* quick_check(1) walks the btrees and stops at the first error. Used on
* rare paths only (artifact import) — cost is proportional to DB size. */
bool cbm_store_check_integrity_deep(cbm_store_t *s) {
if (!cbm_store_check_integrity(s)) {
return false;
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, "PRAGMA quick_check(1);", CBM_NOT_FOUND, &stmt, NULL) !=
SQLITE_OK) {
return false;
}
bool ok = false;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *res = (const char *)sqlite3_column_text(stmt, 0);
ok = res && strcmp(res, "ok") == 0;
if (!ok) {
(void)fprintf(stderr, "ERROR store.corrupt quick_check=%s\n", res ? res : "(null)");
}
}
sqlite3_finalize(stmt);
return ok;
}
/* Classify a SQLite result code as a transient (retryable) condition vs. a
* hard error. SQLITE_BUSY / SQLITE_LOCKED happen when another connection holds
* the writer lock — they are NOT evidence of corruption, yet the bare
* cbm_store_check_integrity() treats any prepare failure as "corrupt". This
* helper backs the verdict API so the quarantine path stops destroying healthy
* DBs that merely lost a lock race (#1206, #1037). */
static bool st_rc_is_transient(int rc) {
switch (rc) {
case SQLITE_BUSY:
case SQLITE_LOCKED:
case SQLITE_IOERR_LOCK:
case SQLITE_IOERR_BLOCKED:
return true;
default:
return false;
}
}
cbm_integrity_verdict_t cbm_store_check_integrity_verdict(cbm_store_t *s) {
if (!s || !s->db) {
/* No handle at all — the caller could not even open the file. That is
* an open problem, not a corruption verdict, so the DB must not be
* quarantined (renaming it would discard a file we never read). */
return CBM_INTEGRITY_TRANSIENT;
}
/* ── Shallow check (projects table shape + root_path sanity) ── */
sqlite3_stmt *stmt = NULL;
int rc =
sqlite3_prepare_v2(s->db, "SELECT count(*) FROM projects;", CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
/* A prepare failure here is the #1206 trigger: under concurrent access
* the schema lookup can return BUSY/LOCKED. Treat transient codes as
* "do not quarantine"; only a hard error is corruption evidence. */
return st_rc_is_transient(rc) ? CBM_INTEGRITY_TRANSIENT : CBM_INTEGRITY_CORRUPT;
}
cbm_integrity_verdict_t verdict = CBM_INTEGRITY_OK;
if (sqlite3_step(stmt) == SQLITE_ROW) {
int row_count = sqlite3_column_int(stmt, 0);
if (row_count > ST_MAX_ROW_CHECK) {
(void)fprintf(stderr, "ERROR store.corrupt table=projects rows=%d (expected 1)\n",
row_count);
verdict = CBM_INTEGRITY_CORRUPT;
}
} else {
/* step returned DONE/ERROR — for a SELECT count(*) this means the table
* is unreadable. BUSY-family => transient; otherwise corruption. */
int step_rc = sqlite3_errcode(s->db);
verdict = st_rc_is_transient(step_rc) ? CBM_INTEGRITY_TRANSIENT : CBM_INTEGRITY_CORRUPT;
}
sqlite3_finalize(stmt);
if (verdict != CBM_INTEGRITY_OK) {
return verdict;
}
/* root_path sanity — same logic as cbm_store_check_integrity, but classify
* prepare failures as transient vs. corrupt rather than a blanket false. */
rc = sqlite3_prepare_v2(s->db,
"SELECT root_path FROM projects WHERE root_path != '' "
"AND NOT (substr(root_path, 1, 1) = '/' "
"OR (substr(root_path, 1, 1) BETWEEN 'A' AND 'Z') "
"OR (substr(root_path, 1, 1) BETWEEN 'a' AND 'z')) LIMIT 1;",
CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
return st_rc_is_transient(rc) ? CBM_INTEGRITY_TRANSIENT : CBM_INTEGRITY_CORRUPT;
}
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *bad_path = (const char *)sqlite3_column_text(stmt, 0);
(void)fprintf(stderr, "ERROR store.corrupt table=projects bad_root_path=%s\n",
bad_path ? bad_path : "(null)");
verdict = CBM_INTEGRITY_CORRUPT;
}
sqlite3_finalize(stmt);
if (verdict != CBM_INTEGRITY_OK) {
return verdict;
}
/* ── Deep check: walk the btrees for page-level damage (#1037) ──
* The shallow check above passes for a DB whose projects table is intact
* but whose node/edge btrees are torn (observed: 185k nodes / 6k edges with
* PRAGMA integrity_check reporting btreeInitPage errors). Only quick_check
* catches that. quick_check is O(db size); this function runs only on the
* quarantine decision path, never on hot opens. */
rc = sqlite3_prepare_v2(s->db, "PRAGMA quick_check(1);", CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
return st_rc_is_transient(rc) ? CBM_INTEGRITY_TRANSIENT : CBM_INTEGRITY_CORRUPT;
}
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *res = (const char *)sqlite3_column_text(stmt, 0);
if (!res || strcmp(res, "ok") != 0) {
(void)fprintf(stderr, "ERROR store.corrupt quick_check=%s\n", res ? res : "(null)");
verdict = CBM_INTEGRITY_CORRUPT;
}
} else {
int step_rc = sqlite3_errcode(s->db);
verdict = st_rc_is_transient(step_rc) ? CBM_INTEGRITY_TRANSIENT : CBM_INTEGRITY_CORRUPT;
}
sqlite3_finalize(stmt);
return verdict;
}
bool cbm_store_check_integrity(cbm_store_t *s) {
if (!s || !s->db) {
return false;
}
/* Each project gets its own .db file, so the projects table should have
* exactly 1 row. More than 5 rows is definitely corrupt (allows some slack
* for edge cases). Also check that root_path looks like a real path. */
sqlite3_stmt *stmt = NULL;
int rc =
sqlite3_prepare_v2(s->db, "SELECT count(*) FROM projects;", CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
return false;
}
bool ok = true;
if (sqlite3_step(stmt) == SQLITE_ROW) {
int row_count = sqlite3_column_int(stmt, 0);
if (row_count > ST_MAX_ROW_CHECK) {
(void)fprintf(stderr, "ERROR store.corrupt table=projects rows=%d (expected 1)\n",
row_count);
ok = false;
}
}
sqlite3_finalize(stmt);
if (ok) {
/* Check that root_path in projects table starts with '/' or a drive
* letter. Corrupt DBs often have numeric strings like "826" in
* root_path. Drive letters may be upper- OR lower-case on Windows
* (e.g. "c:/repo", "y:/share") — rejecting lowercase here flagged
* valid Windows paths as corrupt and deleted the DB (#227/#367). */
rc = sqlite3_prepare_v2(s->db,
"SELECT root_path FROM projects WHERE root_path != '' "
"AND NOT (substr(root_path, 1, 1) = '/' "
"OR (substr(root_path, 1, 1) BETWEEN 'A' AND 'Z') "
"OR (substr(root_path, 1, 1) BETWEEN 'a' AND 'z')) LIMIT 1;",
CBM_NOT_FOUND, &stmt, NULL);
if (rc == SQLITE_OK) {
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *bad_path = (const char *)sqlite3_column_text(stmt, 0);
(void)fprintf(stderr, "ERROR store.corrupt table=projects bad_root_path=%s\n",
bad_path ? bad_path : "(null)");
ok = false;
}
sqlite3_finalize(stmt);
}
}
return ok;
}
cbm_store_t *cbm_store_open(const char *project) {
if (!project) {
return NULL;
}
if (!cbm_validate_project_name(project)) {
return NULL;
}
const char *cdir = cbm_resolve_cache_dir();
if (!cdir) {
cdir = cbm_tmpdir();
}
char path[CBM_SZ_1K];
snprintf(path, sizeof(path), "%s/%s.db", cdir, project);
return store_open_internal(path, false, true);
}
static void finalize_stmt(sqlite3_stmt **s) {
if (*s) {
sqlite3_finalize(*s);
*s = NULL;
}
}
void cbm_store_close(cbm_store_t *s) {
if (!s) {
return;
}
/* Checkpoint WAL before close to prevent orphan WAL accumulation.
* Best-effort — silently skips if concurrent reader holds a lock. */
if (s->db && s->db_path) {
(void)sqlite3_wal_checkpoint_v2(s->db, NULL, SQLITE_CHECKPOINT_PASSIVE, NULL, NULL);
}
/* Finalize all cached statements */
finalize_stmt(&s->stmt_upsert_node);
finalize_stmt(&s->stmt_find_node_by_id);
finalize_stmt(&s->stmt_find_node_by_qn);
finalize_stmt(&s->stmt_find_node_by_qn_any);
finalize_stmt(&s->stmt_find_nodes_by_name);
finalize_stmt(&s->stmt_find_nodes_by_name_any);
finalize_stmt(&s->stmt_find_nodes_by_label);
finalize_stmt(&s->stmt_find_nodes_by_file);
finalize_stmt(&s->stmt_count_nodes);
finalize_stmt(&s->stmt_delete_nodes_by_project);
finalize_stmt(&s->stmt_delete_nodes_by_file);
finalize_stmt(&s->stmt_delete_nodes_by_label);
finalize_stmt(&s->stmt_insert_edge);
finalize_stmt(&s->stmt_find_edges_by_source);
finalize_stmt(&s->stmt_find_edges_by_target);
finalize_stmt(&s->stmt_find_edges_by_source_type);
finalize_stmt(&s->stmt_find_edges_by_target_type);
finalize_stmt(&s->stmt_find_edges_by_type);
finalize_stmt(&s->stmt_count_edges);
finalize_stmt(&s->stmt_count_edges_by_type);
finalize_stmt(&s->stmt_delete_edges_by_project);
finalize_stmt(&s->stmt_delete_edges_by_type);
finalize_stmt(&s->stmt_upsert_project);
finalize_stmt(&s->stmt_get_project);
finalize_stmt(&s->stmt_list_projects);
finalize_stmt(&s->stmt_delete_project);
finalize_stmt(&s->stmt_upsert_file_hash);
finalize_stmt(&s->stmt_get_file_hashes);
finalize_stmt(&s->stmt_delete_file_hash);
finalize_stmt(&s->stmt_delete_file_hashes);
/* Use sqlite3_close_v2 — auto-deallocates when last statement finalizes.
* Prevents ASan false-positive leaks from sqlite3 internal state. */
sqlite3_close_v2(s->db);
safe_str_free(&s->db_path);
free(s);
}
sqlite3 *cbm_store_get_db(cbm_store_t *s) {
return s ? s->db : NULL;
}
int cbm_store_exec(cbm_store_t *s, const char *sql) {
return exec_sql(s, sql);
}
const char *cbm_store_error(cbm_store_t *s) {
return s ? s->errbuf : "null store";
}
/* ── Transaction ────────────────────────────────────────────────── */
int cbm_store_begin(cbm_store_t *s) {
return exec_sql(s, "BEGIN IMMEDIATE;");
}
int cbm_store_commit(cbm_store_t *s) {
return exec_sql(s, "COMMIT;");
}
int cbm_store_rollback(cbm_store_t *s) {
return exec_sql(s, "ROLLBACK;");
}
/* ── Bulk write ─────────────────────────────────────────────────── */
int cbm_store_begin_bulk(cbm_store_t *s) {
/* Stay in WAL mode throughout. Switching to MEMORY journal mode would
* make the database unrecoverable if the process crashes mid-write,
* because the in-memory rollback journal is lost on crash.
* WAL mode is crash-safe: uncommitted WAL entries are simply discarded
* on the next open. Performance is preserved via synchronous=OFF and a
* larger cache, which are safe with WAL. */
int rc = exec_sql(s, "PRAGMA synchronous = OFF;");
if (rc != CBM_STORE_OK) {
return rc;
}
return exec_sql(s, "PRAGMA cache_size = -65536;"); /* CBM_SZ_64 MB */
}
int cbm_store_end_bulk(cbm_store_t *s) {
int rc = exec_sql(s, "PRAGMA synchronous = NORMAL;");
if (rc != CBM_STORE_OK) {
return rc;
}
return exec_sql(s, "PRAGMA cache_size = -2000;"); /* default ~2 MB */
}
int cbm_store_drop_indexes(cbm_store_t *s) {
return exec_sql(s, "DROP INDEX IF EXISTS idx_nodes_label;"
"DROP INDEX IF EXISTS idx_nodes_name;"
"DROP INDEX IF EXISTS idx_nodes_file;"
"DROP INDEX IF EXISTS idx_edges_source;"
"DROP INDEX IF EXISTS idx_edges_target;"
"DROP INDEX IF EXISTS idx_edges_type;"
"DROP INDEX IF EXISTS idx_edges_target_type;"
"DROP INDEX IF EXISTS idx_edges_source_type;");
}
int cbm_store_create_indexes(cbm_store_t *s) {
return create_user_indexes(s);
}
/* ── Checkpoint ─────────────────────────────────────────────────── */
int cbm_store_checkpoint(cbm_store_t *s) {
if (!s) {
return CBM_STORE_ERR;
}
/* PASSIVE never blocks readers and never ftruncate()s either file.
* SQLite recommends PASSIVE for shared databases — TRUNCATE shrinks
* the WAL via ftruncate(fd, 0) on success, which on macOS can raise
* SIGBUS in a sibling process that has the DB mmap'd through SQLite
* when it next faults a page in the now-shorter region.
* See https://www.sqlite.org/c3ref/c_checkpoint_full.html */
int wal_frames = 0;
int checkpointed = 0;
int rc = sqlite3_wal_checkpoint_v2(s->db, NULL, SQLITE_CHECKPOINT_PASSIVE, &wal_frames,
&checkpointed);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "checkpoint");
return CBM_STORE_ERR;
}
/* #1083: a large WAL that a PASSIVE checkpoint can't fully reset — because
* concurrent readers hold marks — is the checkpoint-starvation signal. Warn
* so an operator can see it (and the driving processes) before the -wal file
* fills the disk; journal_size_limit only reclaims once a reset succeeds.
* wal_frames = frames in the WAL, checkpointed = frames reclaimed this pass. */
if (wal_frames >= ST_WAL_STARVE_WARN_FRAMES && checkpointed < wal_frames) {
char frames_buf[ST_BUF_16];
char ckpt_buf[ST_BUF_16];
snprintf(frames_buf, sizeof(frames_buf), "%d", wal_frames);
snprintf(ckpt_buf, sizeof(ckpt_buf), "%d", checkpointed);
cbm_log_warn("store.wal.starving", "wal_frames", frames_buf, "checkpointed", ckpt_buf,
"hint",
"concurrent readers block the WAL reset — the -wal file keeps growing");
}
return exec_sql(s, "PRAGMA optimize;");
}
static int prepare_sqlite_for_publish(sqlite3 *db) {
if (!db) {
return CBM_STORE_ERR;
}
int log_frames = -1;
int checkpointed_frames = -1;
int rc = sqlite3_wal_checkpoint_v2(db, NULL, SQLITE_CHECKPOINT_TRUNCATE, &log_frames,
&checkpointed_frames);
if (rc != SQLITE_OK || (log_frames >= 0 && checkpointed_frames != log_frames)) {
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(db, "PRAGMA journal_mode=DELETE;", CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
return CBM_STORE_ERR;
}
bool delete_mode = false;
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *mode = (const char *)sqlite3_column_text(stmt, 0);
delete_mode = mode && strcmp(mode, "delete") == 0;
}
sqlite3_finalize(stmt);
return delete_mode ? CBM_STORE_OK : CBM_STORE_ERR;
}
int cbm_store_prepare_for_publish(cbm_store_t *s) {
if (!s || !s->db) {
return CBM_STORE_ERR;
}
return prepare_sqlite_for_publish(s->db);
}
int cbm_store_prepare_path_for_replace(const char *path) {
if (!path) {
return CBM_STORE_ERR;
}
char prepare_path[4096];
if (!cbm_path_for_file_api(path, prepare_path, sizeof(prepare_path))) {
return CBM_STORE_ERR;
}
sqlite3 *db = NULL;
int rc = sqlite3_open_v2(prepare_path, &db, SQLITE_OPEN_READWRITE, NULL);
if (rc != SQLITE_OK) {
sqlite3_close(db);
return CBM_STORE_ERR;
}
sqlite3_busy_timeout(db, 10000);
int result = prepare_sqlite_for_publish(db);
if (sqlite3_close(db) != SQLITE_OK) {
result = CBM_STORE_ERR;
}
return result;
}
int cbm_store_backup_path(const char *source_path, const char *staging_path) {
if (!source_path || !staging_path) {
return CBM_STORE_ERR;
}
if (cbm_remove_db_sidecars(staging_path) != 0) {
return CBM_STORE_ERR;
}
char source_api[4096];
char staging_api[4096];
if (!cbm_path_for_file_api(source_path, source_api, sizeof(source_api)) ||
!cbm_path_for_file_api(staging_path, staging_api, sizeof(staging_api))) {
return CBM_STORE_ERR;
}
sqlite3 *source = NULL;
sqlite3 *dest = NULL;
int rc = sqlite3_open_v2(source_api, &source, SQLITE_OPEN_READONLY, NULL);
if (rc != SQLITE_OK) {
sqlite3_close(source);
return CBM_STORE_ERR;
}
sqlite3_busy_timeout(source, 10000);
rc = sqlite3_open_v2(staging_api, &dest, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL);
if (rc != SQLITE_OK) {
sqlite3_close(dest);
sqlite3_close(source);
return CBM_STORE_ERR;
}
sqlite3_busy_timeout(dest, 10000);
sqlite3_backup *backup = sqlite3_backup_init(dest, "main", source, "main");
int result = CBM_STORE_ERR;
if (backup) {
int step_rc = sqlite3_backup_step(backup, CBM_NOT_FOUND);
int finish_rc = sqlite3_backup_finish(backup);
if (step_rc == SQLITE_DONE && finish_rc == SQLITE_OK) {
result = CBM_STORE_OK;
}
}
if (sqlite3_close(dest) != SQLITE_OK) {
result = CBM_STORE_ERR;
}
if (sqlite3_close(source) != SQLITE_OK) {
result = CBM_STORE_ERR;
}
return result;
}
/* #1083: the WAL size limit configured on this (write) connection, in bytes.
* -1 means unlimited (SQLite's default — the pre-fix behavior). Per-connection
* and not persisted, so it can only be read on the connection that set it. */
int64_t cbm_store_journal_size_limit(cbm_store_t *s) {
if (!s || !s->db) {
return -1;
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, "PRAGMA journal_size_limit;", -1, &stmt, NULL) != SQLITE_OK) {
return -1;
}
int64_t limit = -1;
if (sqlite3_step(stmt) == SQLITE_ROW) {
limit = sqlite3_column_int64(stmt, 0);
}
sqlite3_finalize(stmt);
return limit;
}
int cbm_store_seal_for_atomic_publish(cbm_store_t *s) {
if (!s || !s->db) {
return CBM_STORE_ERR;
}
/* Bulk indexing deliberately relaxes synchronous writes. Restore the
* strongest durability before copying WAL pages into the database file.
* This staging connection is exclusively owned by the publisher. */
if (exec_sql(s, "PRAGMA synchronous = FULL;") != CBM_STORE_OK) {
return CBM_STORE_ERR;
}
int log_frames = 0;
int checkpointed_frames = 0;
int rc = sqlite3_wal_checkpoint_v2(s->db, NULL, SQLITE_CHECKPOINT_TRUNCATE, &log_frames,
&checkpointed_frames);
if (rc != SQLITE_OK) {
snprintf(s->errbuf, sizeof(s->errbuf), "seal checkpoint: %s (log=%d checkpointed=%d)",
sqlite3_errstr(rc), log_frames, checkpointed_frames);
return CBM_STORE_ERR;
}
/* PRAGMA journal_mode returns the mode SQLite actually entered. sqlite3_exec
* would discard that result and could falsely report a successful seal. */
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(s->db, "PRAGMA journal_mode = DELETE;", CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "seal journal_mode prepare");
return CBM_STORE_ERR;
}
rc = sqlite3_step(stmt);
if (rc != SQLITE_ROW) {
store_set_error_sqlite(s, "seal journal_mode step");
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
const char *mode = (const char *)sqlite3_column_text(stmt, 0);
bool is_delete = mode && sqlite3_stricmp(mode, "delete") == 0;
rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "seal journal_mode completion");
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
rc = sqlite3_finalize(stmt);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "seal journal_mode finalize");
return CBM_STORE_ERR;
}
if (!is_delete) {
store_set_error(s, "seal journal_mode did not enter delete mode");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_seal_existing_path_for_replace(const char *db_path) {
if (!db_path || !db_path[0]) {
return CBM_STORE_ERR;
}
/* Normalize to the Windows file-API form exactly as the primary open does
* (cbm_store_open_internal). SQLite's win VFS needs the wide-round-tripped
* path; the raw UTF-8 string fails to open a file that was WRITTEN through
* the normalized form, so re-sealing a just-indexed generation returned -1
* and finalize misreported it as "repo has no source files". */
char seal_path[4096];
if (!cbm_path_for_file_api(db_path, seal_path, sizeof(seal_path))) {
return CBM_STORE_ERR;
}
cbm_store_t maintenance = {0};
int rc = sqlite3_open_v2(seal_path, &maintenance.db, SQLITE_OPEN_READWRITE, NULL);
if (rc != SQLITE_OK) {
int primary = rc & 0xff;
sqlite3_close(maintenance.db);
return primary == SQLITE_CORRUPT || primary == SQLITE_NOTADB || primary == SQLITE_FORMAT
? CBM_STORE_NOT_FOUND
: CBM_STORE_ERR;
}
(void)sqlite3_busy_timeout(maintenance.db, 10000);
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(maintenance.db, "PRAGMA quick_check(1);", CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
int primary = rc & 0xff;
sqlite3_close(maintenance.db);
return primary == SQLITE_BUSY || primary == SQLITE_LOCKED || primary == SQLITE_READONLY ||
primary == SQLITE_CANTOPEN || primary == SQLITE_IOERR
? CBM_STORE_ERR
: CBM_STORE_NOT_FOUND;
}
rc = sqlite3_step(stmt);
const char *result = rc == SQLITE_ROW ? (const char *)sqlite3_column_text(stmt, 0) : NULL;
bool structurally_valid = result && strcmp(result, "ok") == 0;
int finalize_rc = sqlite3_finalize(stmt);
if (!structurally_valid || finalize_rc != SQLITE_OK) {
int primary = rc & 0xff;
sqlite3_close(maintenance.db);
return primary == SQLITE_BUSY || primary == SQLITE_LOCKED || primary == SQLITE_READONLY ||
primary == SQLITE_CANTOPEN || primary == SQLITE_IOERR
? CBM_STORE_ERR
: CBM_STORE_NOT_FOUND;
}
/* A structurally valid SQLite file that is not recognizably a codebase
* graph is incompatible at this destination and must be preserved before
* replacement. Legacy graph schemas still pass this shallow identity
* check and can be sealed without running init_schema(). */
if (!cbm_store_check_integrity(&maintenance)) {
sqlite3_close(maintenance.db);
return CBM_STORE_NOT_FOUND;
}
int seal_rc = cbm_store_seal_for_atomic_publish(&maintenance);
sqlite3_close(maintenance.db);
return seal_rc;
}
/* ── Dump ───────────────────────────────────────────────────────── */
/* Dump entire in-memory database to a file via sqlite3_backup.
* Writes to a temp file first, then atomically renames for crash safety.
* sqlite3_backup_step(-1) copies ALL B-tree pages in one call —
* the file on disk is an exact replica of the in-memory page layout. */
int cbm_store_dump_to_file(cbm_store_t *s, const char *dest_path) {
if (!s || !dest_path) {
return CBM_STORE_ERR;
}
/* Ensure parent directory exists */
char dir[CBM_SZ_1K];
snprintf(dir, sizeof(dir), "%s", dest_path);
char *sl = strrchr(dir, '/');
if (sl) {
*sl = '\0';
(void)cbm_mkdir(dir);
}
/* Write to temp file for atomic swap */
char tmp_path[CBM_SZ_1K];
snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", dest_path);
(void)unlink(tmp_path);
char tmp_api[4096];
if (!cbm_path_for_file_api(tmp_path, tmp_api, sizeof(tmp_api))) {
store_set_error(s, "dump: cannot normalize temp file path");
return CBM_STORE_ERR;
}
sqlite3 *dest_db = NULL;
int rc = sqlite3_open_v2(tmp_api, &dest_db, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE, NULL);
if (rc != SQLITE_OK) {
store_set_error(s, "dump: cannot open temp file");
return CBM_STORE_ERR;
}
sqlite3_backup *bk = sqlite3_backup_init(dest_db, "main", s->db, "main");
if (!bk) {
store_set_error(s, "dump: backup init failed");
sqlite3_close(dest_db);
(void)unlink(tmp_path);
return CBM_STORE_ERR;
}
rc = sqlite3_backup_step(bk, CBM_NOT_FOUND); /* copy ALL pages in one shot */
sqlite3_backup_finish(bk);
if (rc != SQLITE_DONE) {
store_set_error(s, "dump: backup step failed");
sqlite3_close(dest_db);
(void)unlink(tmp_path);
return CBM_STORE_ERR;
}
/* Enable WAL on the dumped file so readers can connect concurrently */
sqlite3_exec(dest_db, "PRAGMA journal_mode = WAL;", NULL, NULL, NULL);
sqlite3_close(dest_db);
/* Remove the DESTINATION's leftover sidecars before installing: a
* stale WAL from a crashed session would be replayed on top of the
* fresh file at the next open — SQLite validates the WAL against its
* own header/checksums, not against the main file (#897). */
cbm_remove_db_sidecars(dest_path);
if (cbm_rename_replace(tmp_path, dest_path) != 0) {
store_set_error(s, "dump: rename failed");
(void)unlink(tmp_path);
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
/* ── Project CRUD ───────────────────────────────────────────────── */
int cbm_store_upsert_project(cbm_store_t *s, const char *name, const char *root_path) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_upsert_project,
"INSERT INTO projects (name, indexed_at, root_path) VALUES (?1, ?2, ?3) "
"ON CONFLICT(name) DO UPDATE SET indexed_at=?2, root_path=?3;");
if (!stmt) {
return CBM_STORE_ERR;
}
char ts[CBM_SZ_64];
iso_now(ts, sizeof(ts));
bind_text(stmt, SKIP_ONE, name);
bind_text(stmt, ST_COL_2, ts);
bind_text(stmt, ST_COL_3, root_path);
int rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "upsert_project");
return CBM_STORE_ERR;
}
/* Store generation for cursor staleness (pagination): db_uid is a random
* identity minted once per DB FILE (a full reindex publishes a fresh file
* via the writer, which carries no store_meta — the first upsert_project
* on the opened store seeds a NEW uid, so cursors minted against the old
* file can never validate against the rebuilt one, whose node ids all
* differ). mutation_gen increments on every project upsert — the choke
* point every index run (full, incremental, watcher) passes through.
* Created here rather than in the byte-level writer: adding a table to
* its hand-built sqlite_master is rootpage surgery for zero benefit. */
(void)sqlite3_exec(s->db,
"CREATE TABLE IF NOT EXISTS store_meta (k TEXT PRIMARY KEY, v TEXT);"
"INSERT OR IGNORE INTO store_meta VALUES"
"('db_uid', lower(hex(randomblob(8))));"
"INSERT OR IGNORE INTO store_meta VALUES('mutation_gen','0');"
"UPDATE store_meta SET v = CAST(CAST(v AS INTEGER)+1 AS TEXT) "
"WHERE k='mutation_gen';",
NULL, NULL, NULL);
return CBM_STORE_OK;
}
/* Opaque store generation for pagination cursors: "u<db_uid>g<mutation_gen>",
* or "legacy" when the DB predates store_meta (read-only opens never create
* it). A cursor whose embedded generation mismatches the store's current one
* is stale — the graph may have changed under it. */
int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz) {
if (!s || !s->db || !buf || bufsz == 0) {
return CBM_STORE_ERR;
}
snprintf(buf, bufsz, "legacy");
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT (SELECT v FROM store_meta WHERE k='db_uid'),"
" (SELECT v FROM store_meta WHERE k='mutation_gen');",
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
return CBM_STORE_OK; /* pre-migration DB: stable "legacy" generation */
}
if (sqlite3_step(stmt) == SQLITE_ROW) {
const char *uid = (const char *)sqlite3_column_text(stmt, 0);
const char *gen = (const char *)sqlite3_column_text(stmt, SKIP_ONE);
if (uid && gen) {
snprintf(buf, bufsz, "u%sg%s", uid, gen);
}
}
sqlite3_finalize(stmt);
return CBM_STORE_OK;
}
int cbm_store_get_project(cbm_store_t *s, const char *name, cbm_project_t *out) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_get_project,
"SELECT name, indexed_at, root_path FROM projects WHERE name = ?1;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, name);
int rc = sqlite3_step(stmt);
if (rc == SQLITE_ROW) {
out->name = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
out->indexed_at = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
out->root_path = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2));
/* Cached SELECT statements must not remain parked on SQLITE_ROW.
* Besides retaining a read transaction, an active reader prevents
* publication from switching a fully-written staging database out of
* WAL mode before the atomic rename. All result text is owned by the
* caller at this point, so release the reader immediately. */
sqlite3_reset(stmt);
return CBM_STORE_OK;
}
sqlite3_reset(stmt);
return CBM_STORE_NOT_FOUND;
}
int cbm_store_list_projects(cbm_store_t *s, cbm_project_t **out, int *count) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_list_projects,
"SELECT name, indexed_at, root_path FROM projects ORDER BY name;");
if (!stmt) {
return CBM_STORE_ERR;
}
/* Collect into dynamic array */
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_project_t *arr = malloc(cap * sizeof(cbm_project_t));
int scan_rc1;
while ((scan_rc1 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(cbm_project_t));
}
arr[n].name = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].indexed_at = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
arr[n].root_path = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2));
n++;
}
if (scan_rc1 != SQLITE_DONE) { /* SCANCHK:1:stmt */
store_set_error_sqlite(s, "row scan aborted");
cbm_store_free_projects(arr, n);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
*out = arr;
*count = n;
return CBM_STORE_OK;
}
int cbm_store_delete_project(cbm_store_t *s, const char *name) {
if (!s || !s->db || !name) {
return CBM_STORE_ERR;
}
if (exec_sql(s, "BEGIN;") != CBM_STORE_OK) {
return CBM_STORE_ERR;
}
static const char *cleanup_sql[] = {
"DELETE FROM index_coverage WHERE project = ?1;",
"DELETE FROM index_coverage_meta WHERE project = ?1;",
"DELETE FROM projects WHERE name = ?1 || '::missed';",
};
for (size_t i = 0; i < sizeof(cleanup_sql) / sizeof(cleanup_sql[0]); i++) {
sqlite3_stmt *cleanup = NULL;
if (sqlite3_prepare_v2(s->db, cleanup_sql[i], CBM_NOT_FOUND, &cleanup, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "delete project coverage prepare");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
bind_text(cleanup, SKIP_ONE, name);
int cleanup_rc = sqlite3_step(cleanup);
sqlite3_finalize(cleanup);
if (cleanup_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "delete project coverage");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
}
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_delete_project, "DELETE FROM projects WHERE name = ?1;");
if (!stmt) {
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, name);
int rc = sqlite3_step(stmt);
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "delete_project");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
return exec_sql(s, "COMMIT;");
}
/* ── Node CRUD ──────────────────────────────────────────────────── */
int64_t cbm_store_upsert_node(cbm_store_t *s, const cbm_node_t *n) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_upsert_node,
"INSERT INTO nodes (project, label, name, qualified_name, file_path, "
"start_line, end_line, properties) "
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) "
"ON CONFLICT(project, qualified_name) DO UPDATE SET "
"label=?2, name=?3, file_path=?5, start_line=?6, end_line=?7, properties=?8 "
"RETURNING id;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, safe_str(n->project));
bind_text(stmt, ST_COL_2, safe_str(n->label));
bind_text(stmt, ST_COL_3, safe_str(n->name));
bind_text(stmt, ST_COL_4, safe_str(n->qualified_name));
bind_text(stmt, ST_COL_5, safe_str(n->file_path));
sqlite3_bind_int(stmt, ST_COL_6, n->start_line);
sqlite3_bind_int(stmt, ST_COL_7, n->end_line);
bind_text(stmt, ST_COL_8, safe_props(n->properties_json));
int rc = sqlite3_step(stmt);
if (rc == SQLITE_ROW) {
int64_t id = sqlite3_column_int64(stmt, 0);
sqlite3_reset(stmt); /* unblock COMMIT — RETURNING leaves stmt active */
return id;
}
sqlite3_reset(stmt);
store_set_error_sqlite(s, "upsert_node");
return CBM_STORE_ERR;
}
/* Scan a node from current row of stmt. Heap-allocates strings. */
static void scan_node(sqlite3_stmt *stmt, cbm_node_t *n) {
n->id = sqlite3_column_int64(stmt, 0);
n->project = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
n->label = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2));
n->name = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_3));
n->qualified_name = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_4));
n->file_path = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_5));
n->start_line = sqlite3_column_int(stmt, CBM_SZ_6);
n->end_line = sqlite3_column_int(stmt, CBM_SZ_7);
n->properties_json = heap_strdup((const char *)sqlite3_column_text(stmt, ST_COL_8));
}
int cbm_store_find_node_by_id(cbm_store_t *s, int64_t id, cbm_node_t *out) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_find_node_by_id,
"SELECT id, project, label, name, qualified_name, file_path, "
"start_line, end_line, properties FROM nodes WHERE id = ?1;");
if (!stmt) {
return CBM_STORE_ERR;
}
sqlite3_bind_int64(stmt, SKIP_ONE, id);
int rc = sqlite3_step(stmt);
if (rc == SQLITE_ROW) {
scan_node(stmt, out);
sqlite3_reset(stmt);
return CBM_STORE_OK;
}
sqlite3_reset(stmt);
return CBM_STORE_NOT_FOUND;
}
int cbm_store_find_node_by_qn(cbm_store_t *s, const char *project, const char *qn,
cbm_node_t *out) {
if (!s || !s->db) {
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_find_node_by_qn,
"SELECT id, project, label, name, qualified_name, file_path, "
"start_line, end_line, properties FROM nodes "
"WHERE project = ?1 AND qualified_name = ?2;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, qn);
int rc = sqlite3_step(stmt);
if (rc == SQLITE_ROW) {
scan_node(stmt, out);
sqlite3_reset(stmt);
return CBM_STORE_OK;
}
sqlite3_reset(stmt);
return CBM_STORE_NOT_FOUND;
}
int cbm_store_find_node_by_qn_any(cbm_store_t *s, const char *qn, cbm_node_t *out) {
if (!s || !s->db) {
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_find_node_by_qn_any,
"SELECT id, project, label, name, qualified_name, file_path, "
"start_line, end_line, properties FROM nodes "
"WHERE qualified_name = ?1 LIMIT 1;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, qn);
int rc = sqlite3_step(stmt);
if (rc == SQLITE_ROW) {
scan_node(stmt, out);
sqlite3_reset(stmt);
return CBM_STORE_OK;
}
sqlite3_reset(stmt);
return CBM_STORE_NOT_FOUND;
}
int cbm_store_find_nodes_by_name_any(cbm_store_t *s, const char *name, cbm_node_t **out,
int *count) {
if (!s || !s->db) {
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_find_nodes_by_name_any,
"SELECT id, project, label, name, qualified_name, file_path, "
"start_line, end_line, properties FROM nodes "
"WHERE name = ?1;");
if (!stmt) {
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, name);
int cap = ST_INIT_CAP_16;
int n = 0;
cbm_node_t *arr = malloc(cap * sizeof(cbm_node_t));
int scan_rc2;
while ((scan_rc2 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(cbm_node_t));
}
scan_node(stmt, &arr[n]);
n++;
}
if (scan_rc2 != SQLITE_DONE) { /* SCANCHK:2:stmt */
store_set_error_sqlite(s, "row scan aborted");
cbm_store_free_nodes(arr, n);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
*out = arr;
*count = n;
return CBM_STORE_OK;
}
int cbm_store_find_node_ids_by_qns(cbm_store_t *s, const char *project, const char **qns,
int qn_count, int64_t *out_ids) {
if (!s || !project || !qns || !out_ids || qn_count <= 0) {
return 0;
}
/* Zero out results */
memset(out_ids, 0, (size_t)qn_count * sizeof(int64_t));
int found = 0;
cbm_node_t node = {0};
for (int i = 0; i < qn_count; i++) {
if (!qns[i]) {
continue;
}
int rc = cbm_store_find_node_by_qn(s, project, qns[i], &node);
if (rc == CBM_STORE_OK) {
out_ids[i] = node.id;
found++;
cbm_node_free_fields(&node);
memset(&node, 0, sizeof(node));
}
}
return found;
}
/* Generic: find multiple nodes by a single-column filter. */
static int find_nodes_generic(cbm_store_t *s, sqlite3_stmt **slot, const char *sql,
const char *project, const char *val, cbm_node_t **out, int *count) {
if (!s || !s->db) {
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt = prepare_cached(s, slot, sql);
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, val);
int cap = ST_INIT_CAP_16;
int n = 0;
cbm_node_t *arr = malloc(cap * sizeof(cbm_node_t));
int scan_rc3;
while ((scan_rc3 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(cbm_node_t));
}
scan_node(stmt, &arr[n]);
n++;
}
if (scan_rc3 != SQLITE_DONE) { /* SCANCHK:3:stmt */
store_set_error_sqlite(s, "row scan aborted");
cbm_store_free_nodes(arr, n);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
*out = arr;
*count = n;
return CBM_STORE_OK;
}
int cbm_store_find_nodes_by_name(cbm_store_t *s, const char *project, const char *name,
cbm_node_t **out, int *count) {
if (!s) {
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
return find_nodes_generic(s, &s->stmt_find_nodes_by_name,
"SELECT id, project, label, name, qualified_name, file_path, "
"start_line, end_line, properties FROM nodes "
"WHERE project = ?1 AND name = ?2;",
project, name, out, count);
}
int cbm_store_find_nodes_by_label(cbm_store_t *s, const char *project, const char *label,
cbm_node_t **out, int *count) {
if (!s) {
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
return find_nodes_generic(s, &s->stmt_find_nodes_by_label,
"SELECT id, project, label, name, qualified_name, file_path, "
"start_line, end_line, properties FROM nodes "
"WHERE project = ?1 AND label = ?2;",
project, label, out, count);
}
int cbm_store_find_nodes_by_file(cbm_store_t *s, const char *project, const char *file_path,
cbm_node_t **out, int *count) {
if (!s) {
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
return find_nodes_generic(s, &s->stmt_find_nodes_by_file,
"SELECT id, project, label, name, qualified_name, file_path, "
"start_line, end_line, properties FROM nodes "
"WHERE project = ?1 AND file_path = ?2;",
project, file_path, out, count);
}
int cbm_store_count_nodes(cbm_store_t *s, const char *project) {
if (!s || !s->db) {
return 0;
}
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_count_nodes, "SELECT COUNT(*) FROM nodes WHERE project = ?1;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
int count = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
count = sqlite3_column_int(stmt, 0);
}
sqlite3_reset(stmt);
return count;
}
int cbm_store_delete_nodes_by_project(cbm_store_t *s, const char *project) {
sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_nodes_by_project,
"DELETE FROM nodes WHERE project = ?1;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "delete_nodes_by_project");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_delete_nodes_by_file(cbm_store_t *s, const char *project, const char *file_path) {
sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_nodes_by_file,
"DELETE FROM nodes WHERE project = ?1 AND file_path = ?2;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, file_path);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "delete_nodes_by_file");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_delete_nodes_by_label(cbm_store_t *s, const char *project, const char *label) {
sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_nodes_by_label,
"DELETE FROM nodes WHERE project = ?1 AND label = ?2;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, label);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "delete_nodes_by_label");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
/* ── Node batch ─────────────────────────────────────────────────── */
int cbm_store_upsert_node_batch(cbm_store_t *s, const cbm_node_t *nodes, int count,
int64_t *out_ids) {
if (count == 0) {
return CBM_STORE_OK;
}
exec_sql(s, "BEGIN IMMEDIATE;");
for (int i = 0; i < count; i++) {
int64_t id = cbm_store_upsert_node(s, &nodes[i]);
if (id == CBM_STORE_ERR) {
exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
if (out_ids) {
out_ids[i] = id;
}
}
exec_sql(s, "COMMIT;");
return CBM_STORE_OK;
}
/* ── Edge CRUD ──────────────────────────────────────────────────── */
int64_t cbm_store_insert_edge(cbm_store_t *s, const cbm_edge_t *e) {
/* Conflict target includes local_name_gen (#768) so IMPORTS edges with
* different local_name coexist while re-inserting the same import still
* upserts. Must match the table's UNIQUE constraint in init_schema. */
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_insert_edge,
"INSERT INTO edges (project, source_id, target_id, type, properties) "
"VALUES (?1, ?2, ?3, ?4, ?5) "
"ON CONFLICT(source_id, target_id, type, local_name_gen) DO UPDATE SET "
"properties = json_patch(properties, ?5) "
"RETURNING id;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, safe_str(e->project));
sqlite3_bind_int64(stmt, PAIR_LEN, e->source_id);
sqlite3_bind_int64(stmt, CBM_SZ_3, e->target_id);
bind_text(stmt, ST_COL_4, safe_str(e->type));
bind_text(stmt, ST_COL_5, safe_props(e->properties_json));
int rc = sqlite3_step(stmt);
if (rc == SQLITE_ROW) {
int64_t id = sqlite3_column_int64(stmt, 0);
sqlite3_reset(stmt); /* unblock COMMIT — RETURNING leaves stmt active */
return id;
}
sqlite3_reset(stmt);
store_set_error_sqlite(s, "insert_edge");
return CBM_STORE_ERR;
}
/* Scan an edge from current row of stmt. */
static void scan_edge(sqlite3_stmt *stmt, cbm_edge_t *e) {
e->id = sqlite3_column_int64(stmt, 0);
e->project = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
e->source_id = sqlite3_column_int64(stmt, CBM_SZ_2);
e->target_id = sqlite3_column_int64(stmt, CBM_SZ_3);
e->type = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_4));
e->properties_json = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_5));
}
/* Generic: find multiple edges by a filter. */
static int find_edges_generic(cbm_store_t *s, sqlite3_stmt **slot, const char *sql,
void (*bind_fn)(sqlite3_stmt *, const void *), const void *bind_data,
cbm_edge_t **out, int *count) {
if (!s || !s->db) {
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt = prepare_cached(s, slot, sql);
if (!stmt) {
return CBM_STORE_ERR;
}
bind_fn(stmt, bind_data);
int cap = ST_INIT_CAP_16;
int n = 0;
cbm_edge_t *arr = malloc(cap * sizeof(cbm_edge_t));
int scan_rc4;
while ((scan_rc4 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(cbm_edge_t));
}
scan_edge(stmt, &arr[n]);
n++;
}
if (scan_rc4 != SQLITE_DONE) { /* SCANCHK:4:stmt */
store_set_error_sqlite(s, "row scan aborted");
cbm_store_free_edges(arr, n);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
*out = arr;
*count = n;
return CBM_STORE_OK;
}
/* Bind helpers for edge queries */
typedef struct {
int64_t id;
} bind_id_t;
typedef struct {
int64_t id;
const char *type;
} bind_id_type_t;
typedef struct {
const char *project;
const char *type;
} bind_proj_type_t;
static void bind_source_id(sqlite3_stmt *stmt, const void *data) {
const bind_id_t *b = data;
sqlite3_bind_int64(stmt, SKIP_ONE, b->id);
}
static void bind_id_and_type(sqlite3_stmt *stmt, const void *data) {
const bind_id_type_t *b = data;
sqlite3_bind_int64(stmt, SKIP_ONE, b->id);
bind_text(stmt, ST_COL_2, b->type);
}
static void bind_proj_and_type(sqlite3_stmt *stmt, const void *data) {
const bind_proj_type_t *b = data;
bind_text(stmt, SKIP_ONE, b->project);
bind_text(stmt, ST_COL_2, b->type);
}
int cbm_store_fetch_call_edges(cbm_store_t *s, const char *project, int max_edges,
int64_t **out_src, int64_t **out_tgt, int *count, bool *truncated) {
if (out_src) {
*out_src = NULL;
}
if (out_tgt) {
*out_tgt = NULL;
}
if (count) {
*count = 0;
}
if (truncated) {
*truncated = false;
}
if (!s || !s->db || !project || !out_src || !out_tgt || !count) {
return CBM_STORE_ERR;
}
/* CALLS edges whose BOTH endpoints are callable defs — the call graph the
* SCC pass condenses. Ordered for determinism. LIMIT max_edges + 1 so a
* full result is distinguishable from a truncated one. */
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT e.source_id, e.target_id FROM edges e "
"JOIN nodes ns ON ns.id = e.source_id "
"JOIN nodes nt ON nt.id = e.target_id "
"WHERE e.project = ?1 AND e.type = 'CALLS' "
" AND ns.label IN ('Function','Method') "
" AND nt.label IN ('Function','Method') "
"ORDER BY e.source_id, e.target_id LIMIT ?2;",
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "fetch_call_edges prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
sqlite3_bind_int(stmt, 2, max_edges > 0 ? max_edges + SKIP_ONE : CBM_NOT_FOUND);
int cap = ST_INIT_CAP_16;
int n = 0;
int64_t *src = malloc((size_t)cap * sizeof(int64_t));
int64_t *tgt = malloc((size_t)cap * sizeof(int64_t));
int scan_rc;
while ((scan_rc = sqlite3_step(stmt)) == SQLITE_ROW) {
if (max_edges > 0 && n >= max_edges) {
if (truncated) {
*truncated = true; /* the sentinel row proves there are more */
}
break;
}
if (n >= cap) {
cap *= ST_GROWTH;
src = safe_realloc(src, (size_t)cap * sizeof(int64_t));
tgt = safe_realloc(tgt, (size_t)cap * sizeof(int64_t));
}
src[n] = sqlite3_column_int64(stmt, 0);
tgt[n] = sqlite3_column_int64(stmt, 1);
n++;
}
sqlite3_finalize(stmt);
if (scan_rc != SQLITE_DONE && scan_rc != SQLITE_ROW) {
free(src);
free(tgt);
store_set_error_sqlite(s, "fetch_call_edges scan");
return CBM_STORE_ERR;
}
*out_src = src;
*out_tgt = tgt;
*count = n;
return CBM_STORE_OK;
}
int cbm_store_find_edges_by_source(cbm_store_t *s, int64_t source_id, cbm_edge_t **out,
int *count) {
bind_id_t b = {source_id};
return find_edges_generic(s, &s->stmt_find_edges_by_source,
"SELECT id, project, source_id, target_id, type, properties "
"FROM edges WHERE source_id = ?1;",
bind_source_id, &b, out, count);
}
int cbm_store_find_edges_by_target(cbm_store_t *s, int64_t target_id, cbm_edge_t **out,
int *count) {
bind_id_t b = {target_id};
return find_edges_generic(s, &s->stmt_find_edges_by_target,
"SELECT id, project, source_id, target_id, type, properties "
"FROM edges WHERE target_id = ?1;",
bind_source_id, &b, out, count);
}
int cbm_store_find_edges_by_source_type(cbm_store_t *s, int64_t source_id, const char *type,
cbm_edge_t **out, int *count) {
bind_id_type_t b = {source_id, type};
return find_edges_generic(s, &s->stmt_find_edges_by_source_type,
"SELECT id, project, source_id, target_id, type, properties "
"FROM edges WHERE source_id = ?1 AND type = ?2;",
bind_id_and_type, &b, out, count);
}
int cbm_store_find_edges_by_target_type(cbm_store_t *s, int64_t target_id, const char *type,
cbm_edge_t **out, int *count) {
bind_id_type_t b = {target_id, type};
return find_edges_generic(s, &s->stmt_find_edges_by_target_type,
"SELECT id, project, source_id, target_id, type, properties "
"FROM edges WHERE target_id = ?1 AND type = ?2;",
bind_id_and_type, &b, out, count);
}
int cbm_store_find_edges_by_type(cbm_store_t *s, const char *project, const char *type,
cbm_edge_t **out, int *count) {
bind_proj_type_t b = {project, type};
return find_edges_generic(s, &s->stmt_find_edges_by_type,
"SELECT id, project, source_id, target_id, type, properties "
"FROM edges WHERE project = ?1 AND type = ?2;",
bind_proj_and_type, &b, out, count);
}
int cbm_store_count_edges(cbm_store_t *s, const char *project) {
if (!s || !s->db) {
return 0;
}
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_count_edges, "SELECT COUNT(*) FROM edges WHERE project = ?1;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
int count = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
count = sqlite3_column_int(stmt, 0);
}
sqlite3_reset(stmt);
return count;
}
int cbm_store_count_edges_by_type(cbm_store_t *s, const char *project, const char *type) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_count_edges_by_type,
"SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = ?2;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, type);
int count = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
count = sqlite3_column_int(stmt, 0);
}
sqlite3_reset(stmt);
return count;
}
int cbm_store_delete_edges_by_project(cbm_store_t *s, const char *project) {
sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_edges_by_project,
"DELETE FROM edges WHERE project = ?1;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "delete_edges_by_project");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_delete_edges_by_type(cbm_store_t *s, const char *project, const char *type) {
sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_edges_by_type,
"DELETE FROM edges WHERE project = ?1 AND type = ?2;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, type);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "delete_edges_by_type");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
/* ── Edge batch ─────────────────────────────────────────────────── */
int cbm_store_insert_edge_batch(cbm_store_t *s, const cbm_edge_t *edges, int count) {
if (count == 0) {
return CBM_STORE_OK;
}
exec_sql(s, "BEGIN IMMEDIATE;");
for (int i = 0; i < count; i++) {
int64_t id = cbm_store_insert_edge(s, &edges[i]);
if (id == CBM_STORE_ERR) {
exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
}
exec_sql(s, "COMMIT;");
return CBM_STORE_OK;
}
/* ── File hash CRUD ─────────────────────────────────────────────── */
int cbm_store_upsert_file_hash(cbm_store_t *s, const char *project, const char *rel_path,
const char *sha256, int64_t mtime_ns, int64_t size) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_upsert_file_hash,
"INSERT INTO file_hashes (project, rel_path, sha256, mtime_ns, size) "
"VALUES (?1, ?2, ?3, ?4, ?5) "
"ON CONFLICT(project, rel_path) DO UPDATE SET "
"sha256=?3, mtime_ns=?4, size=?5;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, rel_path);
bind_text(stmt, ST_COL_3, sha256);
sqlite3_bind_int64(stmt, CBM_SZ_4, mtime_ns);
sqlite3_bind_int64(stmt, CBM_SZ_5, size);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "upsert_file_hash");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_get_file_hashes(cbm_store_t *s, const char *project, cbm_file_hash_t **out,
int *count) {
sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_get_file_hashes,
"SELECT project, rel_path, sha256, mtime_ns, size "
"FROM file_hashes WHERE project = ?1;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
int cap = ST_INIT_CAP_16;
int n = 0;
cbm_file_hash_t *arr = malloc(cap * sizeof(cbm_file_hash_t));
int scan_rc5;
while ((scan_rc5 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(cbm_file_hash_t));
}
arr[n].project = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].rel_path = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
arr[n].sha256 = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2));
arr[n].mtime_ns = sqlite3_column_int64(stmt, CBM_SZ_3);
arr[n].size = sqlite3_column_int64(stmt, CBM_SZ_4);
n++;
}
if (scan_rc5 != SQLITE_DONE) { /* SCANCHK:5:stmt */
store_set_error_sqlite(s, "row scan aborted");
cbm_store_free_file_hashes(arr, n);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
*out = arr;
*count = n;
return CBM_STORE_OK;
}
void cbm_store_clear_file_hash(cbm_file_hash_t *hash) {
if (!hash) {
return;
}
free((char *)hash->project);
free((char *)hash->rel_path);
free((char *)hash->sha256);
memset(hash, 0, sizeof(*hash));
}
int cbm_store_get_file_hash(cbm_store_t *s, const char *project, const char *rel_path,
cbm_file_hash_t *out) {
if (!out) {
return CBM_STORE_ERR;
}
memset(out, 0, sizeof(*out));
if (!s || !s->db || !project || !rel_path) {
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT project, rel_path, sha256, mtime_ns, size "
"FROM file_hashes WHERE project = ?1 AND rel_path = ?2;",
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "file hash get exact prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, rel_path);
int rc = sqlite3_step(stmt);
if (rc == SQLITE_ROW) {
out->project = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
out->rel_path = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
out->sha256 = heap_strdup((const char *)sqlite3_column_text(stmt, ST_COL_2));
out->mtime_ns = sqlite3_column_int64(stmt, ST_COL_3);
out->size = sqlite3_column_int64(stmt, CBM_SZ_4);
sqlite3_finalize(stmt);
if (!out->project || !out->rel_path || !out->sha256) {
cbm_store_clear_file_hash(out);
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "file hash get exact");
return CBM_STORE_ERR;
}
return CBM_STORE_NOT_FOUND;
}
int cbm_store_delete_file_hash(cbm_store_t *s, const char *project, const char *rel_path) {
sqlite3_stmt *stmt =
prepare_cached(s, &s->stmt_delete_file_hash,
"DELETE FROM file_hashes WHERE project = ?1 AND rel_path = ?2;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, rel_path);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "delete_file_hash");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_delete_file_hashes(cbm_store_t *s, const char *project) {
sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_delete_file_hashes,
"DELETE FROM file_hashes WHERE project = ?1;");
if (!stmt) {
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
if (sqlite3_step(stmt) != SQLITE_DONE) {
store_set_error_sqlite(s, "delete_file_hashes");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
/* ── Index coverage (#963) ──────────────────────────────────────── */
void cbm_store_coverage_shadow_project(char *dst, size_t dstsz, const char *project) {
snprintf(dst, dstsz, "%s::missed", project);
}
/* Minimal JSON string escape (quotes, backslashes, control chars → space). */
static void cov_json_escape(char *dst, size_t dstsz, const char *src) {
size_t o = 0;
for (const char *p = src; *p && o + 2 < dstsz; p++) {
unsigned char c = (unsigned char)*p;
if (c == '"' || c == '\\') {
dst[o++] = '\\';
dst[o++] = (char)c;
} else if (c < 0x20) {
dst[o++] = ' ';
} else {
dst[o++] = (char)c;
}
}
dst[o] = '\0';
}
/* Rebuild the derived miss-GRAPH view under the shadow project
* "<project>::missed": ONLY the not-fully-indexed files, laid out as the
* file structure (Project → Folder chain → File), each File carrying
* {kind, detail}. Queryable via query_graph(graph="missed") with zero
* cypher-engine changes; the real project's graph gains no rows. Derived
* data — rebuilt from the authoritative (post-prune) table contents. */
/* Fingerprint of the FAILURE rows the shadow graph derives from. When the
* set is unchanged since the last rebuild, the wipe-and-rebuild (tens of
* thousands of node/edge upserts probing the full-size nodes index -- a
* measured 23s at kernel scale) is provably a no-op and is skipped. The
* fingerprint lives in store_meta keyed per project and is cleared
* implicitly by any rebuild from a different row set. */
static void cov_failure_fingerprint(const cbm_coverage_row_t *rows, int count,
char out[CBM_SHA256_HEX_LEN + 1]) {
cbm_sha256_ctx sha;
cbm_sha256_init(&sha);
for (int i = 0; i < count; i++) {
if (!rows[i].kind || strncmp(rows[i].kind, "not_indexed", 11) == 0) {
continue;
}
const char *rel = rows[i].rel_path ? rows[i].rel_path : "";
const char *kind = rows[i].kind;
const char *detail = rows[i].detail ? rows[i].detail : "";
cbm_sha256_update(&sha, rel, strlen(rel) + 1);
cbm_sha256_update(&sha, kind, strlen(kind) + 1);
cbm_sha256_update(&sha, detail, strlen(detail) + 1);
}
uint8_t digest[CBM_SHA256_DIGEST_LEN];
cbm_sha256_final(&sha, digest);
static const char hex[] = "0123456789abcdef";
for (int i = 0; i < CBM_SHA256_DIGEST_LEN; i++) {
out[i * 2] = hex[digest[i] >> 4];
out[i * 2 + 1] = hex[digest[i] & 0x0f];
}
out[CBM_SHA256_HEX_LEN] = 0;
}
static int cov_rebuild_shadow_graph(cbm_store_t *s, const char *project) {
char covproj[CBM_SZ_512];
cbm_store_coverage_shadow_project(covproj, sizeof(covproj), project);
/* Fingerprint gate: rows first, so an unchanged failure set skips the
* wipe entirely and the existing shadow stays byte-identical. */
cbm_coverage_row_t *fp_rows = NULL;
int fp_count = 0;
if (cbm_store_coverage_get(s, project, &fp_rows, &fp_count) != CBM_STORE_OK) {
cbm_store_free_coverage(fp_rows, fp_count);
return CBM_STORE_ERR;
}
char fp[CBM_SHA256_HEX_LEN + 1];
cov_failure_fingerprint(fp_rows, fp_count, fp);
cbm_store_free_coverage(fp_rows, fp_count);
char fp_key[CBM_SZ_512];
snprintf(fp_key, sizeof(fp_key), "coverage_shadow_fp:%s", project);
char prev_fp[CBM_SHA256_HEX_LEN + 1] = {0};
{
sqlite3_stmt *get = NULL;
if (sqlite3_prepare_v2(s->db, "SELECT v FROM store_meta WHERE k = ?1;", CBM_NOT_FOUND, &get,
NULL) == SQLITE_OK) {
bind_text(get, SKIP_ONE, fp_key);
if (sqlite3_step(get) == SQLITE_ROW) {
const char *v = (const char *)sqlite3_column_text(get, 0);
if (v) {
snprintf(prev_fp, sizeof(prev_fp), "%s", v);
}
}
sqlite3_finalize(get);
}
}
if (prev_fp[0] && strcmp(prev_fp, fp) == 0) {
return CBM_STORE_OK;
}
/* Wipe the previous view (edges first: no FK pragma guarantee). */
static const char *wipes[] = {"DELETE FROM edges WHERE project = ?1;",
"DELETE FROM nodes WHERE project = ?1;"};
for (int w = 0; w < 2; w++) {
sqlite3_stmt *del = NULL;
if (sqlite3_prepare_v2(s->db, wipes[w], CBM_NOT_FOUND, &del, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage shadow wipe prepare");
return CBM_STORE_ERR;
}
bind_text(del, SKIP_ONE, covproj);
int wipe_rc = sqlite3_step(del);
sqlite3_finalize(del);
if (wipe_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage shadow wipe");
return CBM_STORE_ERR;
}
}
cbm_coverage_row_t *rows = NULL;
int count = 0;
if (cbm_store_coverage_get(s, project, &rows, &count) != CBM_STORE_OK) {
cbm_store_free_coverage(rows, count);
return CBM_STORE_ERR;
}
if (count == 0) {
cbm_store_free_coverage(rows, count);
return CBM_STORE_OK;
}
/* Only FAILURE rows materialize in the miss graph; a project whose only
* coverage rows are by-design not_indexed_* entries gets NO graph (not
* even a bare root node). */
int failure_count = 0;
for (int i = 0; i < count; i++) {
if (!rows[i].kind || strncmp(rows[i].kind, "not_indexed", 11) != 0) {
failure_count++;
}
}
if (failure_count == 0) {
cbm_store_free_coverage(rows, count);
return CBM_STORE_OK;
}
/* nodes.project has an FK to projects(name) (enforced: foreign_keys=ON),
* so the shadow project needs its row. Invisible to list_projects, which
* scans the cache directory for .db files, not this table. */
if (cbm_store_upsert_project(s, covproj, "") != CBM_STORE_OK) {
cbm_store_free_coverage(rows, count);
return CBM_STORE_ERR;
}
cbm_node_t root = {.project = covproj,
.label = "Project",
.name = project,
.qualified_name = covproj,
.properties_json = "{}"};
int64_t root_id = cbm_store_upsert_node(s, &root);
if (root_id <= 0) {
cbm_store_free_coverage(rows, count);
return CBM_STORE_ERR;
}
for (int i = 0; i < count; i++) {
const char *rel = rows[i].rel_path;
if (!rel || !rel[0]) {
continue;
}
/* The missed graph shows FAILURES ("we did not manage") only —
* by-design not_indexed_* rows stay out of it, so the UI's
* report-an-edge-case callout never fires for gitignored paths. */
if (rows[i].kind && strncmp(rows[i].kind, "not_indexed", 11) == 0) {
continue;
}
char pathbuf[CBM_SZ_1K];
snprintf(pathbuf, sizeof(pathbuf), "%s", rel);
int64_t parent = root_id;
for (char *p = pathbuf; *p; p++) {
if (*p != '/') {
continue;
}
/* Truncate at this slash → pathbuf is the directory prefix; the
* upsert binds copies, so restore the slash right after. */
*p = '\0';
const char *seg = strrchr(pathbuf, '/');
cbm_node_t folder = {.project = covproj,
.label = "Folder",
.name = seg ? seg + 1 : pathbuf,
.qualified_name = pathbuf,
.file_path = pathbuf,
.properties_json = "{}"};
int64_t fid = cbm_store_upsert_node(s, &folder);
*p = '/';
if (fid <= 0) {
cbm_store_free_coverage(rows, count);
return CBM_STORE_ERR;
}
cbm_edge_t e = {.project = covproj,
.source_id = parent,
.target_id = fid,
.type = "CONTAINS_FOLDER",
.properties_json = "{}"};
if (cbm_store_insert_edge(s, &e) <= 0) {
cbm_store_free_coverage(rows, count);
return CBM_STORE_ERR;
}
parent = fid;
}
const char *base = strrchr(rel, '/');
char props[CBM_SZ_2K];
char detail_esc[CBM_SZ_1K];
cov_json_escape(detail_esc, sizeof(detail_esc), rows[i].detail ? rows[i].detail : "");
snprintf(props, sizeof(props), "{\"kind\":\"%s\",\"detail\":\"%s\"}",
rows[i].kind ? rows[i].kind : "", detail_esc);
cbm_node_t file = {.project = covproj,
.label = "File",
.name = base ? base + 1 : rel,
.qualified_name = rel,
.file_path = rel,
.properties_json = props};
int64_t file_id = cbm_store_upsert_node(s, &file);
if (file_id <= 0) {
cbm_store_free_coverage(rows, count);
return CBM_STORE_ERR;
}
cbm_edge_t e = {.project = covproj,
.source_id = parent,
.target_id = file_id,
.type = "CONTAINS_FILE",
.properties_json = "{}"};
if (cbm_store_insert_edge(s, &e) <= 0) {
cbm_store_free_coverage(rows, count);
return CBM_STORE_ERR;
}
}
cbm_store_free_coverage(rows, count);
{
sqlite3_stmt *set = NULL;
if (sqlite3_prepare_v2(s->db, "INSERT OR REPLACE INTO store_meta (k, v) VALUES (?1, ?2);",
CBM_NOT_FOUND, &set, NULL) == SQLITE_OK) {
bind_text(set, SKIP_ONE, fp_key);
bind_text(set, ST_COL_2, fp);
(void)sqlite3_step(set);
sqlite3_finalize(set);
}
}
return CBM_STORE_OK;
}
int cbm_store_coverage_replace_ex(cbm_store_t *s, const char *project,
const cbm_coverage_row_t *rows, int count,
const cbm_coverage_meta_t *meta) {
if (!s || !s->db || !project || count < 0 || (count > 0 && !rows)) {
return CBM_STORE_ERR;
}
if (exec_sql(s, "BEGIN;") != CBM_STORE_OK) {
return CBM_STORE_ERR;
}
sqlite3_stmt *del = NULL;
if (sqlite3_prepare_v2(s->db, "DELETE FROM index_coverage WHERE project = ?1;", CBM_NOT_FOUND,
&del, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage delete prepare");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
bind_text(del, SKIP_ONE, project);
int rc = sqlite3_step(del);
sqlite3_finalize(del);
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage delete");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
sqlite3_stmt *ins = NULL;
if (sqlite3_prepare_v2(
s->db,
"INSERT OR REPLACE INTO index_coverage (project, rel_path, kind, detail) "
"VALUES (?1, ?2, ?3, ?4);",
CBM_NOT_FOUND, &ins, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage insert prepare");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
for (int i = 0; i < count; i++) {
if (!rows[i].rel_path || !rows[i].kind) {
continue;
}
bind_text(ins, SKIP_ONE, project);
bind_text(ins, ST_COL_2, rows[i].rel_path);
bind_text(ins, ST_COL_3, rows[i].kind);
bind_text(ins, CBM_SZ_4, rows[i].detail ? rows[i].detail : "");
if (sqlite3_step(ins) != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage insert");
sqlite3_finalize(ins);
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
sqlite3_reset(ins);
}
sqlite3_finalize(ins);
/* Prune FAILURE rows for files no longer known to the index (deleted from
* the repo): file_hashes is the authoritative live-file set after
* persist. By-design not_indexed_* rows are exempt — deliberately
* unindexed paths never have hash rows (discovery rewrites them fresh
* every run instead). */
sqlite3_stmt *prune = NULL;
if (sqlite3_prepare_v2(s->db,
"DELETE FROM index_coverage WHERE project = ?1 "
"AND kind NOT LIKE 'not_indexed%' AND rel_path NOT IN "
"(SELECT rel_path FROM file_hashes WHERE project = ?1);",
CBM_NOT_FOUND, &prune, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage prune prepare");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
bind_text(prune, SKIP_ONE, project);
int prune_rc = sqlite3_step(prune);
sqlite3_finalize(prune);
if (prune_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage prune");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
if (meta) {
char recorded_at[CBM_SZ_64];
if (meta->recorded_at && meta->recorded_at[0]) {
snprintf(recorded_at, sizeof(recorded_at), "%s", meta->recorded_at);
} else {
iso_now(recorded_at, sizeof(recorded_at));
}
const char *generation =
meta->generation && meta->generation[0] ? meta->generation : recorded_at;
const char *index_mode =
meta->index_mode && meta->index_mode[0] ? meta->index_mode : "unknown";
const char *recording_status = meta->recording_status && meta->recording_status[0]
? meta->recording_status
: "unavailable";
int ignored_stored = meta->ignored_files_stored > 0 ? meta->ignored_files_stored : 0;
int ignored_total = meta->ignored_files_total > 0 ? meta->ignored_files_total : 0;
int coverage_version = meta->coverage_version > 0 ? meta->coverage_version : 1;
sqlite3_stmt *up_meta = NULL;
if (sqlite3_prepare_v2(
s->db,
"INSERT INTO index_coverage_meta "
"(project, generation, index_mode, recorded_at, recording_status, "
" ignored_files_stored, ignored_files_total, coverage_version, "
" hash_records_complete) "
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) "
"ON CONFLICT(project) DO UPDATE SET generation=?2, index_mode=?3, "
"recorded_at=?4, recording_status=?5, ignored_files_stored=?6, "
"ignored_files_total=?7, coverage_version=?8, hash_records_complete=?9;",
CBM_NOT_FOUND, &up_meta, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage meta upsert prepare");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
bind_text(up_meta, SKIP_ONE, project);
bind_text(up_meta, ST_COL_2, generation);
bind_text(up_meta, ST_COL_3, index_mode);
bind_text(up_meta, CBM_SZ_4, recorded_at);
bind_text(up_meta, CBM_SZ_5, recording_status);
sqlite3_bind_int(up_meta, 6, ignored_stored);
sqlite3_bind_int(up_meta, 7, ignored_total);
sqlite3_bind_int(up_meta, 8, coverage_version);
sqlite3_bind_int(up_meta, 9, meta->hash_records_complete ? 1 : 0);
int meta_rc = sqlite3_step(up_meta);
sqlite3_finalize(up_meta);
if (meta_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage meta upsert");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
} else {
sqlite3_stmt *del_meta = NULL;
if (sqlite3_prepare_v2(s->db, "DELETE FROM index_coverage_meta WHERE project = ?1;",
CBM_NOT_FOUND, &del_meta, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage meta delete prepare");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
bind_text(del_meta, SKIP_ONE, project);
int meta_rc = sqlite3_step(del_meta);
sqlite3_finalize(del_meta);
if (meta_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage meta delete");
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
}
/* Rebuild the derived miss-graph view from the now-authoritative table
* contents (same transaction — the table and its view stay in step). */
if (cov_rebuild_shadow_graph(s, project) != CBM_STORE_OK) {
(void)exec_sql(s, "ROLLBACK;");
return CBM_STORE_ERR;
}
return exec_sql(s, "COMMIT;");
}
int cbm_store_coverage_replace(cbm_store_t *s, const char *project, const cbm_coverage_row_t *rows,
int count) {
return cbm_store_coverage_replace_ex(s, project, rows, count, NULL);
}
static int coverage_query_rows(cbm_store_t *s, const char *project, const char *selector,
const char *sql, cbm_coverage_row_t **out, int *count) {
if (!out || !count) {
return CBM_STORE_ERR;
}
*out = NULL;
*count = 0;
if (!s || !s->db || !project || !selector || !sql) {
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage targeted prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, selector);
int cap = ST_INIT_CAP_16;
int n = 0;
cbm_coverage_row_t *arr = malloc((size_t)cap * sizeof(*arr));
if (!arr) {
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
int scan_rc;
while ((scan_rc = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, (size_t)cap * sizeof(*arr));
}
memset(&arr[n], 0, sizeof(arr[n]));
arr[n].rel_path = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].kind = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
arr[n].detail = heap_strdup((const char *)sqlite3_column_text(stmt, ST_COL_2));
if (!arr[n].rel_path || !arr[n].kind || !arr[n].detail) {
sqlite3_finalize(stmt);
cbm_store_free_coverage(arr, n + 1);
return CBM_STORE_ERR;
}
n++;
}
sqlite3_finalize(stmt);
if (scan_rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage targeted scan");
cbm_store_free_coverage(arr, n);
return CBM_STORE_ERR;
}
*out = arr;
*count = n;
return CBM_STORE_OK;
}
int cbm_store_coverage_get_path(cbm_store_t *s, const char *project, const char *rel_path,
cbm_coverage_row_t **out, int *count) {
static const char sql[] = "SELECT rel_path, kind, detail FROM index_coverage "
"WHERE project = ?1 AND (rel_path = ?2 OR "
" (kind = 'not_indexed_dir' AND length(rel_path) < length(?2) "
" AND substr(?2, 1, length(rel_path)) = rel_path "
" AND substr(?2, length(rel_path) + 1, 1) = '/')) "
"ORDER BY length(rel_path) DESC, rel_path, kind;";
return coverage_query_rows(s, project, rel_path, sql, out, count);
}
int cbm_store_coverage_get_scope(cbm_store_t *s, const char *project, const char *scope,
cbm_coverage_row_t **out, int *count) {
static const char sql[] = "SELECT rel_path, kind, detail FROM index_coverage "
"WHERE project = ?1 AND (length(?2) = 0 OR rel_path = ?2 OR "
" (length(rel_path) > length(?2) "
" AND substr(rel_path, 1, length(?2)) = ?2 "
" AND substr(rel_path, length(?2) + 1, 1) = '/') OR "
" (kind = 'not_indexed_dir' AND length(rel_path) < length(?2) "
" AND substr(?2, 1, length(rel_path)) = rel_path "
" AND substr(?2, length(rel_path) + 1, 1) = '/')) "
"ORDER BY rel_path, kind;";
return coverage_query_rows(s, project, scope, sql, out, count);
}
void cbm_store_coverage_meta_clear(cbm_coverage_meta_t *meta) {
if (!meta) {
return;
}
free((char *)meta->project);
free((char *)meta->generation);
free((char *)meta->index_mode);
free((char *)meta->recorded_at);
free((char *)meta->recording_status);
memset(meta, 0, sizeof(*meta));
}
int cbm_store_coverage_meta_get(cbm_store_t *s, const char *project, cbm_coverage_meta_t *out) {
if (!out) {
return CBM_STORE_ERR;
}
memset(out, 0, sizeof(*out));
if (!s || !s->db || !project) {
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT project, generation, index_mode, recorded_at, recording_status, "
"ignored_files_stored, ignored_files_total, coverage_version, "
"hash_records_complete FROM index_coverage_meta WHERE project = ?1;",
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage meta get prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
int rc = sqlite3_step(stmt);
if (rc == SQLITE_ROW) {
out->project = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
out->generation = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
out->index_mode = heap_strdup((const char *)sqlite3_column_text(stmt, ST_COL_2));
out->recorded_at = heap_strdup((const char *)sqlite3_column_text(stmt, ST_COL_3));
out->recording_status = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_4));
out->ignored_files_stored = sqlite3_column_int(stmt, CBM_SZ_5);
out->ignored_files_total = sqlite3_column_int(stmt, 6);
out->coverage_version = sqlite3_column_int(stmt, 7);
out->hash_records_complete = sqlite3_column_int(stmt, 8) != 0;
sqlite3_finalize(stmt);
if (!out->project || !out->generation || !out->index_mode || !out->recorded_at ||
!out->recording_status) {
cbm_store_coverage_meta_clear(out);
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "coverage meta get");
return CBM_STORE_ERR;
}
return CBM_STORE_NOT_FOUND;
}
int cbm_store_coverage_get(cbm_store_t *s, const char *project, cbm_coverage_row_t **out,
int *count) {
*out = NULL;
*count = 0;
if (!s || !s->db || !project) {
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db,
"SELECT rel_path, kind, detail FROM index_coverage "
"WHERE project = ?1 ORDER BY rel_path, kind;",
CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "coverage get prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
int cap = ST_INIT_CAP_16;
int n = 0;
cbm_coverage_row_t *arr = malloc(cap * sizeof(cbm_coverage_row_t));
int scan_rc6;
while ((scan_rc6 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(cbm_coverage_row_t));
}
arr[n].rel_path = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].kind = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
arr[n].detail = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2));
n++;
}
if (scan_rc6 != SQLITE_DONE) { /* SCANCHK:6:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
cbm_store_free_coverage(arr, n);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
*out = arr;
*count = n;
return CBM_STORE_OK;
}
void cbm_store_free_coverage(cbm_coverage_row_t *rows, int count) {
if (!rows) {
return;
}
for (int i = 0; i < count; i++) {
free((char *)rows[i].rel_path);
free((char *)rows[i].kind);
free((char *)rows[i].detail);
}
free(rows);
}
/* ── FindNodesByFileOverlap ─────────────────────────────────────── */
int cbm_store_find_nodes_by_file_overlap(cbm_store_t *s, const char *project, const char *file_path,
int start_line, int end_line, cbm_node_t **out,
int *count) {
*out = NULL;
*count = 0;
const char *sql = "SELECT id, project, label, name, qualified_name, file_path, "
"start_line, end_line, properties FROM nodes "
"WHERE project = ?1 AND file_path = ?2 "
"AND label NOT IN ('Module', 'Package', 'File', 'Folder') "
"AND start_line <= ?4 AND end_line >= ?3 "
"ORDER BY start_line";
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "overlap prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, file_path);
sqlite3_bind_int(stmt, ST_COL_3, start_line);
sqlite3_bind_int(stmt, ST_COL_4, end_line);
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_node_t *nodes = malloc(cap * sizeof(cbm_node_t));
int scan_rc7;
while ((scan_rc7 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
nodes = safe_realloc(nodes, cap * sizeof(cbm_node_t));
}
memset(&nodes[n], 0, sizeof(cbm_node_t));
scan_node(stmt, &nodes[n]);
n++;
}
if (scan_rc7 != SQLITE_DONE) { /* SCANCHK:7:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
cbm_store_free_nodes(nodes, n);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
*out = nodes;
*count = n;
return CBM_STORE_OK;
}
/* ── FindNodesByQNSuffix ───────────────────────────────────────── */
int cbm_store_find_nodes_by_qn_suffix(cbm_store_t *s, const char *project, const char *suffix,
cbm_node_t **out, int *count) {
*out = NULL;
*count = 0;
if (!s || !s->db) {
return CBM_STORE_ERR;
}
/* Match QNs ending with ".suffix" or exactly equal to suffix */
char like_pattern[CBM_SZ_512];
snprintf(like_pattern, sizeof(like_pattern), "%%.%s", suffix);
const char *sql_with_project =
"SELECT id, project, label, name, qualified_name, file_path, "
"start_line, end_line, properties FROM nodes "
"WHERE project = ?1 AND (qualified_name LIKE ?2 OR qualified_name = ?3)";
const char *sql_any = "SELECT id, project, label, name, qualified_name, file_path, "
"start_line, end_line, properties FROM nodes "
"WHERE (qualified_name LIKE ?1 OR qualified_name = ?2)";
sqlite3_stmt *stmt = NULL;
int rc =
sqlite3_prepare_v2(s->db, project ? sql_with_project : sql_any, CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "qn_suffix prepare");
return CBM_STORE_ERR;
}
if (project) {
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, like_pattern);
bind_text(stmt, ST_COL_3, suffix);
} else {
bind_text(stmt, SKIP_ONE, like_pattern);
bind_text(stmt, ST_COL_2, suffix);
}
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_node_t *nodes = malloc(cap * sizeof(cbm_node_t));
int scan_rc8;
while ((scan_rc8 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
nodes = safe_realloc(nodes, cap * sizeof(cbm_node_t));
}
memset(&nodes[n], 0, sizeof(cbm_node_t));
scan_node(stmt, &nodes[n]);
n++;
}
if (scan_rc8 != SQLITE_DONE) { /* SCANCHK:8:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
cbm_store_free_nodes(nodes, n);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
*out = nodes;
*count = n;
return CBM_STORE_OK;
}
/* ── NodeDegree ────────────────────────────────────────────────── */
void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *out_deg) {
if (!s) {
if (in_deg)
*in_deg = 0;
if (out_deg)
*out_deg = 0;
return;
}
*in_deg = 0;
*out_deg = 0;
const char *in_sql = "SELECT COUNT(*) FROM edges WHERE target_id = ?1 AND type = 'CALLS'";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, in_sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) {
sqlite3_bind_int64(stmt, SKIP_ONE, node_id);
if (sqlite3_step(stmt) == SQLITE_ROW) {
*in_deg = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
const char *out_sql = "SELECT COUNT(*) FROM edges WHERE source_id = ?1 AND type = 'CALLS'";
if (sqlite3_prepare_v2(s->db, out_sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK) {
sqlite3_bind_int64(stmt, SKIP_ONE, node_id);
if (sqlite3_step(stmt) == SQLITE_ROW) {
*out_deg = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
}
}
/* ── List distinct file paths ────────────────────────────────── */
int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *count) {
*out = NULL;
*count = 0;
if (!s || !s->db || !project) {
return CBM_STORE_ERR;
}
const char *sql = "SELECT DISTINCT file_path FROM nodes "
"WHERE project = ?1 AND file_path IS NOT NULL AND file_path != ''";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
return CBM_STORE_ERR;
}
sqlite3_bind_text(stmt, SKIP_ONE, project, CBM_NOT_FOUND, SQLITE_STATIC);
int cap = CBM_SZ_64;
int n = 0;
char **files = malloc(cap * sizeof(char *));
int scan_rc9;
while ((scan_rc9 = sqlite3_step(stmt)) == SQLITE_ROW) {
const char *fp = (const char *)sqlite3_column_text(stmt, 0);
if (!fp) {
continue;
}
if (n >= cap) {
cap *= ST_GROWTH;
files = safe_realloc(files, cap * sizeof(char *));
}
files[n++] = heap_strdup(fp);
}
if (scan_rc9 != SQLITE_DONE) { /* SCANCHK:9:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
for (int fi = 0; fi < n; fi++) {
free(files[fi]);
}
free(files);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
*out = files;
*count = n;
return CBM_STORE_OK;
}
/* ── Node neighbor names ──────────────────────────────────────── */
static int query_neighbor_names(sqlite3 *db, const char *sql, int64_t node_id, int limit,
char ***out, int *out_count) {
*out = NULL;
*out_count = 0;
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
return CBM_NOT_FOUND;
}
sqlite3_bind_int64(stmt, SKIP_ONE, node_id);
sqlite3_bind_int(stmt, ST_COL_2, limit);
int cap = ST_INIT_CAP_8;
char **names = malloc((size_t)cap * sizeof(char *));
int count = 0;
int scan_rc10;
while ((scan_rc10 = sqlite3_step(stmt)) == SQLITE_ROW) {
const char *name = (const char *)sqlite3_column_text(stmt, 0);
if (!name) {
continue;
}
if (count >= cap) {
cap *= ST_GROWTH;
names = safe_realloc(names, (size_t)cap * sizeof(char *));
}
names[count++] = strdup(name);
}
if (scan_rc10 != SQLITE_DONE) { /* SCANCHK:10:stmt */
/* No store handle here; neighbor names are include_connected
* enrichment — fail the call, primary surfaces report loudly. */
sqlite3_finalize(stmt);
for (int ni = 0; ni < count; ni++) {
free(names[ni]);
}
free(names);
*out = NULL;
*out_count = 0;
return CBM_NOT_FOUND;
}
sqlite3_finalize(stmt);
*out = names;
*out_count = count;
return 0;
}
int cbm_store_node_neighbor_names(cbm_store_t *s, int64_t node_id, int limit, char ***out_callers,
int *caller_count, char ***out_callees, int *callee_count) {
if (!s) {
return CBM_NOT_FOUND;
}
*out_callers = NULL;
*caller_count = 0;
*out_callees = NULL;
*callee_count = 0;
query_neighbor_names(
s->db,
"SELECT DISTINCT n.name FROM edges e JOIN nodes n ON e.source_id = n.id "
"WHERE e.target_id = ?1 AND e.type IN ('CALLS','HTTP_CALLS','ASYNC_CALLS') "
"ORDER BY n.name LIMIT ?2",
node_id, limit, out_callers, caller_count);
query_neighbor_names(
s->db,
"SELECT DISTINCT n.name FROM edges e JOIN nodes n ON e.target_id = n.id "
"WHERE e.source_id = ?1 AND e.type IN ('CALLS','HTTP_CALLS','ASYNC_CALLS') "
"ORDER BY n.name LIMIT ?2",
node_id, limit, out_callees, callee_count);
return 0;
}
static int count_degrees_direction(cbm_store_t *s, const int64_t *node_ids, int id_count,
const char *in_clause, bool has_type, const char *edge_type,
bool inbound, int *out_counts) {
char sql[ST_SQL_BUF];
const char *id_col = inbound ? "target_id" : "source_id";
if (has_type) {
snprintf(sql, sizeof(sql),
"SELECT %s, COUNT(*) FROM edges "
"WHERE %s IN (%s) AND type = ? GROUP BY %s",
id_col, id_col, in_clause, id_col);
} else {
snprintf(sql, sizeof(sql),
"SELECT %s, COUNT(*) FROM edges "
"WHERE %s IN (%s) GROUP BY %s",
id_col, id_col, in_clause, id_col);
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
return CBM_STORE_ERR;
}
for (int i = 0; i < id_count; i++) {
sqlite3_bind_int64(stmt, i + SKIP_ONE, node_ids[i]);
}
if (has_type) {
bind_text(stmt, id_count + SKIP_ONE, edge_type);
}
int scan_rc11;
while ((scan_rc11 = sqlite3_step(stmt)) == SQLITE_ROW) {
int64_t nid = sqlite3_column_int64(stmt, 0);
int cnt = sqlite3_column_int(stmt, SKIP_ONE);
for (int i = 0; i < id_count; i++) {
if (node_ids[i] == nid) {
out_counts[i] = cnt;
break;
}
}
}
if (scan_rc11 != SQLITE_DONE) { /* SCANCHK:11:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
return CBM_STORE_OK;
}
int cbm_store_batch_count_degrees(cbm_store_t *s, const int64_t *node_ids, int id_count,
const char *edge_type, int *out_in, int *out_out) {
if (!s || !node_ids || id_count <= 0 || !out_in || !out_out) {
return CBM_STORE_ERR;
}
memset(out_in, 0, (size_t)id_count * sizeof(int));
memset(out_out, 0, (size_t)id_count * sizeof(int));
/* Build IN clause: (?,?,?) */
char in_clause[CBM_SZ_4K];
int pos = 0;
for (int i = 0; i < id_count && pos < (int)sizeof(in_clause) - ST_IN_CLAUSE_MARGIN; i++) {
if (i > 0) {
in_clause[pos++] = ',';
}
in_clause[pos++] = '?';
}
in_clause[pos] = '\0';
bool has_type = edge_type && edge_type[0] != '\0';
int rc = count_degrees_direction(s, node_ids, id_count, in_clause, has_type, edge_type, true,
out_in);
if (rc != CBM_STORE_OK) {
return rc;
}
return count_degrees_direction(s, node_ids, id_count, in_clause, has_type, edge_type, false,
out_out);
}
/* ── UpsertFileHashBatch ───────────────────────────────────────── */
int cbm_store_upsert_file_hash_batch(cbm_store_t *s, const cbm_file_hash_t *hashes, int count) {
if (count == 0) {
return CBM_STORE_OK;
}
int rc = cbm_store_begin(s);
if (rc != CBM_STORE_OK) {
return rc;
}
for (int i = 0; i < count; i++) {
rc = cbm_store_upsert_file_hash(s, hashes[i].project, hashes[i].rel_path, hashes[i].sha256,
hashes[i].mtime_ns, hashes[i].size);
if (rc != CBM_STORE_OK) {
cbm_store_rollback(s);
return rc;
}
}
return cbm_store_commit(s);
}
/* ── LSP surface rows (closure-repair incremental) ─────────────── */
static int upsert_lsp_surface_row(cbm_store_t *s, const cbm_lsp_surface_row_t *row) {
const char *sql =
"INSERT INTO lsp_surface (project, rel_path, surface_sha, defs_json, ref_bloom, config_ctx)"
" VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
" ON CONFLICT(project, rel_path) DO UPDATE SET surface_sha = excluded.surface_sha,"
" defs_json = excluded.defs_json, ref_bloom = excluded.ref_bloom,"
" config_ctx = excluded.config_ctx";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "lsp_surface upsert prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, row->project);
bind_text(stmt, ST_COL_2, row->rel_path);
bind_text(stmt, ST_COL_3, row->surface_sha ? row->surface_sha : "");
bind_text(stmt, ST_COL_4, row->defs_json ? row->defs_json : "[]");
if (row->ref_bloom && row->ref_bloom_len > 0) {
sqlite3_bind_blob(stmt, ST_COL_5, row->ref_bloom, row->ref_bloom_len, BIND_TRANSIENT);
} else {
sqlite3_bind_null(stmt, ST_COL_5);
}
bind_text(stmt, ST_COL_6, row->config_ctx ? row->config_ctx : "");
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "lsp_surface upsert step");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_upsert_lsp_surface_batch(cbm_store_t *s, const cbm_lsp_surface_row_t *rows,
int count) {
if (count == 0) {
return CBM_STORE_OK;
}
if (!s || !rows || count < 0) {
return CBM_STORE_ERR;
}
int rc = cbm_store_begin(s);
if (rc != CBM_STORE_OK) {
return rc;
}
for (int i = 0; i < count; i++) {
rc = upsert_lsp_surface_row(s, &rows[i]);
if (rc != CBM_STORE_OK) {
cbm_store_rollback(s);
return rc;
}
}
return cbm_store_commit(s);
}
int cbm_store_get_lsp_surfaces(cbm_store_t *s, const char *project, cbm_lsp_surface_row_t **out,
int *count) {
*out = NULL;
*count = 0;
const char *sql = "SELECT project, rel_path, surface_sha, defs_json, ref_bloom, config_ctx"
" FROM lsp_surface WHERE project = ?1 ORDER BY rel_path";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "lsp_surface get prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_lsp_surface_row_t *rows = calloc((size_t)cap, sizeof(*rows));
if (!rows) {
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
int step_rc;
bool oom = false;
while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
cbm_lsp_surface_row_t *grown = realloc(rows, (size_t)cap * sizeof(*rows));
if (!grown) {
oom = true;
break;
}
rows = grown;
memset(rows + n, 0, (size_t)(cap - n) * sizeof(*rows));
}
cbm_lsp_surface_row_t *r = &rows[n];
memset(r, 0, sizeof(*r));
const char *proj = (const char *)sqlite3_column_text(stmt, 0);
const char *rel = (const char *)sqlite3_column_text(stmt, ST_COL_1);
const char *sha = (const char *)sqlite3_column_text(stmt, ST_COL_2);
const char *defs = (const char *)sqlite3_column_text(stmt, ST_COL_3);
const void *bloom = sqlite3_column_blob(stmt, ST_COL_4);
int bloom_len = sqlite3_column_bytes(stmt, ST_COL_4);
const char *cfg = (const char *)sqlite3_column_text(stmt, ST_COL_5);
r->project = strdup(proj ? proj : "");
r->rel_path = strdup(rel ? rel : "");
r->surface_sha = strdup(sha ? sha : "");
r->defs_json = strdup(defs ? defs : "[]");
r->config_ctx = strdup(cfg ? cfg : "");
if (bloom && bloom_len > 0) {
void *copy = malloc((size_t)bloom_len);
if (copy) {
memcpy(copy, bloom, (size_t)bloom_len);
r->ref_bloom = copy;
r->ref_bloom_len = bloom_len;
}
}
if (!r->project || !r->rel_path || !r->surface_sha || !r->defs_json || !r->config_ctx ||
(bloom && bloom_len > 0 && !r->ref_bloom)) {
n++; /* count the partial row so the free below releases it */
oom = true;
break;
}
n++;
}
sqlite3_finalize(stmt);
if (oom || step_rc != SQLITE_DONE) {
cbm_store_free_lsp_surfaces(rows, n);
if (oom) {
return CBM_STORE_ERR;
}
store_set_error_sqlite(s, "lsp_surface get step");
return CBM_STORE_ERR;
}
*out = rows;
*count = n;
return CBM_STORE_OK;
}
int cbm_store_delete_lsp_surfaces(cbm_store_t *s, const char *project) {
const char *sql = "DELETE FROM lsp_surface WHERE project = ?1";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "lsp_surface delete prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "lsp_surface delete step");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
void cbm_store_free_lsp_surfaces(cbm_lsp_surface_row_t *rows, int count) {
if (!rows) {
return;
}
for (int i = 0; i < count; i++) {
free((void *)rows[i].project);
free((void *)rows[i].rel_path);
free((void *)rows[i].surface_sha);
free((void *)rows[i].defs_json);
free((void *)rows[i].ref_bloom);
free((void *)rows[i].config_ctx);
}
free(rows);
}
/* One chunk of the dependent-file query. Binding an IN list needs one
* placeholder per element, so the SQL is assembled per chunk; the chunk cap
* keeps it bounded. Dedup across chunks happens in the caller's hash set. */
enum { DEPFILE_CHUNK = 200 };
static int dependent_files_chunk(cbm_store_t *s, const char *project,
const char *const *target_files, int chunk_count,
CBMHashTable *seen, char ***out, int *out_count, int *out_cap) {
char sql[CBM_SZ_4K];
int pos = snprintf(sql, sizeof(sql),
/* CROSS JOIN pins nodes-first (see the delta snapshot query:
* the free planner walks every project edge instead). */
"SELECT DISTINCT src.file_path FROM nodes tgt"
" CROSS JOIN edges e ON e.target_id = tgt.id"
" CROSS JOIN nodes src ON e.source_id = src.id"
" WHERE tgt.project = ?1 AND tgt.file_path IN (");
for (int i = 0; i < chunk_count; i++) {
pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, "%s?%d", i ? "," : "", i + ST_COL_2);
if ((size_t)pos >= sizeof(sql)) {
return CBM_STORE_ERR;
}
}
/* Structural containment is not resolution: a Folder/Project container
* (whose file_path is a properties placeholder, not a real file) or a
* CONTAINS_* edge says nothing about who consumed the target's
* definitions, and the closure planner would otherwise decline on a
* "dependent" no discovery can ever produce. */
pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos,
") AND src.file_path <> '' AND src.file_path IS NOT NULL"
" AND src.label NOT IN ('Folder','Project')"
" AND e.type NOT IN ('CONTAINS_FILE','CONTAINS_FOLDER')");
if ((size_t)pos >= sizeof(sql)) {
return CBM_STORE_ERR;
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "dependent_files prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
for (int i = 0; i < chunk_count; i++) {
bind_text(stmt, i + ST_COL_2, target_files[i]);
}
int step_rc;
while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) {
const char *path = (const char *)sqlite3_column_text(stmt, 0);
if (!path || !path[0] || cbm_ht_get(seen, path)) {
continue;
}
if (*out_count >= *out_cap) {
int ncap = *out_cap ? *out_cap * ST_GROWTH : ST_INIT_CAP_8;
char **grown = realloc(*out, (size_t)ncap * sizeof(char *));
if (!grown) {
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
*out = grown;
*out_cap = ncap;
}
char *copy = strdup(path);
if (!copy) {
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
(*out)[(*out_count)++] = copy;
cbm_ht_set(seen, copy, copy);
}
sqlite3_finalize(stmt);
return step_rc == SQLITE_DONE ? CBM_STORE_OK : CBM_STORE_ERR;
}
int cbm_store_get_dependent_files(cbm_store_t *s, const char *project,
const char *const *target_files, int target_count, char ***out,
int *out_count) {
*out = NULL;
*out_count = 0;
if (!s || !project || target_count <= 0) {
return target_count == 0 ? CBM_STORE_OK : CBM_STORE_ERR;
}
/* Exclude the targets themselves up front: an edge between two files of
* the closure adds nothing, and the caller wants "who ELSE consumed". */
CBMHashTable *seen = cbm_ht_create((size_t)target_count * PAIR_LEN);
if (!seen) {
return CBM_STORE_ERR;
}
for (int i = 0; i < target_count; i++) {
cbm_ht_set(seen, target_files[i], (void *)target_files[i]);
}
char **files = NULL;
int count = 0;
int cap = 0;
int rc = CBM_STORE_OK;
for (int off = 0; off < target_count && rc == CBM_STORE_OK; off += DEPFILE_CHUNK) {
int chunk = target_count - off;
if (chunk > DEPFILE_CHUNK) {
chunk = DEPFILE_CHUNK;
}
rc = dependent_files_chunk(s, project, target_files + off, chunk, seen, &files, &count,
&cap);
}
cbm_ht_free(seen);
if (rc != CBM_STORE_OK) {
cbm_store_free_dependent_files(files, count);
return rc;
}
*out = files;
*out_count = count;
return CBM_STORE_OK;
}
void cbm_store_free_dependent_files(char **files, int count) {
if (!files) {
return;
}
for (int i = 0; i < count; i++) {
free(files[i]);
}
free(files);
}
/* ── FindEdgesByURLPath ────────────────────────────────────────── */
int cbm_store_find_edges_by_url_path(cbm_store_t *s, const char *project, const char *keyword,
cbm_edge_t **out, int *count) {
*out = NULL;
*count = 0;
/* Search properties JSON for url_path containing keyword */
char like_pattern[CBM_SZ_512];
snprintf(like_pattern, sizeof(like_pattern), "%%\"url_path\":\"%%%s%%\"%%", keyword);
const char *sql = "SELECT id, project, source_id, target_id, type, properties FROM edges "
"WHERE project = ?1 AND properties LIKE ?2";
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "url_path prepare");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, like_pattern);
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_edge_t *edges = malloc(cap * sizeof(cbm_edge_t));
int scan_rc12;
while ((scan_rc12 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
edges = safe_realloc(edges, cap * sizeof(cbm_edge_t));
}
memset(&edges[n], 0, sizeof(cbm_edge_t));
scan_edge(stmt, &edges[n]);
n++;
}
if (scan_rc12 != SQLITE_DONE) { /* SCANCHK:12:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
cbm_store_free_edges(edges, n);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
*out = edges;
*count = n;
return CBM_STORE_OK;
}
/* ── RestoreFrom ───────────────────────────────────────────────── */
int cbm_store_restore_from(cbm_store_t *dst, cbm_store_t *src) {
if (!dst || !src) {
return CBM_STORE_ERR;
}
sqlite3_backup *bk = sqlite3_backup_init(dst->db, "main", src->db, "main");
if (!bk) {
store_set_error_sqlite(dst, "backup init");
return CBM_STORE_ERR;
}
int rc = sqlite3_backup_step(bk, CBM_NOT_FOUND); /* copy all pages */
sqlite3_backup_finish(bk);
if (rc != SQLITE_DONE) {
store_set_error(dst, "backup step failed");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
/* ── Search ─────────────────────────────────────────────────────── */
/* Convert a glob pattern to SQL LIKE pattern. */
char *cbm_glob_to_like(const char *pattern) {
if (!pattern) {
return NULL;
}
size_t len = strlen(pattern);
char *out = malloc((len * ST_GROWTH) + SKIP_ONE);
size_t j = 0;
for (size_t i = 0; i < len; i++) {
if (pattern[i] == '*' && i + SKIP_ONE < len && pattern[i + SKIP_ONE] == '*') {
/* Remove leading / from output if present (handles glob dir-star) */
if (j > 0 && out[j - SKIP_ONE] == '/') {
j--;
}
out[j++] = '%';
i++; /* skip second * */
if (i + SKIP_ONE < len && pattern[i + SKIP_ONE] == '/') {
i++; /* skip trailing / */
}
} else if (pattern[i] == '*') {
out[j++] = '%';
} else if (pattern[i] == '?') {
out[j++] = '_';
} else {
out[j++] = pattern[i];
}
}
out[j] = '\0';
return out;
}
/* ── extractLikeHints ─────────────────────────────────────────── */
int cbm_extract_like_hints(const char *pattern, char **out, int max_out) {
if (!pattern || !out || max_out <= 0) {
return 0;
}
/* Bail on alternation — can't convert OR regex to AND LIKE */
for (const char *p = pattern; *p; p++) {
if (*p == '|') {
return 0;
}
}
int count = 0;
char buf[CBM_SZ_256];
int blen = 0;
int i = 0;
while (pattern[i]) {
char ch = pattern[i];
switch (ch) {
case '\\':
/* Escaped char — the next char is literal */
if (pattern[i + SKIP_ONE]) {
if (blen < (int)sizeof(buf) - SKIP_ONE) {
buf[blen++] = pattern[i + SKIP_ONE];
}
i += ST_GLOB_SKIP;
} else {
i++;
}
break;
case '.':
case '*':
case '+':
case '?':
case '^':
case '$':
case '(':
case ')':
case '[':
case ']':
case '{':
case '}':
/* Meta character — flush current literal segment */
if (blen >= ST_GLOB_MIN_LEN && count < max_out) {
buf[blen] = '\0';
out[count++] = strdup(buf);
}
blen = 0;
i++;
break;
default:
if (blen < (int)sizeof(buf) - SKIP_ONE) {
buf[blen++] = ch;
}
i++;
break;
}
}
/* Flush trailing segment */
if (blen >= ST_GLOB_MIN_LEN && count < max_out) {
buf[blen] = '\0';
out[count++] = strdup(buf);
}
return count;
}
/* ── ensureCaseInsensitive / stripCaseFlag ────────────────────── */
const char *cbm_ensure_case_insensitive(const char *pattern) {
static char buf[CBM_SZ_2K];
if (!pattern) {
buf[0] = '\0';
return buf;
}
/* Already has (?i) prefix? Return as-is. */
if (strncmp(pattern, "(?i)", SLEN("(?i)")) == 0) {
snprintf(buf, sizeof(buf), "%s", pattern);
} else {
snprintf(buf, sizeof(buf), "(?i)%s", pattern);
}
return buf;
}
const char *cbm_strip_case_flag(const char *pattern) {
static char buf[CBM_SZ_2K];
if (!pattern) {
buf[0] = '\0';
return buf;
}
if (strncmp(pattern, "(?i)", SLEN("(?i)")) == 0) {
snprintf(buf, sizeof(buf), "%s", pattern + 4);
} else {
snprintf(buf, sizeof(buf), "%s", pattern);
}
return buf;
}
/* Bind value tracker for dynamic query building */
typedef struct {
const char *text;
} search_bind_t;
/* Pool of malloc'd strings that must outlive statement execution.
* Freed in one shot after both statements are finalized. */
typedef struct {
char *ptrs[ST_LIKE_POOL_MAX];
int count;
} search_like_pool_t;
static void like_pool_add(search_like_pool_t *pool, char *ptr) {
if (ptr && pool->count < ST_LIKE_POOL_MAX) {
pool->ptrs[pool->count++] = ptr;
} else {
free(ptr); /* pool full — don't leak */
}
}
static void like_pool_free(search_like_pool_t *pool) {
for (int i = 0; i < pool->count; i++) {
free(pool->ptrs[i]);
}
pool->count = 0;
}
/* Wrap a literal string in % for use as a LIKE pattern (%literal%). */
static char *make_like_hint(const char *literal) {
size_t len = strlen(literal);
char *buf = malloc(len + 3); /* % + literal + % + NUL */
if (buf) {
buf[0] = '%';
memcpy(buf + SKIP_ONE, literal, len);
buf[len + SKIP_ONE] = '%';
buf[len + 2] = '\0';
}
return buf;
}
static void search_apply_degree_filter(char *sql, size_t sql_sz, const cbm_search_params_t *p) {
bool has_degree_filter = (p->min_degree >= 0 || p->max_degree >= 0);
if (!has_degree_filter) {
return;
}
char inner_sql[CBM_SZ_4K];
snprintf(inner_sql, sizeof(inner_sql), "%s", sql);
if (p->min_degree >= 0 && p->max_degree >= 0) {
snprintf(sql, sql_sz,
"SELECT * FROM (%s) WHERE (in_deg + out_deg) >= %d AND (in_deg + out_deg) <= %d",
inner_sql, p->min_degree, p->max_degree);
} else if (p->min_degree >= 0) {
snprintf(sql, sql_sz, "SELECT * FROM (%s) WHERE (in_deg + out_deg) >= %d", inner_sql,
p->min_degree);
} else {
snprintf(sql, sql_sz, "SELECT * FROM (%s) WHERE (in_deg + out_deg) <= %d", inner_sql,
p->max_degree);
}
}
/* Append a WHERE clause fragment, joining with AND if not the first. */
static int where_append(char *where, int where_sz, int wlen, int *nparams, const char *cond) {
if (*nparams > 0) {
wlen += snprintf(where + wlen, where_sz - wlen, " AND ");
}
wlen += snprintf(where + wlen, where_sz - wlen, "%s", cond);
(*nparams)++;
return wlen;
}
/* Bind a text parameter and increment the bind index. */
static void where_bind_text(search_bind_t *binds, int *bind_idx, const char *val) {
binds[*bind_idx].text = val;
(*bind_idx)++;
}
/* Build exclude-labels NOT IN clause with bind placeholders. */
static void search_build_exclude_labels(const char **labels, search_bind_t *binds, int *bind_idx,
char *clause, int clause_sz) {
int elen = snprintf(clause, clause_sz, "n.label NOT IN (");
for (int i = 0; labels[i]; i++) {
if (i > 0) {
elen += snprintf(clause + elen, clause_sz - elen, ",");
if (elen >= clause_sz) {
elen = clause_sz - SKIP_ONE;
}
}
elen += snprintf(clause + elen, clause_sz - elen, "?%d", *bind_idx + SKIP_ONE);
if (elen >= clause_sz) {
elen = clause_sz - SKIP_ONE;
}
where_bind_text(binds, bind_idx, labels[i]);
}
snprintf(clause + elen, clause_sz - elen, ")");
}
/* Append a regex WHERE clause for a column (case-sensitive or insensitive). */
static void where_add_regex(char *where, int where_sz, int *wlen, int *nparams,
search_bind_t *binds, int *bind_idx, const char *column,
const char *pattern, bool case_sensitive) {
char buf[CBM_SZ_128];
if (case_sensitive) {
snprintf(buf, sizeof(buf), "%s REGEXP ?%d", column, *bind_idx + SKIP_ONE);
} else {
snprintf(buf, sizeof(buf), "iregexp(?%d, %s)", *bind_idx + SKIP_ONE, column);
}
*wlen = where_append(where, where_sz, *wlen, nparams, buf);
where_bind_text(binds, bind_idx, pattern);
}
/* Prepend LIKE pre-filter conditions for literal segments of a regex pattern.
* The idx_nodes_name index satisfies LIKE '%literal%', cutting the rows that
* reach the (more expensive) iregexp call to only those containing the literal.
* cbm_extract_like_hints bails on alternation, so no false negatives. */
static void where_add_like_hints(const char *column, const char *pattern, char *where, int where_sz,
int *wlen, int *nparams, search_bind_t *binds, int *bind_idx,
search_like_pool_t *pool) {
char *hints[ST_LIKE_HINT_MAX];
int nhints = cbm_extract_like_hints(pattern, hints, ST_LIKE_HINT_MAX);
char bind_buf[CBM_SZ_64];
for (int i = 0; i < nhints; i++) {
char *lp = make_like_hint(hints[i]);
free(hints[i]);
if (!lp)
continue;
int pool_was_full = (pool->count >= ST_LIKE_POOL_MAX);
like_pool_add(pool, lp);
if (pool_was_full)
continue; /* lp was freed — skip bind */
snprintf(bind_buf, sizeof(bind_buf), "%s LIKE ?%d", column, *bind_idx + SKIP_ONE);
*wlen = where_append(where, where_sz, *wlen, nparams, bind_buf);
where_bind_text(binds, bind_idx, lp);
}
}
/* Build basic WHERE clauses: project, label, name, file, qn patterns. */
static int search_where_basic(const cbm_search_params_t *params, char *where, int where_sz,
int *wlen, int *nparams, search_bind_t *binds, int *bind_idx,
search_like_pool_t *pool) {
char bind_buf[CBM_SZ_64];
if (params->project) {
snprintf(bind_buf, sizeof(bind_buf), "n.project = ?%d", *bind_idx + SKIP_ONE);
*wlen = where_append(where, where_sz, *wlen, nparams, bind_buf);
where_bind_text(binds, bind_idx, params->project);
}
/* Ignore an empty-string label: it is non-NULL but should behave like an
* omitted label (no filter), matching the BM25 query path. Without the
* params->label[0] guard, name_pattern/qn_pattern searches that pass
* label="" append `n.label = ''`, which matches no node and silently
* returns zero results (issue #481). */
if (params->label && params->label[0]) {
snprintf(bind_buf, sizeof(bind_buf), "n.label = ?%d", *bind_idx + SKIP_ONE);
*wlen = where_append(where, where_sz, *wlen, nparams, bind_buf);
where_bind_text(binds, bind_idx, params->label);
}
if (params->name_pattern) {
where_add_like_hints("n.name", params->name_pattern, where, where_sz, wlen, nparams, binds,
bind_idx, pool);
where_add_regex(where, where_sz, wlen, nparams, binds, bind_idx, "n.name",
params->name_pattern, params->case_sensitive);
}
if (params->qn_pattern) {
where_add_like_hints("n.qualified_name", params->qn_pattern, where, where_sz, wlen, nparams,
binds, bind_idx, pool);
where_add_regex(where, where_sz, wlen, nparams, binds, bind_idx, "n.qualified_name",
params->qn_pattern, params->case_sensitive);
}
if (params->file_pattern) {
char *lp = cbm_glob_to_like(params->file_pattern);
/* A file_pattern with no glob wildcards is treated as a path-substring
* match (issue #200): file_pattern="offer-server" should match
* "src/offer-server/x.js", not only a path equal to "offer-server".
* Patterns that DO contain * / ? keep their explicit glob semantics. */
if (lp && !strchr(params->file_pattern, '*') && !strchr(params->file_pattern, '?')) {
size_t lplen = strlen(lp);
char *contains = malloc(lplen + 3);
if (contains) {
contains[0] = '%';
memcpy(contains + 1, lp, lplen);
contains[lplen + 1] = '%';
contains[lplen + 2] = '\0';
free(lp);
lp = contains;
}
}
int pool_was_full = (pool->count >= ST_LIKE_POOL_MAX);
like_pool_add(pool, lp);
if (!pool_was_full && lp) {
snprintf(bind_buf, sizeof(bind_buf), "n.file_path LIKE ?%d", *bind_idx + SKIP_ONE);
*wlen = where_append(where, where_sz, *wlen, nparams, bind_buf);
where_bind_text(binds, bind_idx, lp);
}
}
return *nparams;
}
/* Build advanced WHERE clauses: relationship, entry points, exclude labels. */
static void search_where_advanced(const cbm_search_params_t *params, char *where, int where_sz,
int *wlen, int *nparams, search_bind_t *binds, int *bind_idx) {
if (params->relationship) {
char rel_clause[CBM_SZ_256];
snprintf(rel_clause, sizeof(rel_clause),
"EXISTS(SELECT 1 FROM edges e WHERE "
"(e.source_id = n.id OR e.target_id = n.id) AND e.type = ?%d)",
*bind_idx + SKIP_ONE);
*wlen = where_append(where, where_sz, *wlen, nparams, rel_clause);
where_bind_text(binds, bind_idx, params->relationship);
}
if (params->exclude_entry_points) {
/* Exclude nodes with no inbound CALLS but at least one outbound CALLS.
* Dead code (degree=0) is NOT excluded — only true entry points. */
*wlen = where_append(where, where_sz, *wlen, nparams,
"NOT (NOT EXISTS(SELECT 1 FROM edges e WHERE e.target_id = n.id "
"AND e.type = 'CALLS') "
"AND EXISTS(SELECT 1 FROM edges e2 WHERE e2.source_id = n.id "
"AND e2.type = 'CALLS'))");
}
if (params->exclude_labels) {
char excl_clause[CBM_SZ_512];
search_build_exclude_labels(params->exclude_labels, binds, bind_idx, excl_clause,
(int)sizeof(excl_clause));
(void)where_append(where, where_sz, *wlen, nparams, excl_clause);
}
}
static int search_build_where(const cbm_search_params_t *params, char *where, int where_sz,
search_bind_t *binds, int *bind_idx, search_like_pool_t *pool) {
int wlen = 0;
int nparams = 0;
search_where_basic(params, where, where_sz, &wlen, &nparams, binds, bind_idx, pool);
search_where_advanced(params, where, where_sz, &wlen, &nparams, binds, bind_idx);
return nparams;
}
int cbm_store_search(cbm_store_t *s, const cbm_search_params_t *params, cbm_search_output_t *out) {
memset(out, 0, sizeof(*out));
if (!s || !s->db) {
return CBM_STORE_ERR;
}
char sql[CBM_SZ_4K];
char count_sql[CBM_SZ_4K];
int bind_idx = 0;
const char *select_cols = "SELECT n.id, n.project, n.label, n.name, n.qualified_name, "
"n.file_path, n.start_line, n.end_line, n.properties, "
"(SELECT COUNT(*) FROM edges e WHERE e.target_id = n.id AND "
"e.type IN ('CALLS', 'USAGE', 'CALL_REFERENCE', 'INHERITS', "
"'IMPLEMENTS')) AS in_deg, "
"(SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND "
"e.type IN ('CALLS', 'USAGE', 'CALL_REFERENCE', 'INHERITS', "
"'IMPLEMENTS')) AS out_deg ";
char where[CBM_SZ_2K] = "";
search_bind_t binds[ST_SEARCH_MAX_BINDS];
search_like_pool_t like_pool = {0};
int nparams =
search_build_where(params, where, (int)sizeof(where), binds, &bind_idx, &like_pool);
/* Build full SQL */
if (nparams > 0) {
snprintf(sql, sizeof(sql), "%s FROM nodes n WHERE %s", select_cols, where);
} else {
snprintf(sql, sizeof(sql), "%s FROM nodes n", select_cols);
}
/* Degree filters */
bool has_degree_filter = (params->min_degree >= 0 || params->max_degree >= 0);
search_apply_degree_filter(sql, sizeof(sql), params);
/* Count query — stripped of per-row edge subqueries for the common (no-degree-filter)
* case, since we only need the row count, not in_deg/out_deg. The degree-filter
* case must wrap the full query because the filter references those columns. */
if (has_degree_filter) {
snprintf(count_sql, sizeof(count_sql), "SELECT COUNT(*) FROM (%s)", sql);
} else if (nparams > 0) {
snprintf(count_sql, sizeof(count_sql), "SELECT COUNT(*) FROM nodes n WHERE %s", where);
} else {
snprintf(count_sql, sizeof(count_sql), "SELECT COUNT(*) FROM nodes n");
}
/* Add ORDER BY + LIMIT */
int limit = params->limit > 0 ? params->limit : CBM_DEFAULT_SEARCH_LIMIT;
int offset = params->offset;
const char *name_col = has_degree_filter ? "name" : "n.name";
const char *id_col = has_degree_filter ? "id" : "n.id";
char order_limit[CBM_SZ_128];
/* (name, id) is a unique total order — names are non-unique, and without
* the tie-break offset pages are not contractually stable across calls. */
snprintf(order_limit, sizeof(order_limit), " ORDER BY %s, %s LIMIT %d OFFSET %d", name_col,
id_col, limit, offset);
strncat(sql, order_limit, sizeof(sql) - strlen(sql) - 1);
/* Execute count query */
sqlite3_stmt *cnt_stmt = NULL;
int rc = sqlite3_prepare_v2(s->db, count_sql, CBM_NOT_FOUND, &cnt_stmt, NULL);
if (rc == SQLITE_OK) {
for (int i = 0; i < bind_idx; i++) {
bind_text(cnt_stmt, i + SKIP_ONE, binds[i].text);
}
if (sqlite3_step(cnt_stmt) == SQLITE_ROW) {
out->total = sqlite3_column_int(cnt_stmt, 0);
}
sqlite3_finalize(cnt_stmt);
}
/* Execute main query */
sqlite3_stmt *main_stmt = NULL;
rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &main_stmt, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "search prepare");
like_pool_free(&like_pool);
return CBM_STORE_ERR;
}
for (int i = 0; i < bind_idx; i++) {
bind_text(main_stmt, i + SKIP_ONE, binds[i].text);
}
int cap = ST_INIT_CAP_16;
int n = 0;
cbm_search_result_t *results = malloc(cap * sizeof(cbm_search_result_t));
int scan_rc13;
while ((scan_rc13 = sqlite3_step(main_stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
results = safe_realloc(results, cap * sizeof(cbm_search_result_t));
}
memset(&results[n], 0, sizeof(cbm_search_result_t));
scan_node(main_stmt, &results[n].node);
results[n].in_degree = sqlite3_column_int(main_stmt, ST_COL_9);
results[n].out_degree = sqlite3_column_int(main_stmt, CBM_DECIMAL_BASE);
n++;
}
if (scan_rc13 != SQLITE_DONE) { /* SCANCHK:13:main_stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(main_stmt);
like_pool_free(&like_pool);
out->results = results;
out->count = n;
return CBM_STORE_ERR;
}
sqlite3_finalize(main_stmt);
like_pool_free(&like_pool);
out->results = results;
out->count = n;
return CBM_STORE_OK;
}
void cbm_store_search_free(cbm_search_output_t *out) {
if (!out) {
return;
}
for (int i = 0; i < out->count; i++) {
cbm_search_result_t *r = &out->results[i];
safe_str_free(&r->node.project);
safe_str_free(&r->node.label);
safe_str_free(&r->node.name);
safe_str_free(&r->node.qualified_name);
safe_str_free(&r->node.file_path);
safe_str_free(&r->node.properties_json);
for (int j = 0; j < r->connected_count; j++) {
safe_str_free(&r->connected_names[j]);
}
free(r->connected_names);
}
free(out->results);
memset(out, 0, sizeof(*out));
}
/* ── BFS Traversal ──────────────────────────────────────────────── */
static int bfs_collect_edges(cbm_store_t *s, int64_t start_id, const cbm_node_hop_t *visited,
int visited_count, const char *types_clause, const char **edge_types,
int edge_type_count, cbm_edge_info_t **out_edges,
int *out_edge_count) {
*out_edges = NULL;
*out_edge_count = 0;
/* Visited-ID set via a per-connection TEMP table. The previous approach
* interpolated the ids into a fixed 4KB SQL string: past ~1000 visited
* nodes the list was cut MID-NUMBER, the SQL failed to prepare, and every
* trace edge silently vanished (and a luckier cut could match an
* unrelated node). TEMP tables live in the connection's temp db, so this
* works on read-only query connections too. */
if (sqlite3_exec(s->db,
"CREATE TEMP TABLE IF NOT EXISTS bfs_ids (id INTEGER PRIMARY KEY);"
"DELETE FROM bfs_ids;",
NULL, NULL, NULL) != SQLITE_OK) {
return CBM_STORE_OK; /* best-effort: nodes without edges beat an error */
}
sqlite3_stmt *ins = NULL;
if (sqlite3_prepare_v2(s->db, "INSERT OR IGNORE INTO bfs_ids(id) VALUES (?1)", CBM_NOT_FOUND,
&ins, NULL) != SQLITE_OK) {
return CBM_STORE_OK;
}
sqlite3_bind_int64(ins, SKIP_ONE, start_id);
(void)sqlite3_step(ins);
for (int i = 0; i < visited_count; i++) {
sqlite3_reset(ins);
sqlite3_bind_int64(ins, SKIP_ONE, visited[i].node.id);
(void)sqlite3_step(ins);
}
sqlite3_finalize(ins);
char edge_sql[ST_SQL_BUF];
snprintf(edge_sql, sizeof(edge_sql),
"SELECT n1.name, n2.name, e.type, e.source_id, e.target_id, e.properties "
"FROM edges e "
"JOIN nodes n1 ON n1.id = e.source_id "
"JOIN nodes n2 ON n2.id = e.target_id "
"WHERE e.source_id IN (SELECT id FROM bfs_ids) "
"AND e.target_id IN (SELECT id FROM bfs_ids) "
"AND e.type IN (%s)",
types_clause);
sqlite3_stmt *estmt = NULL;
int rc = sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &estmt, NULL);
if (rc != SQLITE_OK) {
*out_edges = NULL;
*out_edge_count = 0;
return CBM_STORE_OK;
}
if (edge_type_count > 0) {
for (int i = 0; i < edge_type_count; i++) {
bind_text(estmt, i + SKIP_ONE, edge_types[i]);
}
} else {
bind_text(estmt, SKIP_ONE, "CALLS");
}
int ecap = ST_INIT_CAP_8;
int en = 0;
cbm_edge_info_t *edges = malloc(ecap * sizeof(cbm_edge_info_t));
int scan_rc14;
while ((scan_rc14 = sqlite3_step(estmt)) == SQLITE_ROW) {
if (en >= ecap) {
ecap *= ST_GROWTH;
edges = safe_realloc(edges, ecap * sizeof(cbm_edge_info_t));
}
edges[en].from_name = heap_strdup((const char *)sqlite3_column_text(estmt, 0));
edges[en].to_name = heap_strdup((const char *)sqlite3_column_text(estmt, SKIP_ONE));
edges[en].type = heap_strdup((const char *)sqlite3_column_text(estmt, CBM_SZ_2));
edges[en].confidence = (double)SKIP_ONE;
edges[en].source_id = sqlite3_column_int64(estmt, ST_COL_3);
edges[en].target_id = sqlite3_column_int64(estmt, ST_COL_4);
edges[en].properties_json = heap_strdup((const char *)sqlite3_column_text(estmt, CBM_SZ_5));
en++;
}
if (scan_rc14 != SQLITE_DONE) { /* SCANCHK:14:estmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(estmt);
*out_edges = edges;
*out_edge_count = en;
return CBM_STORE_ERR;
}
sqlite3_finalize(estmt);
*out_edges = edges;
*out_edge_count = en;
return CBM_STORE_OK;
}
/* Build parameterized placeholder list "?1,?2,?3" for N edge types. */
static void bfs_build_types_clause(int edge_type_count, char *buf, int buf_sz) {
if (edge_type_count <= 0) {
snprintf(buf, buf_sz, "?1");
return;
}
int tlen = 0;
for (int i = 0; i < edge_type_count; i++) {
if (i > 0) {
tlen += snprintf(buf + tlen, buf_sz - tlen, ",");
if (tlen >= buf_sz) {
tlen = buf_sz - SKIP_ONE;
}
}
tlen += snprintf(buf + tlen, buf_sz - tlen, "?%d", i + SKIP_ONE);
if (tlen >= buf_sz) {
tlen = buf_sz - SKIP_ONE;
}
}
}
int cbm_store_bfs(cbm_store_t *s, int64_t start_id, const char *direction, const char **edge_types,
int edge_type_count, int max_depth, int max_results, cbm_traverse_result_t *out) {
memset(out, 0, sizeof(*out));
cbm_node_t root = {0};
int rc = cbm_store_find_node_by_id(s, start_id, &root);
if (rc != CBM_STORE_OK) {
return rc;
}
out->root = root;
char types_clause[CBM_SZ_512];
bfs_build_types_clause(edge_type_count, types_clause, (int)sizeof(types_clause));
/* Build recursive CTE for BFS */
char sql[CBM_SZ_4K];
const char *join_cond;
const char *next_id;
bool is_inbound = (direction != NULL) && (strcmp(direction, "inbound") == 0);
if (is_inbound) {
join_cond = "e.target_id = bfs.node_id";
next_id = "e.source_id";
} else {
join_cond = "e.source_id = bfs.node_id";
next_id = "e.target_id";
}
snprintf(sql, sizeof(sql),
/* SHORTEST-PATH semantics: the UNION dedupes (node, hop) PAIRS,
* so a single self-loop minted every hop level for every node it
* could reach — walk-padding that fabricated *k..k Cypher matches
* of arbitrary length and exploded the row set to nodes x depth
* (#797). MIN(hop) GROUP BY node returns each node once at its
* minimal distance. */
"WITH RECURSIVE bfs(node_id, hop) AS ("
" SELECT %lld, 0"
" UNION"
" SELECT %s, bfs.hop + 1"
" FROM bfs"
" JOIN edges e ON %s"
" WHERE e.type IN (%s) AND bfs.hop < %d"
")"
"SELECT n.id, n.project, n.label, n.name, n.qualified_name, "
"n.file_path, n.start_line, n.end_line, n.properties, MIN(bfs.hop) AS hop "
"FROM bfs "
"JOIN nodes n ON n.id = bfs.node_id "
"WHERE bfs.hop > 0 " /* exclude root at hop 0 (self via a loop still appears) */
"GROUP BY n.id "
/* (hop, id) is a unique total order — deterministic pagination
* watermarks and reproducible trace output depend on it. */
"ORDER BY hop, n.id "
"LIMIT %d;",
(long long)start_id, next_id, join_cond, types_clause, max_depth, max_results);
sqlite3_stmt *stmt = NULL;
rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "bfs prepare");
return CBM_STORE_ERR;
}
/* Bind edge type parameters */
if (edge_type_count > 0) {
for (int i = 0; i < edge_type_count; i++) {
bind_text(stmt, i + SKIP_ONE, edge_types[i]);
}
} else {
bind_text(stmt, SKIP_ONE, "CALLS");
}
int cap = ST_INIT_CAP_16;
int n = 0;
cbm_node_hop_t *visited = malloc(cap * sizeof(cbm_node_hop_t));
int scan_rc15;
while ((scan_rc15 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
visited = safe_realloc(visited, cap * sizeof(cbm_node_hop_t));
}
scan_node(stmt, &visited[n].node);
visited[n].hop = sqlite3_column_int(stmt, ST_COL_9);
n++;
}
if (scan_rc15 != SQLITE_DONE) { /* SCANCHK:15:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
out->visited = visited;
out->visited_count = n;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
out->visited = visited;
out->visited_count = n;
/* Collect edges between visited nodes (including root) */
if (n > 0) {
bfs_collect_edges(s, start_id, out->visited, n, types_clause, edge_types, edge_type_count,
&out->edges, &out->edge_count);
} else {
out->edges = NULL;
out->edge_count = 0;
}
return CBM_STORE_OK;
}
/* Multi-source BFS: one recursive CTE anchored on ALL seeds (via a temp
* table — never a per-seed loop, which re-walks overlapping hub subgraphs
* seed_count times). Semantics for impact analysis:
* - shortest-path MIN(hop) across the whole seed set;
* - SEEDS ARE EXCLUDED from the result: a seed reached from another seed
* (changed files call each other constantly) is not "impact";
* - uncapped counting up to max_results as a memory-safety ceiling only —
* *truncated reports when it was hit (never a silent cap);
* - canonical (hop, id) order.
* No root node and no edge collection — impact wants the reached set. */
int cbm_store_bfs_multi(cbm_store_t *s, const int64_t *seed_ids, int seed_count,
const char *direction, const char **edge_types, int edge_type_count,
int max_depth, int max_results, cbm_traverse_result_t *out,
bool *truncated) {
memset(out, 0, sizeof(*out));
if (truncated) {
*truncated = false;
}
if (!s || !s->db || !seed_ids || seed_count <= 0) {
return CBM_STORE_ERR;
}
if (sqlite3_exec(s->db,
"CREATE TEMP TABLE IF NOT EXISTS bfs_seeds (id INTEGER PRIMARY KEY);"
"DELETE FROM bfs_seeds;",
NULL, NULL, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "bfs_multi seeds");
return CBM_STORE_ERR;
}
sqlite3_stmt *ins = NULL;
if (sqlite3_prepare_v2(s->db, "INSERT OR IGNORE INTO bfs_seeds(id) VALUES (?1)", CBM_NOT_FOUND,
&ins, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "bfs_multi seed insert");
return CBM_STORE_ERR;
}
for (int i = 0; i < seed_count; i++) {
sqlite3_reset(ins);
sqlite3_bind_int64(ins, SKIP_ONE, seed_ids[i]);
(void)sqlite3_step(ins);
}
sqlite3_finalize(ins);
char types_clause[CBM_SZ_512];
bfs_build_types_clause(edge_type_count, types_clause, (int)sizeof(types_clause));
const char *join_cond;
const char *next_id;
bool is_inbound = (direction != NULL) && (strcmp(direction, "inbound") == 0);
if (is_inbound) {
join_cond = "e.target_id = bfs.node_id";
next_id = "e.source_id";
} else {
join_cond = "e.source_id = bfs.node_id";
next_id = "e.target_id";
}
char sql[CBM_SZ_4K];
snprintf(sql, sizeof(sql),
"WITH RECURSIVE bfs(node_id, hop) AS ("
" SELECT id, 0 FROM bfs_seeds"
" UNION"
" SELECT %s, bfs.hop + 1"
" FROM bfs"
" JOIN edges e ON %s"
" WHERE e.type IN (%s) AND bfs.hop < %d"
")"
"SELECT n.id, n.project, n.label, n.name, n.qualified_name, "
"n.file_path, n.start_line, n.end_line, n.properties, MIN(bfs.hop) AS hop "
"FROM bfs "
"JOIN nodes n ON n.id = bfs.node_id "
/* hop > 0 excludes the anchors themselves; the NOT IN excludes a
* seed REACHED from another seed at hop > 0 (leak-back). */
"WHERE bfs.hop > 0 AND bfs.node_id NOT IN (SELECT id FROM bfs_seeds) "
"GROUP BY n.id "
"ORDER BY hop, n.id "
"LIMIT %d;",
next_id, join_cond, types_clause, max_depth, max_results + SKIP_ONE);
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "bfs_multi prepare");
return CBM_STORE_ERR;
}
if (edge_type_count > 0) {
for (int i = 0; i < edge_type_count; i++) {
bind_text(stmt, i + SKIP_ONE, edge_types[i]);
}
} else {
bind_text(stmt, SKIP_ONE, "CALLS");
}
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) {
if (n > max_results) {
break; /* ceiling + 1 row fetched: report truncation below */
}
if (n >= cap) {
cap *= ST_GROWTH;
visited = safe_realloc(visited, cap * sizeof(cbm_node_hop_t));
}
scan_node(stmt, &visited[n].node);
visited[n].hop = sqlite3_column_int(stmt, ST_COL_9);
n++;
}
bool aborted = (scan_rc16 != SQLITE_DONE && scan_rc16 != SQLITE_ROW);
if (n > max_results) {
/* Free the sentinel row and flag the ceiling. */
n = max_results;
cbm_node_free_fields(&visited[n].node);
if (truncated) {
*truncated = true;
}
}
sqlite3_finalize(stmt);
out->visited = visited;
out->visited_count = n;
if (aborted) {
store_set_error_sqlite(s, "bfs_multi row scan aborted");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
void cbm_store_traverse_free(cbm_traverse_result_t *out) {
if (!out) {
return;
}
/* Free root */
safe_str_free(&out->root.project);
safe_str_free(&out->root.label);
safe_str_free(&out->root.name);
safe_str_free(&out->root.qualified_name);
safe_str_free(&out->root.file_path);
safe_str_free(&out->root.properties_json);
/* Free visited */
for (int i = 0; i < out->visited_count; i++) {
cbm_node_hop_t *h = &out->visited[i];
safe_str_free(&h->node.project);
safe_str_free(&h->node.label);
safe_str_free(&h->node.name);
safe_str_free(&h->node.qualified_name);
safe_str_free(&h->node.file_path);
safe_str_free(&h->node.properties_json);
}
free(out->visited);
/* Free edges */
for (int i = 0; i < out->edge_count; i++) {
safe_str_free(&out->edges[i].from_name);
safe_str_free(&out->edges[i].to_name);
safe_str_free(&out->edges[i].type);
safe_str_free(&out->edges[i].properties_json);
}
free(out->edges);
memset(out, 0, sizeof(*out));
}
/* ── Impact analysis ────────────────────────────────────────────── */
cbm_risk_level_t cbm_hop_to_risk(int hop) {
switch (hop) {
case SKIP_ONE:
return CBM_RISK_CRITICAL;
case ST_COL_2:
return CBM_RISK_HIGH;
case ST_COL_3:
return CBM_RISK_MEDIUM;
default:
return CBM_RISK_LOW;
}
}
const char *cbm_risk_label(cbm_risk_level_t level) {
switch (level) {
case CBM_RISK_CRITICAL:
return "CRITICAL";
case CBM_RISK_HIGH:
return "HIGH";
case CBM_RISK_MEDIUM:
return "MEDIUM";
case CBM_RISK_LOW:
default:
return "LOW";
}
}
cbm_impact_summary_t cbm_build_impact_summary(const cbm_node_hop_t *hops, int hop_count,
const cbm_edge_info_t *edges, int edge_count) {
cbm_impact_summary_t s = {0};
for (int i = 0; i < hop_count; i++) {
switch (cbm_hop_to_risk(hops[i].hop)) {
case CBM_RISK_CRITICAL:
s.critical++;
break;
case CBM_RISK_HIGH:
s.high++;
break;
case CBM_RISK_MEDIUM:
s.medium++;
break;
case CBM_RISK_LOW:
s.low++;
break;
}
s.total++;
}
for (int i = 0; i < edge_count; i++) {
if (edges[i].type && (strcmp(edges[i].type, "HTTP_CALLS") == 0 ||
strcmp(edges[i].type, "ASYNC_CALLS") == 0)) {
s.has_cross_service = true;
break;
}
}
return s;
}
int cbm_deduplicate_hops(const cbm_node_hop_t *hops, int hop_count, cbm_node_hop_t **out,
int *out_count) {
*out = NULL;
*out_count = 0;
if (hop_count == 0) {
return CBM_STORE_OK;
}
/* Simple O(n²) dedup — keep minimum hop per node ID */
cbm_node_hop_t *result = malloc(hop_count * sizeof(cbm_node_hop_t));
int n = 0;
for (int i = 0; i < hop_count; i++) {
int found = ST_FOUND;
for (int j = 0; j < n; j++) {
if (result[j].node.id == hops[i].node.id) {
found = j;
break;
}
}
if (found >= 0) {
if (hops[i].hop < result[found].hop) {
result[found].hop = hops[i].hop;
}
} else {
result[n] = hops[i];
n++;
}
}
*out = safe_realloc(result, n * sizeof(cbm_node_hop_t));
*out_count = n;
return CBM_STORE_OK;
}
/* ── Schema ─────────────────────────────────────────────────────── */
enum { SCHEMA_MAX_JSON_KEYS = 50 };
/* Discover distinct JSON property keys for a table/column via json_each().
* Prepends base_cols, then appends up to SCHEMA_MAX_JSON_KEYS from the query.
* Caller must free the returned array and each string in it. */
static void schema_discover_props(sqlite3 *db, const char *sql, const char *project,
const char *filter, const char **base_cols, int base_col_count,
char ***out_props, int *out_count) {
int pcap = base_col_count + SCHEMA_MAX_JSON_KEYS;
char **props = malloc(pcap * sizeof(char *));
int pn = 0;
for (int b = 0; b < base_col_count; b++) {
props[pn++] = heap_strdup(base_cols[b]);
}
sqlite3_stmt *pstmt = NULL;
if (sqlite3_prepare_v2(db, sql, CBM_NOT_FOUND, &pstmt, NULL) == SQLITE_OK) {
bind_text(pstmt, SKIP_ONE, project);
bind_text(pstmt, PAIR_LEN, filter);
while (sqlite3_step(pstmt) == SQLITE_ROW && pn < pcap) {
props[pn++] = heap_strdup((const char *)sqlite3_column_text(pstmt, 0));
}
sqlite3_finalize(pstmt);
}
*out_props = props;
*out_count = pn;
}
/* Path scoping for architecture / schema (shared). */
static bool arch_path_is_set(const char *path) {
if (!path) {
return false;
}
while (*path == ' ' || *path == '\t' || *path == '\n' || *path == '\r') {
path++;
}
return path[0] != '\0';
}
static bool arch_path_prepare(const char *path, char *norm_out, size_t norm_sz, char *like_out,
size_t like_sz) {
if (!arch_path_is_set(path)) {
return false;
}
while (*path == ' ' || *path == '\t' || *path == '\n' || *path == '\r') {
path++;
}
if (path[0] == '\0') {
return false;
}
if (strncmp(path, "./", 2) == 0) {
path += 2;
}
while (*path == '/') {
path++;
}
if (path[0] == '\0') {
return false;
}
strncpy(norm_out, path, norm_sz - 1);
norm_out[norm_sz - 1] = '\0';
size_t len = strlen(norm_out);
while (len > 0 &&
(norm_out[len - 1] == ' ' || norm_out[len - 1] == '\t' || norm_out[len - 1] == '/')) {
norm_out[--len] = '\0';
}
/* Collapse duplicate slashes */
size_t w = 0;
for (size_t r = 0; norm_out[r] != '\0'; r++) {
if (norm_out[r] == '/' && w > 0 && norm_out[w - 1] == '/') {
continue;
}
norm_out[w++] = norm_out[r];
}
norm_out[w] = '\0';
if (norm_out[0] == '\0') {
return false;
}
snprintf(like_out, like_sz, "%s/%%", norm_out);
return true;
}
static const char *arch_path_scope_sql(void) {
return " AND (file_path = ? OR file_path LIKE ?)";
}
static void arch_bind_path_scope(sqlite3_stmt *stmt, int exact_idx, int like_idx, const char *norm,
const char *like_pat) {
bind_text(stmt, exact_idx, norm);
bind_text(stmt, like_idx, like_pat);
}
bool cbm_store_arch_path_scoped(const char *path) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512 + 4];
return arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
}
bool cbm_store_normalize_arch_path(const char *path, char *norm_out, size_t norm_sz) {
char like[CBM_SZ_512 + 4];
return arch_path_prepare(path, norm_out, norm_sz, like, sizeof(like));
}
int cbm_store_count_nodes_scoped(cbm_store_t *s, const char *project, const char *path) {
if (!s || !s->db || !project) {
return 0;
}
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
if (!arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like))) {
return cbm_store_count_nodes(s, project);
}
const char *sql = "SELECT COUNT(*) FROM nodes WHERE project = ?1 "
"AND (file_path = ?2 OR file_path LIKE ?3);";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
if (stmt) {
sqlite3_finalize(stmt);
}
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
int n = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
n = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
return n;
}
int cbm_store_count_edges_scoped(cbm_store_t *s, const char *project, const char *path) {
if (!s || !s->db || !project) {
return 0;
}
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
if (!arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like))) {
return cbm_store_count_edges(s, project);
}
const char *sql =
"SELECT COUNT(*) FROM edges e WHERE e.project = ?1 "
"AND EXISTS (SELECT 1 FROM nodes ns WHERE ns.id = e.source_id AND ns.project = ?1 "
"AND (ns.file_path = ?2 OR ns.file_path LIKE ?3)) "
"AND EXISTS (SELECT 1 FROM nodes nt WHERE nt.id = e.target_id AND nt.project = ?1 "
"AND (nt.file_path = ?2 OR nt.file_path LIKE ?3));";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
if (stmt) {
sqlite3_finalize(stmt);
}
return CBM_STORE_ERR;
}
bind_text(stmt, ST_COL_1, project);
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
int n = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
n = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
return n;
}
/* with_props=false skips the per-label/per-type JSON property-key discovery:
* those json_each() scans walk EVERY row of each label/type (minutes-scale on
* multi-million-node graphs) and get_architecture only needs the counts. */
static int get_schema_impl(cbm_store_t *s, const char *project, cbm_schema_info_t *out,
bool with_props) {
memset(out, 0, sizeof(*out));
if (!s || !s->db) {
return CBM_NOT_FOUND;
}
/* Node labels */
{
const char *sql = "SELECT label, COUNT(*) FROM nodes WHERE project = ?1 GROUP BY label "
"ORDER BY COUNT(*) DESC;";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
if (stmt) {
sqlite3_finalize(stmt);
}
return CBM_NOT_FOUND;
}
bind_text(stmt, SKIP_ONE, project);
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_label_count_t *arr = malloc(cap * sizeof(cbm_label_count_t));
if (!arr) {
sqlite3_finalize(stmt);
return CBM_NOT_FOUND;
}
int scan_rc16;
while ((scan_rc16 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
int new_cap = cap * ST_GROWTH;
void *tmp = realloc(arr, new_cap * sizeof(cbm_label_count_t));
if (!tmp) {
for (int i = 0; i < n; i++) {
safe_str_free(&arr[i].label);
}
free(arr);
sqlite3_finalize(stmt);
return CBM_NOT_FOUND;
}
arr = tmp;
cap = new_cap;
}
arr[n].label = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].count = sqlite3_column_int(stmt, SKIP_ONE);
arr[n].properties = NULL;
arr[n].property_count = 0;
n++;
}
if (scan_rc16 != SQLITE_DONE) { /* SCANCHK:16:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
out->node_labels = arr;
out->node_label_count = n;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
out->node_labels = arr;
out->node_label_count = n;
}
/* Node label property keys: base columns + distinct JSON property keys per label */
if (with_props) {
static const char *node_base_cols[] = {"name", "qualified_name", "file_path", "start_line",
"end_line"};
const char *prop_sql = "SELECT DISTINCT je.key "
"FROM nodes, json_each(nodes.properties) AS je "
"WHERE nodes.project = ?1 AND nodes.label = ?2 "
" AND nodes.properties != '{}' "
"ORDER BY je.key "
"LIMIT 50;";
for (int i = 0; i < out->node_label_count; i++) {
schema_discover_props(
s->db, prop_sql, project, out->node_labels[i].label, node_base_cols,
(int)(sizeof(node_base_cols) / sizeof(node_base_cols[0])),
&out->node_labels[i].properties, &out->node_labels[i].property_count);
}
}
/* Edge types */
{
const char *sql = "SELECT type, COUNT(*) FROM edges WHERE project = ?1 GROUP BY type ORDER "
"BY COUNT(*) DESC;";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
if (stmt) {
sqlite3_finalize(stmt);
}
cbm_store_schema_free(out);
return CBM_NOT_FOUND;
}
bind_text(stmt, SKIP_ONE, project);
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_type_count_t *arr = malloc(cap * sizeof(cbm_type_count_t));
if (!arr) {
sqlite3_finalize(stmt);
cbm_store_schema_free(out);
return CBM_NOT_FOUND;
}
int scan_rc17;
while ((scan_rc17 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
int new_cap = cap * ST_GROWTH;
void *tmp = realloc(arr, new_cap * sizeof(cbm_type_count_t));
if (!tmp) {
for (int i = 0; i < n; i++) {
safe_str_free(&arr[i].type);
}
free(arr);
sqlite3_finalize(stmt);
cbm_store_schema_free(out);
return CBM_NOT_FOUND;
}
arr = tmp;
cap = new_cap;
}
arr[n].type = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].count = sqlite3_column_int(stmt, SKIP_ONE);
arr[n].properties = NULL;
arr[n].property_count = 0;
n++;
}
if (scan_rc17 != SQLITE_DONE) { /* SCANCHK:17:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
out->edge_types = arr;
out->edge_type_count = n;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
out->edge_types = arr;
out->edge_type_count = n;
}
/* Edge type property keys: base columns + distinct JSON property keys per type */
if (with_props) {
static const char *edge_base_cols[] = {"source_id", "target_id"};
const char *prop_sql = "SELECT DISTINCT je.key "
"FROM edges, json_each(edges.properties) AS je "
"WHERE edges.project = ?1 AND edges.type = ?2 "
" AND edges.properties != '{}' "
"ORDER BY je.key "
"LIMIT 50;";
for (int i = 0; i < out->edge_type_count; i++) {
schema_discover_props(s->db, prop_sql, project, out->edge_types[i].type, edge_base_cols,
(int)(sizeof(edge_base_cols) / sizeof(edge_base_cols[0])),
&out->edge_types[i].properties,
&out->edge_types[i].property_count);
}
}
return CBM_STORE_OK;
}
int cbm_store_get_schema(cbm_store_t *s, const char *project, cbm_schema_info_t *out) {
return get_schema_impl(s, project, out, true);
}
int cbm_store_get_schema_counts(cbm_store_t *s, const char *project, cbm_schema_info_t *out) {
return get_schema_impl(s, project, out, false);
}
int cbm_store_get_schema_counts_scoped(cbm_store_t *s, const char *project, const char *path,
cbm_schema_info_t *out) {
memset(out, 0, sizeof(*out));
if (!s || !s->db) {
return CBM_NOT_FOUND;
}
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
if (!scoped) {
return get_schema_impl(s, project, out, false);
}
char sqlbuf[ST_SQL_BUF];
{
const char *base = "SELECT label, COUNT(*) FROM nodes WHERE project = ?1";
snprintf(sqlbuf, sizeof(sqlbuf), "%s%s GROUP BY label ORDER BY COUNT(*) DESC;", base,
arch_path_scope_sql());
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
if (stmt) {
sqlite3_finalize(stmt);
}
return CBM_NOT_FOUND;
}
bind_text(stmt, SKIP_ONE, project);
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_label_count_t *arr = malloc(cap * sizeof(cbm_label_count_t));
if (!arr) {
sqlite3_finalize(stmt);
return CBM_NOT_FOUND;
}
int scan_rc18;
while ((scan_rc18 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
int new_cap = cap * ST_GROWTH;
void *tmp = realloc(arr, new_cap * sizeof(cbm_label_count_t));
if (!tmp) {
for (int i = 0; i < n; i++) {
safe_str_free(&arr[i].label);
}
free(arr);
sqlite3_finalize(stmt);
return CBM_NOT_FOUND;
}
arr = tmp;
cap = new_cap;
}
arr[n].label = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].count = sqlite3_column_int(stmt, SKIP_ONE);
arr[n].properties = NULL;
arr[n].property_count = 0;
n++;
}
if (scan_rc18 != SQLITE_DONE) { /* SCANCHK:18:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
out->node_labels = arr;
out->node_label_count = n;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
out->node_labels = arr;
out->node_label_count = n;
}
{
const char *esql =
"SELECT e.type, COUNT(*) FROM edges e WHERE e.project = ?1 "
"AND EXISTS (SELECT 1 FROM nodes ns WHERE ns.id = e.source_id AND ns.project = ?1 "
"AND (ns.file_path = ?2 OR ns.file_path LIKE ?3)) "
"AND EXISTS (SELECT 1 FROM nodes nt WHERE nt.id = e.target_id AND nt.project = ?1 "
"AND (nt.file_path = ?2 OR nt.file_path LIKE ?3)) "
"GROUP BY e.type ORDER BY COUNT(*) DESC;";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, esql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
if (stmt) {
sqlite3_finalize(stmt);
}
cbm_store_schema_free(out);
return CBM_NOT_FOUND;
}
bind_text(stmt, SKIP_ONE, project);
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_type_count_t *arr = malloc(cap * sizeof(cbm_type_count_t));
if (!arr) {
sqlite3_finalize(stmt);
cbm_store_schema_free(out);
return CBM_NOT_FOUND;
}
int scan_rc19;
while ((scan_rc19 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
int new_cap = cap * ST_GROWTH;
void *tmp = realloc(arr, new_cap * sizeof(cbm_type_count_t));
if (!tmp) {
for (int i = 0; i < n; i++) {
safe_str_free(&arr[i].type);
}
free(arr);
sqlite3_finalize(stmt);
cbm_store_schema_free(out);
return CBM_NOT_FOUND;
}
arr = tmp;
cap = new_cap;
}
arr[n].type = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].count = sqlite3_column_int(stmt, SKIP_ONE);
arr[n].properties = NULL;
arr[n].property_count = 0;
n++;
}
if (scan_rc19 != SQLITE_DONE) { /* SCANCHK:19:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
out->edge_types = arr;
out->edge_type_count = n;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
out->edge_types = arr;
out->edge_type_count = n;
}
return CBM_STORE_OK;
}
void cbm_store_schema_free(cbm_schema_info_t *out) {
if (!out) {
return;
}
for (int i = 0; i < out->node_label_count; i++) {
safe_str_free(&out->node_labels[i].label);
for (int j = 0; j < out->node_labels[i].property_count; j++) {
free(out->node_labels[i].properties[j]);
}
free(out->node_labels[i].properties);
}
free(out->node_labels);
for (int i = 0; i < out->edge_type_count; i++) {
safe_str_free(&out->edge_types[i].type);
for (int j = 0; j < out->edge_types[i].property_count; j++) {
free(out->edge_types[i].properties[j]);
}
free(out->edge_types[i].properties);
}
free(out->edge_types);
for (int i = 0; i < out->rel_pattern_count; i++) {
safe_str_free(&out->rel_patterns[i]);
}
free(out->rel_patterns);
for (int i = 0; i < out->sample_func_count; i++) {
safe_str_free(&out->sample_func_names[i]);
}
free(out->sample_func_names);
for (int i = 0; i < out->sample_class_count; i++) {
safe_str_free(&out->sample_class_names[i]);
}
free(out->sample_class_names);
for (int i = 0; i < out->sample_qn_count; i++) {
safe_str_free(&out->sample_qns[i]);
}
free(out->sample_qns);
memset(out, 0, sizeof(*out));
}
/* ── Architecture helpers ───────────────────────────────────────── */
/* Extract sub-package from QN: project.dir1.dir2.sym → dir1 (4+ parts → [2], else [1]) */
const char *cbm_qn_to_package(const char *qn) {
if (!qn || !qn[0]) {
return "";
}
static CBM_TLS char buf[CBM_SZ_256];
/* Find dots and extract segment */
const char *dots[ST_QN_MAX_DOTS] = {NULL};
int ndots = 0;
for (const char *p = qn; *p && ndots < ST_QN_MAX_DOTS; p++) {
if (*p == '.') {
dots[ndots++] = p;
}
}
/* 4+ segments: return segment[2] */
if (ndots >= ST_QN_MIN_DOTS) {
const char *start = dots[SKIP_ONE] + SKIP_ONE;
int len = (int)(dots[ST_COL_2] - start);
if (len > 0 && len < (int)sizeof(buf)) {
memcpy(buf, start, len);
buf[len] = '\0';
return buf;
}
}
/* 2+ segments: return segment[1] */
if (ndots >= SKIP_ONE) {
const char *start = dots[0] + SKIP_ONE;
const char *end = (ndots >= ST_COL_2) ? dots[SKIP_ONE] : qn + strlen(qn);
int len = (int)(end - start);
if (len > 0 && len < (int)sizeof(buf)) {
memcpy(buf, start, len);
buf[len] = '\0';
return buf;
}
}
return "";
}
/* Extract top-level package from QN: project.dir1.rest → dir1 (segment[1]) */
const char *cbm_qn_to_top_package(const char *qn) {
if (!qn || !qn[0]) {
return "";
}
static CBM_TLS char buf[CBM_SZ_256];
const char *first_dot = strchr(qn, '.');
if (!first_dot) {
return "";
}
const char *start = first_dot + SKIP_ONE;
const char *second_dot = strchr(start, '.');
const char *end = second_dot ? second_dot : qn + strlen(qn);
int len = (int)(end - start);
if (len > 0 && len < (int)sizeof(buf)) {
memcpy(buf, start, len);
buf[len] = '\0';
return buf;
}
return "";
}
bool cbm_is_test_file_path(const char *fp) {
if (!fp || fp[0] == '\0') {
return false;
}
return strstr(fp, "test") != NULL;
}
/* File extension → language name mapping (table-driven) */
typedef struct {
const char *ext;
const char *lang;
} ext_lang_entry_t;
static const ext_lang_entry_t ext_lang_table[] = {
{".py", "Python"}, {".go", "Go"}, {".js", "JavaScript"}, {".jsx", "JavaScript"},
{".ts", "TypeScript"}, {".tsx", "TypeScript"}, {".rs", "Rust"}, {".java", "Java"},
{".cpp", "C++"}, {".cc", "C++"}, {".cxx", "C++"}, {".c", "C"},
{".h", "C"}, {".cs", "C#"}, {".php", "PHP"}, {".lua", "Lua"},
{".scala", "Scala"}, {".kt", "Kotlin"}, {".rb", "Ruby"}, {".sh", "Bash"},
{".bash", "Bash"}, {".zig", "Zig"}, {".ex", "Elixir"}, {".exs", "Elixir"},
{".hs", "Haskell"}, {".ml", "OCaml"}, {".mli", "OCaml"}, {".html", "HTML"},
{".css", "CSS"}, {".yaml", "YAML"}, {".yml", "YAML"}, {".toml", "TOML"},
{".hcl", "HCL"}, {".tf", "HCL"}, {".sql", "SQL"}, {".erl", "Erlang"},
{".swift", "Swift"}, {".dart", "Dart"}, {".groovy", "Groovy"}, {".pl", "Perl"},
{".r", "R"}, {".scss", "SCSS"}, {".vue", "Vue"}, {".svelte", "Svelte"},
{NULL, NULL},
};
static const char *ext_to_lang(const char *ext) {
if (!ext) {
return NULL;
}
for (const ext_lang_entry_t *e = ext_lang_table; e->ext; e++) {
if (strcmp(ext, e->ext) == 0) {
return e->lang;
}
}
return NULL;
}
/* Get lowercase file extension from path */
static const char *file_ext(const char *path) {
if (!path) {
return NULL;
}
const char *dot = strrchr(path, '.');
if (!dot) {
return NULL;
}
static CBM_TLS char buf[CBM_SZ_16];
int len = (int)strlen(dot);
if (len >= (int)sizeof(buf)) {
return NULL;
}
for (int i = 0; i < len; i++) {
buf[i] = (char)((dot[i] >= 'A' && dot[i] <= 'Z') ? dot[i] + CBM_SZ_32 : dot[i]);
}
buf[len] = '\0';
return buf;
}
/* ── Architecture aspect implementations ───────────────────────── */
static int arch_languages(cbm_store_t *s, const char *project, const char *path,
cbm_architecture_info_t *out) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
char sqlbuf[ST_SQL_BUF];
const char *base = "SELECT file_path FROM nodes WHERE project=?1 AND label='File'";
if (scoped) {
snprintf(sqlbuf, sizeof(sqlbuf), "%s%s", base, arch_path_scope_sql());
} else {
snprintf(sqlbuf, sizeof(sqlbuf), "%s", base);
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "arch_languages");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
if (scoped) {
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
}
/* Count per language using a simple parallel array */
const char *lang_names[CBM_SZ_64];
int lang_counts[CBM_SZ_64];
int nlang = 0;
int scan_rc20;
while ((scan_rc20 = sqlite3_step(stmt)) == SQLITE_ROW) {
const char *fp = (const char *)sqlite3_column_text(stmt, 0);
const char *ext = file_ext(fp);
const char *lang = ext_to_lang(ext);
if (!lang) {
continue;
}
int found = ST_FOUND;
for (int i = 0; i < nlang; i++) {
if (strcmp(lang_names[i], lang) == 0) {
found = i;
break;
}
}
if (found >= 0) {
lang_counts[found]++;
} else if (nlang < CBM_SZ_64) {
lang_names[nlang] = lang;
lang_counts[nlang] = SKIP_ONE;
nlang++;
}
}
if (scan_rc20 != SQLITE_DONE) { /* SCANCHK:20:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
/* Sort by count descending (simple insertion sort) */
for (int i = SKIP_ONE; i < nlang; i++) {
int j = i;
while (j > 0 && lang_counts[j] > lang_counts[j - SKIP_ONE]) {
int tc = lang_counts[j];
lang_counts[j] = lang_counts[j - SKIP_ONE];
lang_counts[j - SKIP_ONE] = tc;
const char *tn = lang_names[j];
lang_names[j] = lang_names[j - SKIP_ONE];
lang_names[j - SKIP_ONE] = tn;
j--;
}
}
if (nlang > CBM_DECIMAL_BASE) {
nlang = ST_MAX_LANG;
}
out->languages = (nlang > 0) ? calloc(nlang, sizeof(cbm_language_count_t)) : NULL;
out->language_count = nlang;
for (int i = 0; i < nlang; i++) {
out->languages[i].language = heap_strdup(lang_names[i]);
out->languages[i].file_count = lang_counts[i];
}
return CBM_STORE_OK;
}
static int arch_entry_points(cbm_store_t *s, const char *project, const char *path,
cbm_architecture_info_t *out) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
char sqlbuf[ST_SQL_BUF];
const char *base = "SELECT name, qualified_name, file_path FROM nodes "
"WHERE project=?1 AND json_extract(properties, '$.is_entry_point') = 1 "
"AND (json_extract(properties, '$.is_test') IS NULL OR "
"json_extract(properties, '$.is_test') != 1) "
"AND file_path NOT LIKE '%test%'";
if (scoped) {
snprintf(sqlbuf, sizeof(sqlbuf), "%s%s LIMIT 20", base, arch_path_scope_sql());
} else {
snprintf(sqlbuf, sizeof(sqlbuf), "%s LIMIT 20", base);
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "arch_entry_points");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
if (scoped) {
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
}
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_entry_point_t *arr = calloc(cap, sizeof(cbm_entry_point_t));
int scan_rc21;
while ((scan_rc21 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(cbm_entry_point_t));
}
arr[n].name = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].qualified_name = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
arr[n].file = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2));
n++;
}
if (scan_rc21 != SQLITE_DONE) { /* SCANCHK:21:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
out->entry_points = arr;
out->entry_point_count = n;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
out->entry_points = arr;
out->entry_point_count = n;
return CBM_STORE_OK;
}
/* Extract a JSON string value from a simple JSON object by key name. */
static char *extract_json_string_prop(const char *json, const char *key, int key_len) {
if (!json) {
return NULL;
}
const char *m = strstr(json, key);
if (!m) {
return NULL;
}
m = strchr(m + key_len, '"');
if (!m) {
return NULL;
}
m++;
const char *end = strchr(m, '"');
if (!end || end - m >= CBM_SZ_256) {
return NULL;
}
char vbuf[CBM_SZ_256];
memcpy(vbuf, m, end - m);
vbuf[end - m] = '\0';
return heap_strdup(vbuf);
}
static int arch_routes(cbm_store_t *s, const char *project, const char *path,
cbm_architecture_info_t *out) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
char sqlbuf[ST_SQL_BUF];
const char *base = "SELECT name, properties, COALESCE(file_path, '') FROM nodes "
"WHERE project=?1 AND label='Route' "
"AND (json_extract(properties, '$.is_test') IS NULL OR "
"json_extract(properties, '$.is_test') != 1)";
if (scoped) {
snprintf(sqlbuf, sizeof(sqlbuf), "%s%s LIMIT 20", base, arch_path_scope_sql());
} else {
snprintf(sqlbuf, sizeof(sqlbuf), "%s LIMIT 20", base);
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "arch_routes");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
if (scoped) {
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
}
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_route_info_t *arr = calloc(cap, sizeof(cbm_route_info_t));
int scan_rc22;
while ((scan_rc22 = sqlite3_step(stmt)) == SQLITE_ROW) {
const char *name = (const char *)sqlite3_column_text(stmt, 0);
const char *props = (const char *)sqlite3_column_text(stmt, SKIP_ONE);
const char *fp = (const char *)sqlite3_column_text(stmt, CBM_SZ_2);
if (cbm_is_test_file_path(fp)) {
continue;
}
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(cbm_route_info_t));
}
arr[n].method = heap_strdup("");
arr[n].path = heap_strdup(name);
arr[n].handler = heap_strdup("");
char *val;
val = extract_json_string_prop(props, "\"method\"", ST_METHOD_PROP_LEN);
if (val) {
safe_str_free(&arr[n].method);
arr[n].method = val;
}
val = extract_json_string_prop(props, "\"path\"", ST_PATH_PROP_LEN);
if (val) {
safe_str_free(&arr[n].path);
arr[n].path = val;
}
val = extract_json_string_prop(props, "\"handler\"", ST_HANDLER_PROP_LEN);
if (val) {
safe_str_free(&arr[n].handler);
arr[n].handler = val;
}
n++;
}
if (scan_rc22 != SQLITE_DONE) { /* SCANCHK:22:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
out->routes = arr;
out->route_count = n;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
out->routes = arr;
out->route_count = n;
return CBM_STORE_OK;
}
static int arch_hotspots(cbm_store_t *s, const char *project, const char *path,
cbm_architecture_info_t *out) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
char sqlbuf[ST_SQL_BUF];
const char *base = "SELECT n.name, n.qualified_name, COUNT(*) as fan_in "
"FROM nodes n JOIN edges e ON e.target_id = n.id AND e.type = 'CALLS' "
"WHERE n.project=?1 AND n.label IN ('Function', 'Method') "
"AND (json_extract(n.properties, '$.is_test') IS NULL OR "
"json_extract(n.properties, '$.is_test') != 1) "
"AND n.file_path NOT LIKE '%test%'";
if (scoped) {
snprintf(sqlbuf, sizeof(sqlbuf),
"%s AND (n.file_path = ?2 OR n.file_path LIKE ?3) "
"GROUP BY n.id ORDER BY fan_in DESC LIMIT 10",
base);
} else {
snprintf(sqlbuf, sizeof(sqlbuf), "%s GROUP BY n.id ORDER BY fan_in DESC LIMIT 10", base);
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "arch_hotspots");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
if (scoped) {
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
}
int cap = ST_INIT_CAP_8;
int n = 0;
cbm_hotspot_t *arr = calloc(cap, sizeof(cbm_hotspot_t));
int scan_rc23;
while ((scan_rc23 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(cbm_hotspot_t));
}
arr[n].name = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].qualified_name = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
arr[n].fan_in = sqlite3_column_int(stmt, CBM_SZ_2);
n++;
}
if (scan_rc23 != SQLITE_DONE) { /* SCANCHK:23:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
out->hotspots = arr;
out->hotspot_count = n;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
out->hotspots = arr;
out->hotspot_count = n;
return CBM_STORE_OK;
}
/* Look up package name for a node ID in the parallel arrays. nids must be
* sorted ascending (the node query orders by id) — a linear scan here is
* O(E×N) across the edge loop and spun for >10 minutes on Linux-kernel-sized
* graphs (~1.4M defs × ~1.4M CALLS edges). */
static const char *lookup_pkg(const int64_t *nids, char **npkgs, int nn, int64_t id) {
int lo = 0;
int hi = nn - SKIP_ONE;
while (lo <= hi) {
int mid = lo + (hi - lo) / PAIR_LEN;
if (nids[mid] == id) {
return npkgs[mid];
}
if (nids[mid] < id) {
lo = mid + SKIP_ONE;
} else {
hi = mid - SKIP_ONE;
}
}
return NULL;
}
/* Accumulate a cross-package boundary into parallel arrays. */
static void accum_boundary(const char *src_pkg, const char *tgt_pkg, char **bfroms, char **btos,
int *bcounts, int *bn, int bcap) {
int found = ST_FOUND;
for (int i = 0; i < *bn; i++) {
if (strcmp(bfroms[i], src_pkg) == 0 && strcmp(btos[i], tgt_pkg) == 0) {
found = i;
break;
}
}
if (found >= 0) {
bcounts[found]++;
} else if (*bn < bcap) {
bfroms[*bn] = heap_strdup(src_pkg);
btos[*bn] = heap_strdup(tgt_pkg);
bcounts[*bn] = SKIP_ONE;
(*bn)++;
}
}
static int arch_boundaries(cbm_store_t *s, const char *project, const char *path,
cbm_cross_pkg_boundary_t **out_arr, int *out_count) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
char nsqlbuf[ST_SQL_BUF];
const char *nbase =
"SELECT id, qualified_name, file_path FROM nodes WHERE project=?1 AND label IN "
"(" CBM_SQL_CALLABLE_OR_TYPE_LABELS ")";
if (scoped) {
snprintf(nsqlbuf, sizeof(nsqlbuf), "%s%s ORDER BY id", nbase, arch_path_scope_sql());
} else {
snprintf(nsqlbuf, sizeof(nsqlbuf), "%s ORDER BY id", nbase);
}
sqlite3_stmt *nstmt = NULL;
if (sqlite3_prepare_v2(s->db, nsqlbuf, CBM_NOT_FOUND, &nstmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "arch_boundaries_nodes");
return CBM_STORE_ERR;
}
bind_text(nstmt, SKIP_ONE, project);
if (scoped) {
arch_bind_path_scope(nstmt, ST_COL_2, ST_COL_3, norm, like);
}
int ncap = CBM_SZ_256;
int nn = 0;
int64_t *nids = malloc(ncap * sizeof(int64_t));
char **npkgs = malloc(ncap * sizeof(char *));
int scan_rc24;
while ((scan_rc24 = sqlite3_step(nstmt)) == SQLITE_ROW) {
if (nn >= ncap) {
ncap *= ST_GROWTH;
nids = safe_realloc(nids, ncap * sizeof(int64_t));
npkgs = safe_realloc(npkgs, ncap * sizeof(char *));
}
int64_t nid = sqlite3_column_int64(nstmt, 0);
nids[nn] = nid;
const char *qn = (const char *)sqlite3_column_text(nstmt, SKIP_ONE);
npkgs[nn] = heap_strdup(cbm_qn_to_package(qn));
nn++;
}
if (scan_rc24 != SQLITE_DONE) { /* SCANCHK:24:nstmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(nstmt);
for (int bi = 0; bi < nn; bi++) {
free(npkgs[bi]);
}
free(nids);
free(npkgs);
return CBM_STORE_ERR;
}
sqlite3_finalize(nstmt);
/* Scan edges, count cross-package calls */
const char *esql = "SELECT source_id, target_id FROM edges WHERE project=?1 AND type='CALLS'";
sqlite3_stmt *estmt = NULL;
if (sqlite3_prepare_v2(s->db, esql, CBM_NOT_FOUND, &estmt, NULL) != SQLITE_OK) {
for (int i = 0; i < nn; i++) {
free(npkgs[i]);
}
free(nids);
free(npkgs);
store_set_error_sqlite(s, "arch_boundaries_edges");
return CBM_STORE_ERR;
}
bind_text(estmt, SKIP_ONE, project);
int bcap = CBM_SZ_32;
int bn = 0;
char **bfroms = malloc(bcap * sizeof(char *));
char **btos = malloc(bcap * sizeof(char *));
int *bcounts = malloc(bcap * sizeof(int));
int scan_rc25;
while ((scan_rc25 = sqlite3_step(estmt)) == SQLITE_ROW) {
int64_t src_id = sqlite3_column_int64(estmt, 0);
int64_t tgt_id = sqlite3_column_int64(estmt, SKIP_ONE);
const char *src_pkg = lookup_pkg(nids, npkgs, nn, src_id);
const char *tgt_pkg = lookup_pkg(nids, npkgs, nn, tgt_id);
if (!src_pkg || !tgt_pkg || !src_pkg[0] || !tgt_pkg[0] || strcmp(src_pkg, tgt_pkg) == 0) {
continue;
}
accum_boundary(src_pkg, tgt_pkg, bfroms, btos, bcounts, &bn, bcap);
}
if (scan_rc25 != SQLITE_DONE) { /* SCANCHK:25:estmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(estmt);
for (int bi = 0; bi < nn; bi++) {
free(npkgs[bi]);
}
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);
for (int i = 0; i < nn; i++) {
free(npkgs[i]);
}
free(nids);
free(npkgs);
/* Sort by count descending */
for (int i = SKIP_ONE; i < bn; i++) {
int j = i;
while (j > 0 && bcounts[j] > bcounts[j - SKIP_ONE]) {
int tc = bcounts[j];
bcounts[j] = bcounts[j - SKIP_ONE];
bcounts[j - SKIP_ONE] = tc;
char *tf = bfroms[j];
bfroms[j] = bfroms[j - SKIP_ONE];
bfroms[j - SKIP_ONE] = tf;
char *tt = btos[j];
btos[j] = btos[j - SKIP_ONE];
btos[j - SKIP_ONE] = tt;
j--;
}
}
if (bn > CBM_DECIMAL_BASE) {
for (int i = ST_MAX_ITERATIONS; i < bn; i++) {
free(bfroms[i]);
free(btos[i]);
}
bn = ST_MAX_ITERATIONS;
}
cbm_cross_pkg_boundary_t *result =
(bn > 0) ? calloc(bn, sizeof(cbm_cross_pkg_boundary_t)) : NULL;
for (int i = 0; i < bn; i++) {
result[i].from = bfroms[i];
result[i].to = btos[i];
result[i].call_count = bcounts[i];
}
free(bfroms);
free(btos);
free(bcounts);
*out_arr = result;
*out_count = bn;
return CBM_STORE_OK;
}
#define MAX_PREVIEW_NAMES 15
/* Fallback: derive packages from QN segments when no Package nodes exist. */
static int arch_packages_from_qn(cbm_store_t *s, const char *project, const char *path,
cbm_package_summary_t **out_arr, int *out_count) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
char qsqlbuf[ST_SQL_BUF];
const char *qbase = "SELECT qualified_name FROM nodes WHERE project=?1 AND label IN "
"(" CBM_SQL_CALLABLE_OR_TYPE_LABELS ")";
if (scoped) {
snprintf(qsqlbuf, sizeof(qsqlbuf), "%s%s", qbase, arch_path_scope_sql());
} else {
snprintf(qsqlbuf, sizeof(qsqlbuf), "%s", qbase);
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, qsqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "arch_packages_qn");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
if (scoped) {
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
}
char *pnames[CBM_SZ_64];
int pcounts[CBM_SZ_64];
int np = 0;
int scan_rc26;
while ((scan_rc26 = sqlite3_step(stmt)) == SQLITE_ROW) {
const char *qn = (const char *)sqlite3_column_text(stmt, 0);
const char *pkg = cbm_qn_to_package(qn);
if (!pkg[0]) {
continue;
}
int found = ST_FOUND;
for (int i = 0; i < np; i++) {
if (strcmp(pnames[i], pkg) == 0) {
found = i;
break;
}
}
if (found >= 0) {
pcounts[found]++;
} else if (np < CBM_SZ_64) {
pnames[np] = heap_strdup(pkg);
pcounts[np] = SKIP_ONE;
np++;
}
}
if (scan_rc26 != SQLITE_DONE) { /* SCANCHK:26:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
for (int pi = 0; pi < np; pi++) {
free(pnames[pi]);
}
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
/* Sort by count desc */
for (int i = SKIP_ONE; i < np; i++) {
int j = i;
while (j > 0 && pcounts[j] > pcounts[j - SKIP_ONE]) {
int tc = pcounts[j];
pcounts[j] = pcounts[j - SKIP_ONE];
pcounts[j - SKIP_ONE] = tc;
char *tn = pnames[j];
pnames[j] = pnames[j - SKIP_ONE];
pnames[j - SKIP_ONE] = tn;
j--;
}
}
if (np > MAX_PREVIEW_NAMES) {
for (int i = MAX_PREVIEW_NAMES; i < np; i++) {
free(pnames[i]);
}
np = MAX_PREVIEW_NAMES;
}
cbm_package_summary_t *arr = (np > 0) ? calloc(np, sizeof(cbm_package_summary_t)) : NULL;
for (int i = 0; i < np; i++) {
arr[i].name = pnames[i];
arr[i].node_count = pcounts[i];
}
*out_arr = arr;
*out_count = np;
return CBM_STORE_OK;
}
static int arch_packages(cbm_store_t *s, const char *project, const char *path,
cbm_architecture_info_t *out) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
char sqlbuf[ST_SQL_BUF];
const char *base = "SELECT n.name, COUNT(*) as cnt FROM nodes n "
"WHERE n.project=?1 AND n.label='Package'";
if (scoped) {
snprintf(sqlbuf, sizeof(sqlbuf),
"%s AND (n.file_path = ?2 OR n.file_path LIKE ?3) "
"GROUP BY n.name ORDER BY cnt DESC LIMIT 15",
base);
} else {
snprintf(sqlbuf, sizeof(sqlbuf), "%s GROUP BY n.name ORDER BY cnt DESC LIMIT 15", base);
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "arch_packages");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
if (scoped) {
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
}
int cap = ST_INIT_CAP_16;
int n = 0;
cbm_package_summary_t *arr = calloc(cap, sizeof(cbm_package_summary_t));
int scan_rc27;
while ((scan_rc27 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(cbm_package_summary_t));
}
arr[n].name = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
arr[n].node_count = sqlite3_column_int(stmt, SKIP_ONE);
n++;
}
if (scan_rc27 != SQLITE_DONE) { /* SCANCHK:27:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
out->packages = arr;
out->package_count = n;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
/* Fallback: group by QN segment if no Package nodes */
if (n == 0) {
free(arr);
int rc = arch_packages_from_qn(s, project, path, &arr, &n);
if (rc != CBM_STORE_OK) {
return rc;
}
}
out->packages = arr;
out->package_count = n;
return CBM_STORE_OK;
}
static void classify_layer(const char *pkg, int in, int out_deg, bool has_routes,
bool has_entry_points, const char **layer, const char **reason) {
static CBM_TLS char reason_buf[CBM_SZ_128];
if (has_entry_points && out_deg > 0 && in == 0) {
*layer = "entry";
*reason = "has entry points, only outbound calls";
return;
}
if (has_routes) {
*layer = "api";
*reason = "has HTTP route definitions";
return;
}
if (in > out_deg && in > ST_MIN_INDEGREE) {
snprintf(reason_buf, sizeof(reason_buf), "high fan-in (%d in, %d out)", in, out_deg);
*layer = "core";
*reason = reason_buf;
return;
}
if (out_deg == 0 && in > 0) {
*layer = "leaf";
*reason = "only inbound calls, no outbound";
return;
}
if (in == 0 && out_deg > 0) {
*layer = "entry";
*reason = "only outbound calls";
return;
}
snprintf(reason_buf, sizeof(reason_buf), "fan-in=%d, fan-out=%d", in, out_deg);
*layer = "internal";
*reason = reason_buf;
(void)pkg;
}
/* Find or insert a package name, returning its index. Returns -1 if full. */
static int find_or_add_pkg(char **all_pkgs, int *npkgs, int max_pkgs, const char *pkg) {
for (int j = 0; j < *npkgs; j++) {
if (strcmp(all_pkgs[j], pkg) == 0) {
return j;
}
}
if (*npkgs < max_pkgs) {
int idx = *npkgs;
all_pkgs[idx] = heap_strdup(pkg);
(*npkgs)++;
return idx;
}
return CBM_NOT_FOUND;
}
/* Check if a package name appears in an array. */
static bool pkg_in_list(const char *pkg, char **list, int count) {
for (int j = 0; j < count; j++) {
if (strcmp(pkg, list[j]) == 0) {
return true;
}
}
return false;
}
/* Collect package names from nodes matching a SQL query (must use ?1 = project). */
static int collect_pkg_names(cbm_store_t *s, const char *sql, const char *project, const char *path,
char **pkgs, int max_pkgs) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
char sqlbuf[ST_SQL_BUF];
if (scoped) {
snprintf(sqlbuf, sizeof(sqlbuf), "%s%s", sql, arch_path_scope_sql());
} else {
snprintf(sqlbuf, sizeof(sqlbuf), "%s", sql);
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK || !stmt) {
if (stmt) {
sqlite3_finalize(stmt);
}
return CBM_NOT_FOUND;
}
bind_text(stmt, SKIP_ONE, project);
if (scoped) {
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
}
int count = 0;
while (sqlite3_step(stmt) == SQLITE_ROW && count < max_pkgs) {
const char *qn = (const char *)sqlite3_column_text(stmt, 0);
pkgs[count++] = heap_strdup(cbm_qn_to_package(qn));
}
sqlite3_finalize(stmt);
return count;
}
static int arch_layers(cbm_store_t *s, const char *project, const char *path,
cbm_architecture_info_t *out) {
/* Get boundaries for fan analysis */
cbm_cross_pkg_boundary_t *boundaries = NULL;
int bcount = 0;
int rc = arch_boundaries(s, project, path, &boundaries, &bcount);
if (rc != CBM_STORE_OK) {
return rc;
}
/* Collect route and entry point packages */
char *route_pkgs[CBM_SZ_32];
int nrpkgs =
collect_pkg_names(s, "SELECT qualified_name FROM nodes WHERE project=?1 AND label='Route'",
project, path, route_pkgs, CBM_SZ_32);
char *entry_pkgs[CBM_SZ_32];
int nepkgs = collect_pkg_names(s,
"SELECT qualified_name FROM nodes WHERE project=?1 AND "
"json_extract(properties, '$.is_entry_point') = 1",
project, path, entry_pkgs, CBM_SZ_32);
/* Compute fan-in/out per package */
char *all_pkgs[CBM_SZ_64];
int fan_in[CBM_SZ_64];
int fan_out[CBM_SZ_64];
int npkgs = 0;
memset(fan_in, 0, sizeof(fan_in));
memset(fan_out, 0, sizeof(fan_out));
for (int i = 0; i < bcount; i++) {
int fi = find_or_add_pkg(all_pkgs, &npkgs, ST_MAX_PKGS, boundaries[i].from);
if (fi >= 0) {
fan_out[fi] += boundaries[i].call_count;
}
int ti = find_or_add_pkg(all_pkgs, &npkgs, ST_MAX_PKGS, boundaries[i].to);
if (ti >= 0) {
fan_in[ti] += boundaries[i].call_count;
}
}
/* Also include route/entry packages */
for (int i = 0; i < nrpkgs; i++) {
find_or_add_pkg(all_pkgs, &npkgs, ST_MAX_PKGS, route_pkgs[i]);
}
for (int i = 0; i < nepkgs; i++) {
find_or_add_pkg(all_pkgs, &npkgs, ST_MAX_PKGS, entry_pkgs[i]);
}
/* Classify each package */
out->layers = (npkgs > 0) ? calloc(npkgs, sizeof(cbm_package_layer_t)) : NULL;
out->layer_count = npkgs;
for (int i = 0; i < npkgs; i++) {
bool has_route = pkg_in_list(all_pkgs[i], route_pkgs, nrpkgs);
bool has_entry = pkg_in_list(all_pkgs[i], entry_pkgs, nepkgs);
const char *layer;
const char *reason;
classify_layer(all_pkgs[i], fan_in[i], fan_out[i], has_route, has_entry, &layer, &reason);
out->layers[i].name = all_pkgs[i]; /* transfer ownership */
out->layers[i].layer = heap_strdup(layer);
out->layers[i].reason = heap_strdup(reason);
}
/* Sort layers by name */
for (int i = SKIP_ONE; i < npkgs; i++) {
int j = i;
while (j > 0 && strcmp(out->layers[j].name, out->layers[j - SKIP_ONE].name) < 0) {
cbm_package_layer_t tmp = out->layers[j];
out->layers[j] = out->layers[j - SKIP_ONE];
out->layers[j - SKIP_ONE] = tmp;
j--;
}
}
/* Cleanup */
for (int i = 0; i < bcount; i++) {
safe_str_free(&boundaries[i].from);
safe_str_free(&boundaries[i].to);
}
free(boundaries);
for (int i = 0; i < nrpkgs; i++) {
free(route_pkgs[i]);
}
for (int i = 0; i < nepkgs; i++) {
free(entry_pkgs[i]);
}
return CBM_STORE_OK;
}
/* Add a child to a dir entry if not already present. */
static void dir_add_child(char ***children, int *child_count, int *child_cap, const char *child) {
for (int k = 0; k < *child_count; k++) {
if (strcmp((*children)[k], child) == 0) {
return;
}
}
if (*child_count >= *child_cap) {
*child_cap = *child_cap ? *child_cap * PAIR_LEN : ST_INIT_CAP_4;
*children = realloc(*children, *child_cap * sizeof(char *));
}
(*children)[(*child_count)++] = heap_strdup(child);
}
/* Find or create a directory entry by path. Returns its index, or -1 if full. */
static int dir_find_or_create(char **dir_paths, int *dir_child_counts, char ***dir_children,
int *dir_children_caps, int *dn, int dcap, const char *dir) {
for (int i = 0; i < *dn; i++) {
if (strcmp(dir_paths[i], dir) == 0) {
return i;
}
}
if (*dn < dcap) {
int idx = *dn;
dir_paths[idx] = heap_strdup(dir);
dir_child_counts[idx] = 0;
dir_children[idx] = NULL;
dir_children_caps[idx] = 0;
(*dn)++;
return idx;
}
return CBM_NOT_FOUND;
}
/* Create a file tree entry by checking if a path is a file and counting dir children. */
static cbm_file_tree_entry_t make_tree_entry(const char *path, char **files, int fn,
char **dir_paths, const int *dir_child_counts,
int dn) {
cbm_file_tree_entry_t e = {0};
e.path = heap_strdup(path);
bool is_file = false;
for (int f = 0; f < fn; f++) {
if (strcmp(files[f], path) == 0) {
is_file = true;
break;
}
}
e.type = heap_strdup(is_file ? "file" : "dir");
for (int d = 0; d < dn; d++) {
if (strcmp(dir_paths[d], path) == 0) {
e.children = dir_child_counts[d];
break;
}
}
return e;
}
/* Split a path by '/' into parts. Returns number of parts. */
static int split_path_parts(const char *fp, char *buf, int buf_sz, char **parts, int max_parts) {
strncpy(buf, fp, buf_sz - SKIP_ONE);
buf[buf_sz - SKIP_ONE] = '\0';
int nparts = 0;
char *p = buf;
parts[nparts++] = p;
while (*p && nparts < max_parts) {
if (*p == '/') {
*p = '\0';
parts[nparts++] = p + SKIP_ONE;
}
p++;
}
return nparts;
}
/* Register dir hierarchy for one file path. */
static void arch_register_file_dirs(const char *fp, char **dir_paths, int *dir_child_counts,
char ***dir_children, int *dir_children_caps, int *dn,
int dcap) {
char tmp[CBM_SZ_512];
char *parts[ST_SEARCH_MAX_BINDS];
int nparts = split_path_parts(fp, tmp, (int)sizeof(tmp), parts, ST_SEARCH_MAX_BINDS);
int ri = dir_find_or_create(dir_paths, dir_child_counts, dir_children, dir_children_caps, dn,
dcap, "");
if (ri >= 0 && nparts > 0) {
dir_add_child(&dir_children[ri], &dir_child_counts[ri], &dir_children_caps[ri], parts[0]);
}
for (int depth = 0; depth < nparts - SKIP_ONE && depth < ST_MAX_PATH_DEPTH; depth++) {
char dir[CBM_SZ_512] = "";
for (int k = 0; k <= depth; k++) {
if (k > 0) {
strcat(dir, "/");
}
strcat(dir, parts[k]);
}
const char *child = (depth + SKIP_ONE < nparts) ? parts[depth + SKIP_ONE] : NULL;
if (!child) {
continue;
}
int di = dir_find_or_create(dir_paths, dir_child_counts, dir_children, dir_children_caps,
dn, dcap, dir);
if (di >= 0) {
dir_add_child(&dir_children[di], &dir_child_counts[di], &dir_children_caps[di], child);
}
}
}
/* Count the number of '/' in a string. */
static int count_slashes(const char *s) {
int n = 0;
for (; *s; s++) {
if (*s == '/') {
n++;
}
}
return n;
}
/* Push a tree entry, growing the array if needed. */
static void push_tree_entry(cbm_file_tree_entry_t **entries, int *en, int *ecap,
cbm_file_tree_entry_t e) {
if (*en >= *ecap) {
*ecap *= ST_GROWTH;
*entries = safe_realloc(*entries, *ecap * sizeof(cbm_file_tree_entry_t));
}
(*entries)[(*en)++] = e;
}
/* Collect tree entries from dir arrays. */
static void arch_collect_entries(char **dir_paths, int *dir_child_counts, char ***dir_children,
int dn, char **files, int fn, cbm_file_tree_entry_t **entries_out,
int *en_out) {
int ecap = CBM_SZ_64;
int en = 0;
cbm_file_tree_entry_t *entries = calloc(ecap, sizeof(cbm_file_tree_entry_t));
/* Root children */
for (int i = 0; i < dn; i++) {
if (strcmp(dir_paths[i], "") != 0) {
continue;
}
for (int k = 0; k < dir_child_counts[i]; k++) {
push_tree_entry(
&entries, &en, &ecap,
make_tree_entry(dir_children[i][k], files, fn, dir_paths, dir_child_counts, dn));
}
}
/* Non-root dir children (depth < ST_COL_3) */
for (int i = 0; i < dn; i++) {
if (strcmp(dir_paths[i], "") == 0 || count_slashes(dir_paths[i]) >= ST_MAX_PATH_DEPTH) {
continue;
}
for (int k = 0; k < dir_child_counts[i]; k++) {
char path[CBM_SZ_512];
snprintf(path, sizeof(path), "%s/%s", dir_paths[i], dir_children[i][k]);
push_tree_entry(&entries, &en, &ecap,
make_tree_entry(path, files, fn, dir_paths, dir_child_counts, dn));
}
}
/* Sort by path */
for (int i = SKIP_ONE; i < en; i++) {
int j = i;
while (j > 0 && strcmp(entries[j].path, entries[j - SKIP_ONE].path) < 0) {
cbm_file_tree_entry_t tmp = entries[j];
entries[j] = entries[j - SKIP_ONE];
entries[j - SKIP_ONE] = tmp;
j--;
}
}
*entries_out = entries;
*en_out = en;
}
/* Free dir arrays. */
static void arch_free_dirs(char **dir_paths, int *dir_child_counts, char ***dir_children,
int *dir_children_caps, int dn, char **files, int fn) {
for (int i = 0; i < dn; i++) {
free(dir_paths[i]);
for (int k = 0; k < dir_child_counts[i]; k++) {
free(dir_children[i][k]);
}
free(dir_children[i]);
}
free(dir_paths);
free(dir_child_counts);
free(dir_children);
free(dir_children_caps);
for (int i = 0; i < fn; i++) {
free(files[i]);
}
free(files);
}
static int arch_file_tree(cbm_store_t *s, const char *project, const char *path,
cbm_architecture_info_t *out) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
char sqlbuf[ST_SQL_BUF];
const char *base = "SELECT file_path FROM nodes WHERE project=?1 AND label='File'";
if (scoped) {
snprintf(sqlbuf, sizeof(sqlbuf), "%s%s", base, arch_path_scope_sql());
} else {
snprintf(sqlbuf, sizeof(sqlbuf), "%s", base);
}
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sqlbuf, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "arch_file_tree");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
if (scoped) {
arch_bind_path_scope(stmt, ST_COL_2, ST_COL_3, norm, like);
}
int fcap = CBM_SZ_32;
int fn = 0;
char **files = malloc(fcap * sizeof(char *));
int dcap = CBM_SZ_64;
int dn = 0;
char **dir_paths = calloc(dcap, sizeof(char *));
int *dir_child_counts = calloc(dcap, sizeof(int));
char ***dir_children = calloc(dcap, sizeof(char **));
int *dir_children_caps = calloc(dcap, sizeof(int));
int scan_rc28;
while ((scan_rc28 = sqlite3_step(stmt)) == SQLITE_ROW) {
const char *fp = (const char *)sqlite3_column_text(stmt, 0);
if (!fp) {
continue;
}
if (fn >= fcap) {
fcap *= ST_GROWTH;
files = safe_realloc(files, fcap * sizeof(char *));
}
files[fn++] = heap_strdup(fp);
arch_register_file_dirs(fp, dir_paths, dir_child_counts, dir_children, dir_children_caps,
&dn, dcap);
}
if (scan_rc28 != SQLITE_DONE) { /* SCANCHK:28:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
arch_free_dirs(dir_paths, dir_child_counts, dir_children, dir_children_caps, dn, files, fn);
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
arch_collect_entries(dir_paths, dir_child_counts, dir_children, dn, files, fn, &out->file_tree,
&out->file_tree_count);
arch_free_dirs(dir_paths, dir_child_counts, dir_children, dir_children_caps, dn, files, fn);
return CBM_STORE_OK;
}
/* ── Louvain community detection ───────────────────────────────── */
/* Build deduplicated, normalized edge weight arrays from raw edges.
* Returns total number of unique edges in *out_wn. */
/* Find the index of a node ID in the nodes array, or -1. */
static int louvain_node_index(const int64_t *nodes, int n, int64_t id) {
for (int i = 0; i < n; i++) {
if (nodes[i] == id) {
return i;
}
}
return CBM_NOT_FOUND;
}
static void louvain_build_weights(const int64_t *nodes, int n, const cbm_louvain_edge_t *edges,
int edge_count, int **out_wsi, int **out_wdi, double **out_ww,
int *out_wn) {
int wcap = edge_count > 0 ? edge_count : SKIP_ONE;
int wn = 0;
int *wsi = malloc(wcap * sizeof(int));
int *wdi = malloc(wcap * sizeof(int));
double *ww = malloc(wcap * sizeof(double));
for (int e = 0; e < edge_count; e++) {
int si = louvain_node_index(nodes, n, edges[e].src);
int di = louvain_node_index(nodes, n, edges[e].dst);
if (si < 0 || di < 0 || si == di) {
continue;
}
if (si > di) {
int tmp = si;
si = di;
di = tmp;
}
int found = ST_FOUND;
for (int i = 0; i < wn; i++) {
if (wsi[i] == si && wdi[i] == di) {
found = i;
break;
}
}
if (found >= 0) {
ww[found] += (double)SKIP_ONE;
} else {
if (wn >= wcap) {
wcap *= ST_GROWTH;
wsi = safe_realloc(wsi, wcap * sizeof(int));
wdi = safe_realloc(wdi, wcap * sizeof(int));
ww = safe_realloc(ww, wcap * sizeof(double));
}
wsi[wn] = si;
wdi[wn] = di;
ww[wn] = (double)SKIP_ONE;
wn++;
}
}
*out_wsi = wsi;
*out_wdi = wdi;
*out_ww = ww;
*out_wn = wn;
}
enum { LEIDEN_MAX_LEVELS = 64, LEIDEN_MOVE_PASS_CAP = 100 };
/* Weighted undirected graph in CSR form. Each undirected edge is stored as
* two directed entries; k[i] is the weighted degree of node i. Self-loops are
* never materialised — an aggregate node's intra-community weight is folded
* into k[i] (which is preserved across levels), while nbr/w hold only
* inter-community edges. The modularity gain therefore uses k[] for the
* null-model term and nbr/w for connectivity, so intra weight affects the
* degree but is never mistaken for an edge to another community. */
typedef struct {
int n;
int *off; /* CSR offsets, length n + 1 */
int *nbr; /* neighbour indices, length off[n] */
double *w; /* edge weights aligned with nbr */
double *k; /* weighted degree per node */
} cbm_lg_t;
static void lg_free(cbm_lg_t *g) {
free(g->off);
free(g->nbr);
free(g->w);
free(g->k);
g->off = NULL;
g->nbr = NULL;
g->w = NULL;
g->k = NULL;
}
/* Build a CSR graph from a deduplicated undirected edge list. */
static int lg_build(int n, const int *wsi, const int *wdi, const double *ww, int wn,
cbm_lg_t *out) {
int *off = calloc((size_t)n + 1, sizeof(int));
double *k = calloc((size_t)n, sizeof(double));
int *fill = malloc((size_t)n * sizeof(int));
if (!off || !k || !fill) {
free(off);
free(k);
free(fill);
return CBM_NOT_FOUND;
}
for (int e = 0; e < wn; e++) {
off[wsi[e] + 1]++;
off[wdi[e] + 1]++;
}
for (int i = 0; i < n; i++) {
off[i + 1] += off[i];
}
int total = off[n];
/* 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);
free(fill);
free(nbr);
free(w);
return CBM_NOT_FOUND;
}
memcpy(fill, off, (size_t)n * sizeof(int));
for (int e = 0; e < wn; e++) {
int a = wsi[e];
int b = wdi[e];
double we = ww[e];
nbr[fill[a]] = b;
w[fill[a]] = we;
fill[a]++;
nbr[fill[b]] = a;
w[fill[b]] = we;
fill[b]++;
k[a] += we;
k[b] += we;
}
free(fill);
out->n = n;
out->off = off;
out->nbr = nbr;
out->w = w;
out->k = k;
return CBM_STORE_OK;
}
/* Local-moving phase: greedily move each node to the neighbouring community
* with the highest modularity gain, using a work queue seeded with every node
* and re-queueing only the neighbours of a node that actually moved. Mutates
* comm[] in place. */
static void leiden_move(const cbm_lg_t *g, int *comm, double gamma, double twom) {
int n = g->n;
double *stot = calloc((size_t)n, sizeof(double));
double *acc = calloc((size_t)n, sizeof(double));
int *queue = malloc((size_t)n * sizeof(int));
int *dirty = malloc((size_t)n * sizeof(int));
bool *inq = calloc((size_t)n, sizeof(bool));
if (!stot || !acc || !queue || !dirty || !inq) {
free(stot);
free(acc);
free(queue);
free(dirty);
free(inq);
return;
}
for (int i = 0; i < n; i++) {
stot[comm[i]] += g->k[i];
queue[i] = i;
inq[i] = true;
}
int qhead = 0;
int qcount = n;
long cap = (long)n * LEIDEN_MOVE_PASS_CAP + LEIDEN_MAX_LEVELS;
while (qcount > 0 && cap-- > 0) {
int v = queue[qhead];
qhead = (qhead + 1) % n;
qcount--;
inq[v] = false;
int cv = comm[v];
int ndirty = 0;
for (int e = g->off[v]; e < g->off[v + 1]; e++) {
int u = g->nbr[e];
if (u == v) {
continue;
}
int cu = comm[u];
if (acc[cu] == 0.0) {
dirty[ndirty++] = cu;
}
acc[cu] += g->w[e];
}
stot[cv] -= g->k[v];
double kv = g->k[v];
int best_c = cv;
double best_gain = acc[cv] - gamma * kv * stot[cv] / twom;
for (int d = 0; d < ndirty; d++) {
int c = dirty[d];
double gain = acc[c] - gamma * kv * stot[c] / twom;
if (gain > best_gain) {
best_gain = gain;
best_c = c;
}
}
stot[best_c] += kv;
comm[v] = best_c;
if (best_c != cv) {
for (int e = g->off[v]; e < g->off[v + 1]; e++) {
int u = g->nbr[e];
if (comm[u] != best_c && !inq[u] && qcount < n) {
queue[(qhead + qcount) % n] = u;
qcount++;
inq[u] = true;
}
}
}
for (int d = 0; d < ndirty; d++) {
acc[dirty[d]] = 0.0;
}
}
free(stot);
free(acc);
free(queue);
free(dirty);
free(inq);
}
/* Compact community labels in comm[] to the dense range [0, returned count). */
static int leiden_relabel(int *comm, int n) {
int *map = malloc((size_t)n * sizeof(int));
if (!map) {
return n;
}
for (int i = 0; i < n; i++) {
map[i] = CBM_NOT_FOUND;
}
int next = 0;
for (int i = 0; i < n; i++) {
int c = comm[i];
if (map[c] == CBM_NOT_FOUND) {
map[c] = next++;
}
comm[i] = map[c];
}
free(map);
return next;
}
/* Refinement phase: within each move-phase community, merge singleton nodes
* into the best connected sub-community (positive modularity gain, edge must
* exist). This re-derives communities bottom-up so each one is guaranteed
* internally connected — the defect single-level Louvain suffers from, which
* fragments the graph into hundreds of tiny noisy clusters. Writes
* sub-community labels into refined[] and returns their count. */
static int leiden_refine(const cbm_lg_t *g, const int *comm, double gamma, double twom,
int *refined) {
int n = g->n;
double *stot = calloc((size_t)n, sizeof(double));
double *acc = calloc((size_t)n, sizeof(double));
int *rsize = malloc((size_t)n * sizeof(int));
int *dirty = malloc((size_t)n * sizeof(int));
if (!stot || !acc || !rsize || !dirty) {
free(stot);
free(acc);
free(rsize);
free(dirty);
for (int i = 0; i < n; i++) {
refined[i] = i;
}
return leiden_relabel(refined, n);
}
for (int i = 0; i < n; i++) {
refined[i] = i;
stot[i] = g->k[i];
rsize[i] = 1;
}
for (int v = 0; v < n; v++) {
if (rsize[refined[v]] != 1) {
continue; /* only singletons merge, per the refinement rule */
}
int cv = comm[v];
int ndirty = 0;
for (int e = g->off[v]; e < g->off[v + 1]; e++) {
int u = g->nbr[e];
if (u == v || comm[u] != cv) {
continue; /* stay within the move-phase community */
}
int ru = refined[u];
if (acc[ru] == 0.0) {
dirty[ndirty++] = ru;
}
acc[ru] += g->w[e];
}
int rv = refined[v];
double kv = g->k[v];
stot[rv] -= kv;
int best_r = rv;
double best_gain = 0.0;
for (int d = 0; d < ndirty; d++) {
int r = dirty[d];
if (r == rv) {
continue;
}
double gain = acc[r] - gamma * kv * stot[r] / twom;
if (gain > best_gain) {
best_gain = gain;
best_r = r;
}
}
if (best_r != rv) {
refined[v] = best_r;
stot[best_r] += kv;
rsize[best_r]++;
rsize[rv]--;
} else {
stot[rv] += kv;
}
for (int d = 0; d < ndirty; d++) {
acc[dirty[d]] = 0.0;
}
}
free(stot);
free(acc);
free(rsize);
free(dirty);
return leiden_relabel(refined, n);
}
/* Aggregation phase: collapse each refined sub-community into a single node.
* Builds the coarser graph in *out and records, for each aggregate node, the
* move-phase community it belonged to in seed[] so the next level starts from
* the coarse structure rather than from singletons. */
static int leiden_aggregate(const cbm_lg_t *g, const int *refined, int r_count, const int *comm,
cbm_lg_t *out, int *seed) {
int n = g->n;
double *k2 = calloc((size_t)r_count, sizeof(double));
int *gcount = calloc((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));
if (!k2 || !gcount || !gstart || !members || !fill || !acc || !dirty || !off2) {
free(k2);
free(gcount);
free(gstart);
free(members);
free(fill);
free(acc);
free(dirty);
free(off2);
return CBM_NOT_FOUND;
}
for (int r = 0; r < r_count; r++) {
seed[r] = CBM_NOT_FOUND;
}
for (int i = 0; i < n; i++) {
int r = refined[i];
k2[r] += g->k[i];
gcount[r]++;
if (seed[r] == CBM_NOT_FOUND) {
seed[r] = comm[i];
}
}
gstart[0] = 0;
for (int r = 0; r < r_count; r++) {
gstart[r + 1] = gstart[r] + gcount[r];
fill[r] = gstart[r];
}
for (int i = 0; i < n; i++) {
members[fill[refined[i]]++] = i;
}
/* Pass 1: count distinct inter-community neighbours per aggregate node. */
off2[0] = 0;
for (int r = 0; r < r_count; r++) {
int nd = 0;
for (int m = gstart[r]; m < gstart[r + 1]; m++) {
int i = members[m];
for (int e = g->off[i]; e < g->off[i + 1]; e++) {
int rb = refined[g->nbr[e]];
if (rb == r || acc[rb] != 0.0) {
continue;
}
acc[rb] = 1.0;
dirty[nd++] = rb;
}
}
off2[r + 1] = off2[r] + nd;
for (int d = 0; d < nd; d++) {
acc[dirty[d]] = 0.0;
}
}
int total = off2[r_count];
/* 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);
free(gstart);
free(members);
free(fill);
free(acc);
free(dirty);
free(off2);
free(nbr2);
free(w2);
return CBM_NOT_FOUND;
}
/* Pass 2: accumulate inter-community edge weights. */
for (int r = 0; r < r_count; r++) {
int nd = 0;
for (int m = gstart[r]; m < gstart[r + 1]; m++) {
int i = members[m];
for (int e = g->off[i]; e < g->off[i + 1]; e++) {
int rb = refined[g->nbr[e]];
if (rb == r) {
continue;
}
if (acc[rb] == 0.0) {
dirty[nd++] = rb;
}
acc[rb] += g->w[e];
}
}
int base = off2[r];
for (int d = 0; d < nd; d++) {
nbr2[base + d] = dirty[d];
w2[base + d] = acc[dirty[d]];
acc[dirty[d]] = 0.0;
}
}
free(gcount);
free(gstart);
free(members);
free(fill);
free(acc);
free(dirty);
out->n = r_count;
out->off = off2;
out->nbr = nbr2;
out->w = w2;
out->k = k2;
return CBM_STORE_OK;
}
int cbm_leiden(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges,
int edge_count, double resolution, cbm_louvain_result_t **out, int *out_count) {
if (node_count <= 0) {
*out = NULL;
*out_count = 0;
return CBM_STORE_OK;
}
int n = node_count;
double gamma = resolution > 0.0 ? resolution : 1.0;
cbm_louvain_result_t *result = malloc((size_t)n * sizeof(*result));
if (!result) {
return CBM_NOT_FOUND;
}
for (int i = 0; i < n; i++) {
result[i].node_id = nodes[i];
result[i].community = i;
}
/* Build deduplicated undirected edge weights, then a CSR graph. */
int *wsi;
int *wdi;
double *ww;
int wn;
louvain_build_weights(nodes, n, edges, edge_count, &wsi, &wdi, &ww, &wn);
cbm_lg_t g;
int built = (wn > 0) ? lg_build(n, wsi, wdi, ww, wn, &g) : CBM_NOT_FOUND;
free(wsi);
free(wdi);
free(ww);
if (built != CBM_STORE_OK) {
/* No edges (or allocation failure): every node is its own community. */
*out = result;
*out_count = n;
return CBM_STORE_OK;
}
double twom = 0.0;
for (int i = 0; i < n; i++) {
twom += g.k[i];
}
int *orig = malloc((size_t)n * sizeof(int)); /* original node -> current graph node */
int *comm = malloc((size_t)n * sizeof(int));
if (twom <= 0.0 || !orig || !comm) {
free(orig);
free(comm);
lg_free(&g);
*out = result;
*out_count = n;
return CBM_STORE_OK;
}
for (int i = 0; i < n; i++) {
orig[i] = i;
comm[i] = i;
}
for (int level = 0; level < LEIDEN_MAX_LEVELS; level++) {
leiden_move(&g, comm, gamma, twom);
int c_count = leiden_relabel(comm, g.n);
if (c_count >= g.n) {
break; /* every node already isolated — nothing to coarsen */
}
int *refined = malloc((size_t)g.n * sizeof(int));
if (!refined) {
break;
}
int r_count = leiden_refine(&g, comm, gamma, twom, refined);
if (r_count >= g.n) {
free(refined);
break; /* refinement cannot reduce the graph further */
}
for (int i = 0; i < n; i++) {
orig[i] = refined[orig[i]];
}
cbm_lg_t g2;
int *seed = malloc((size_t)r_count * sizeof(int));
if (!seed || leiden_aggregate(&g, refined, r_count, comm, &g2, seed) != CBM_STORE_OK) {
free(seed);
free(refined);
break;
}
free(refined);
lg_free(&g);
g = g2;
free(comm);
comm = seed;
}
for (int i = 0; i < n; i++) {
result[i].community = comm[orig[i]];
}
free(comm);
free(orig);
lg_free(&g);
*out = result;
*out_count = n;
return CBM_STORE_OK;
}
int cbm_louvain(const int64_t *nodes, int node_count, const cbm_louvain_edge_t *edges,
int edge_count, cbm_louvain_result_t **out, int *out_count) {
return cbm_leiden(nodes, node_count, edges, edge_count, 1.0, out, out_count);
}
/* ── Architecture: community clusters via Leiden ───────────────── */
enum {
CBM_CLUSTER_TOP_N = 12, /* report at most this many clusters */
CBM_CLUSTER_MAX_TOPNODES = 5, /* representative node names per cluster */
CBM_CLUSTER_MAX_PKGS = 5, /* packages listed per cluster */
CBM_CLUSTER_MIN_MEMBERS = 2, /* skip singletons */
CBM_CLUSTER_NODE_CAP = 8000 /* bound the work for very large graphs */
};
static int cluster_id_cmp(const void *key, const void *el) {
int64_t k = *(const int64_t *)key;
int64_t e = *(const int64_t *)el;
return (k > e) - (k < e);
}
/* Index of node id within the id-sorted ids[], or CBM_NOT_FOUND. */
static int cluster_id_index(const int64_t *ids, int n, int64_t id) {
const int64_t *hit = bsearch(&id, ids, (size_t)n, sizeof(int64_t), cluster_id_cmp);
return hit ? (int)(hit - ids) : CBM_NOT_FOUND;
}
/* Append `pkg` to a distinct package list (with a per-package count). */
static void cluster_add_pkg(const char **pkgs, int *counts, int *count, int cap, const char *pkg) {
if (!pkg || !pkg[0]) {
return;
}
for (int i = 0; i < *count; i++) {
if (strcmp(pkgs[i], pkg) == 0) {
counts[i]++;
return;
}
}
if (*count < cap) {
pkgs[*count] = heap_strdup(pkg);
counts[*count] = 1;
(*count)++;
}
}
/* Build the cluster_info for one community c into *ci. */
static void cluster_build_one(cbm_cluster_info_t *ci, int c, int n, const int *comm,
const int *degree, const char **names, const char **qns, int members,
double cohesion) {
memset(ci, 0, sizeof(*ci));
ci->id = c;
ci->members = members;
ci->cohesion = cohesion;
/* Top nodes by degree. */
int top_idx[CBM_CLUSTER_MAX_TOPNODES];
int top_deg[CBM_CLUSTER_MAX_TOPNODES];
int tn = 0;
for (int i = 0; i < n; i++) {
if (comm[i] != c) {
continue;
}
int d = degree[i];
int pos = tn;
while (pos > 0 && top_deg[pos - 1] < d) {
pos--;
}
if (pos < CBM_CLUSTER_MAX_TOPNODES) {
int last = (tn < CBM_CLUSTER_MAX_TOPNODES) ? tn : CBM_CLUSTER_MAX_TOPNODES - 1;
for (int k = last; k > pos; k--) {
top_idx[k] = top_idx[k - 1];
top_deg[k] = top_deg[k - 1];
}
top_idx[pos] = i;
top_deg[pos] = d;
if (tn < CBM_CLUSTER_MAX_TOPNODES) {
tn++;
}
}
}
if (tn > 0) {
ci->top_nodes = malloc((size_t)tn * sizeof(char *));
for (int i = 0; i < tn; i++) {
ci->top_nodes[i] = heap_strdup(names[top_idx[i]]);
}
ci->top_node_count = tn;
}
/* Distinct packages (+ dominant one as the label). */
const char *pkgs[CBM_CLUSTER_MAX_PKGS];
int pkg_counts[CBM_CLUSTER_MAX_PKGS];
int pc = 0;
for (int i = 0; i < n; i++) {
if (comm[i] == c) {
cluster_add_pkg(pkgs, pkg_counts, &pc, CBM_CLUSTER_MAX_PKGS,
cbm_qn_to_top_package(qns[i]));
}
}
if (pc > 0) {
ci->packages = malloc((size_t)pc * sizeof(char *));
int best = 0;
for (int i = 0; i < pc; i++) {
ci->packages[i] = heap_strdup(pkgs[i]);
if (pkg_counts[i] > pkg_counts[best]) {
best = i;
}
}
ci->package_count = pc;
ci->label = heap_strdup(pkgs[best]);
for (int i = 0; i < pc; i++) {
safe_str_free(&pkgs[i]);
}
}
if (!ci->label) {
ci->label = heap_strdup(ci->top_node_count > 0 ? ci->top_nodes[0] : "cluster");
}
ci->edge_types = malloc(sizeof(char *));
ci->edge_types[0] = heap_strdup("CALLS");
ci->edge_type_count = 1;
}
/* Comparator for sorting community indices by descending member count. */
typedef struct {
int comm;
int members;
} cluster_rank_t;
static int cluster_rank_cmp(const void *a, const void *b) {
const cluster_rank_t *ca = a;
const cluster_rank_t *cb = b;
return cb->members - ca->members;
}
static int arch_clusters(cbm_store_t *s, const char *project, const char *path,
cbm_architecture_info_t *out) {
char norm[CBM_SZ_512];
char like[CBM_SZ_512];
bool scoped = arch_path_prepare(path, norm, sizeof(norm), like, sizeof(like));
char nsqlbuf[ST_SQL_BUF];
const char *nbase = "SELECT id, name, qualified_name, file_path FROM nodes "
"WHERE project=?1 AND label IN (" CBM_SQL_CALLABLE_OR_TYPE_LABELS ")";
if (scoped) {
snprintf(nsqlbuf, sizeof(nsqlbuf), "%s%s ORDER BY id LIMIT ?4", nbase,
arch_path_scope_sql());
} else {
snprintf(nsqlbuf, sizeof(nsqlbuf), "%s ORDER BY id LIMIT ?2", nbase);
}
sqlite3_stmt *st = NULL;
if (sqlite3_prepare_v2(s->db, nsqlbuf, CBM_NOT_FOUND, &st, NULL) != SQLITE_OK) {
return CBM_STORE_OK; /* clusters are best-effort */
}
bind_text(st, SKIP_ONE, project);
if (scoped) {
arch_bind_path_scope(st, ST_COL_2, ST_COL_3, norm, like);
sqlite3_bind_int(st, ST_COL_4, CBM_CLUSTER_NODE_CAP);
} else {
sqlite3_bind_int(st, CBM_SZ_2, CBM_CLUSTER_NODE_CAP);
}
int cap = ST_INIT_CAP_8;
int n = 0;
int64_t *ids = malloc((size_t)cap * sizeof(int64_t));
const char **names = malloc((size_t)cap * sizeof(char *));
const char **qns = malloc((size_t)cap * sizeof(char *));
int scan_rc29;
while ((scan_rc29 = sqlite3_step(st)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
ids = safe_realloc(ids, (size_t)cap * sizeof(int64_t));
names = safe_realloc(names, (size_t)cap * sizeof(char *));
qns = safe_realloc(qns, (size_t)cap * sizeof(char *));
}
ids[n] = sqlite3_column_int64(st, 0);
names[n] = heap_strdup((const char *)sqlite3_column_text(st, SKIP_ONE));
qns[n] = heap_strdup((const char *)sqlite3_column_text(st, CBM_SZ_2));
n++;
}
if (scan_rc29 != SQLITE_DONE) { /* SCANCHK:29:st */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(st);
for (int ci = 0; ci < n; ci++) {
safe_str_free(&names[ci]);
safe_str_free(&qns[ci]);
}
free(ids);
free((void *)names);
free((void *)qns);
return CBM_STORE_ERR;
}
sqlite3_finalize(st);
if (n < CBM_CLUSTER_MIN_MEMBERS) {
for (int i = 0; i < n; i++) {
safe_str_free(&names[i]);
safe_str_free(&qns[i]);
}
free(ids);
free(names);
free(qns);
return CBM_STORE_OK;
}
/* 2. Load CALLS edges with both endpoints in the node set (store indices). */
cbm_louvain_edge_t *edges = malloc((size_t)n * sizeof(cbm_louvain_edge_t));
int *esrc = malloc((size_t)n * sizeof(int));
int *edst = malloc((size_t)n * sizeof(int));
int *degree = calloc((size_t)n, sizeof(int));
int ne = 0;
const char *esql = "SELECT source_id, target_id FROM edges WHERE project=?1 AND type='CALLS'";
if (sqlite3_prepare_v2(s->db, esql, CBM_NOT_FOUND, &st, NULL) == SQLITE_OK) {
int ecap = n;
bind_text(st, SKIP_ONE, project);
int scan_rc30;
while ((scan_rc30 = sqlite3_step(st)) == SQLITE_ROW) {
int si = cluster_id_index(ids, n, sqlite3_column_int64(st, 0));
int ti = cluster_id_index(ids, n, sqlite3_column_int64(st, SKIP_ONE));
if (si < 0 || ti < 0 || si == ti) {
continue;
}
if (ne >= ecap) {
ecap *= ST_GROWTH;
edges = safe_realloc(edges, (size_t)ecap * sizeof(cbm_louvain_edge_t));
esrc = safe_realloc(esrc, (size_t)ecap * sizeof(int));
edst = safe_realloc(edst, (size_t)ecap * sizeof(int));
}
edges[ne].src = ids[si];
edges[ne].dst = ids[ti];
esrc[ne] = si;
edst[ne] = ti;
degree[si]++;
degree[ti]++;
ne++;
}
if (scan_rc30 != SQLITE_DONE) { /* SCANCHK:30:st */
/* Enrichment path: record the error but proceed with the
* partial adjacency — primary query surfaces already fail
* loudly on the same corruption (#896). */
store_set_error_sqlite(s, "cluster edge scan aborted");
}
sqlite3_finalize(st);
}
/* 3. Community detection. */
cbm_louvain_result_t *res = NULL;
int rn = 0;
int *comm = NULL;
int C = 0;
if (cbm_leiden(ids, n, edges, ne, 1.0, &res, &rn) == CBM_STORE_OK && res && rn == n) {
comm = malloc((size_t)n * sizeof(int));
for (int i = 0; i < n; i++) {
comm[i] = res[i].community;
if (comm[i] + 1 > C) {
C = comm[i] + 1;
}
}
}
free(res);
if (comm && C > 0) {
/* 4. Members + cohesion (internal vs boundary edges) per community. */
int *members = calloc((size_t)C, sizeof(int));
int *internal = calloc((size_t)C, sizeof(int));
int *boundary = calloc((size_t)C, sizeof(int));
for (int i = 0; i < n; i++) {
members[comm[i]]++;
}
for (int e = 0; e < ne; e++) {
int cs = comm[esrc[e]];
int cd = comm[edst[e]];
if (cs == cd) {
internal[cs]++;
} else {
boundary[cs]++;
boundary[cd]++;
}
}
/* 5. Rank communities by size, take the top N non-singletons. */
cluster_rank_t *rank = malloc((size_t)C * sizeof(cluster_rank_t));
for (int c = 0; c < C; c++) {
rank[c] = (cluster_rank_t){c, members[c]};
}
qsort(rank, (size_t)C, sizeof(cluster_rank_t), cluster_rank_cmp);
cbm_cluster_info_t *clusters =
malloc((size_t)CBM_CLUSTER_TOP_N * sizeof(cbm_cluster_info_t));
int cc = 0;
for (int r = 0; r < C && cc < CBM_CLUSTER_TOP_N; r++) {
int c = rank[r].comm;
if (members[c] < CBM_CLUSTER_MIN_MEMBERS) {
break; /* sorted desc — the rest are singletons too */
}
double denom = internal[c] + boundary[c];
double cohesion = denom > 0 ? (double)internal[c] / denom : 0.0;
cluster_build_one(&clusters[cc], c, n, comm, degree, names, qns, members[c], cohesion);
cc++;
}
out->clusters = clusters;
out->cluster_count = cc;
free(members);
free(internal);
free(boundary);
free(rank);
}
free(comm);
for (int i = 0; i < n; i++) {
safe_str_free(&names[i]);
safe_str_free(&qns[i]);
}
free(ids);
free(names);
free(qns);
free(edges);
free(esrc);
free(edst);
free(degree);
return CBM_STORE_OK;
}
/* ── GetArchitecture dispatch ──────────────────────────────────── */
/* "overview" = compact architecture summary: every aspect EXCEPT the large
* per-file listing (file_tree), which alone dominates the payload on real
* repos and can push the MCP response past the output cap. Declared in
* store.h and shared with aspect_wanted in src/mcp/mcp.c so the store-side
* DB gate and the MCP-side serialization gate cannot drift. */
bool cbm_store_arch_aspect_in_overview(const char *name) {
return strcmp(name, "file_tree") != 0;
}
static bool want_aspect(const char **aspects, int aspect_count, const char *name) {
if (!aspects || aspect_count == 0) {
return true;
}
for (int i = 0; i < aspect_count; i++) {
if (strcmp(aspects[i], "all") == 0) {
return true;
}
if (strcmp(aspects[i], "overview") == 0 && cbm_store_arch_aspect_in_overview(name)) {
return true;
}
if (strcmp(aspects[i], name) == 0) {
return true;
}
}
return false;
}
int cbm_store_get_architecture(cbm_store_t *s, const char *project, const char *path,
const char **aspects, int aspect_count,
cbm_architecture_info_t *out) {
memset(out, 0, sizeof(*out));
int rc;
if (want_aspect(aspects, aspect_count, "languages")) {
rc = arch_languages(s, project, path, out);
if (rc != CBM_STORE_OK) {
return rc;
}
}
if (want_aspect(aspects, aspect_count, "packages")) {
rc = arch_packages(s, project, path, out);
if (rc != CBM_STORE_OK) {
return rc;
}
}
if (want_aspect(aspects, aspect_count, "entry_points")) {
rc = arch_entry_points(s, project, path, out);
if (rc != CBM_STORE_OK) {
return rc;
}
}
if (want_aspect(aspects, aspect_count, "routes")) {
rc = arch_routes(s, project, path, out);
if (rc != CBM_STORE_OK) {
return rc;
}
}
if (want_aspect(aspects, aspect_count, "hotspots")) {
rc = arch_hotspots(s, project, path, out);
if (rc != CBM_STORE_OK) {
return rc;
}
}
if (want_aspect(aspects, aspect_count, "boundaries")) {
cbm_cross_pkg_boundary_t *barr = NULL;
int bcount = 0;
rc = arch_boundaries(s, project, path, &barr, &bcount);
if (rc != CBM_STORE_OK) {
return rc;
}
out->boundaries = barr;
out->boundary_count = bcount;
}
if (want_aspect(aspects, aspect_count, "layers")) {
rc = arch_layers(s, project, path, out);
if (rc != CBM_STORE_OK) {
return rc;
}
}
if (want_aspect(aspects, aspect_count, "file_tree")) {
rc = arch_file_tree(s, project, path, out);
if (rc != CBM_STORE_OK) {
return rc;
}
}
if (want_aspect(aspects, aspect_count, "clusters")) {
rc = arch_clusters(s, project, path, out);
if (rc != CBM_STORE_OK) {
return rc;
}
}
return CBM_STORE_OK;
}
void cbm_store_architecture_free(cbm_architecture_info_t *out) {
if (!out) {
return;
}
for (int i = 0; i < out->language_count; i++) {
safe_str_free(&out->languages[i].language);
}
free(out->languages);
for (int i = 0; i < out->package_count; i++) {
safe_str_free(&out->packages[i].name);
}
free(out->packages);
for (int i = 0; i < out->entry_point_count; i++) {
safe_str_free(&out->entry_points[i].name);
safe_str_free(&out->entry_points[i].qualified_name);
safe_str_free(&out->entry_points[i].file);
}
free(out->entry_points);
for (int i = 0; i < out->route_count; i++) {
safe_str_free(&out->routes[i].method);
safe_str_free(&out->routes[i].path);
safe_str_free(&out->routes[i].handler);
}
free(out->routes);
for (int i = 0; i < out->hotspot_count; i++) {
safe_str_free(&out->hotspots[i].name);
safe_str_free(&out->hotspots[i].qualified_name);
}
free(out->hotspots);
for (int i = 0; i < out->boundary_count; i++) {
safe_str_free(&out->boundaries[i].from);
safe_str_free(&out->boundaries[i].to);
}
free(out->boundaries);
for (int i = 0; i < out->service_count; i++) {
safe_str_free(&out->services[i].from);
safe_str_free(&out->services[i].to);
safe_str_free(&out->services[i].type);
}
free(out->services);
for (int i = 0; i < out->layer_count; i++) {
safe_str_free(&out->layers[i].name);
safe_str_free(&out->layers[i].layer);
safe_str_free(&out->layers[i].reason);
}
free(out->layers);
for (int i = 0; i < out->cluster_count; i++) {
safe_str_free(&out->clusters[i].label);
for (int j = 0; j < out->clusters[i].top_node_count; j++) {
safe_str_free(&out->clusters[i].top_nodes[j]);
}
free(out->clusters[i].top_nodes);
for (int j = 0; j < out->clusters[i].package_count; j++) {
safe_str_free(&out->clusters[i].packages[j]);
}
free(out->clusters[i].packages);
for (int j = 0; j < out->clusters[i].edge_type_count; j++) {
safe_str_free(&out->clusters[i].edge_types[j]);
}
free(out->clusters[i].edge_types);
}
free(out->clusters);
for (int i = 0; i < out->file_tree_count; i++) {
safe_str_free(&out->file_tree[i].path);
safe_str_free(&out->file_tree[i].type);
}
free(out->file_tree);
memset(out, 0, sizeof(*out));
}
/* ── ADR (Architecture Decision Record) ────────────────────────── */
static const char *canonical_sections[] = {"PURPOSE", "STACK", "ARCHITECTURE",
"PATTERNS", "TRADEOFFS", "PHILOSOPHY"};
static const int canonical_section_count = 6;
static bool is_canonical_section(const char *name) {
for (int i = 0; i < canonical_section_count; i++) {
if (strcmp(name, canonical_sections[i]) == 0) {
return true;
}
}
return false;
}
/* Save a completed ADR section into result, trimming whitespace. */
static void adr_save_section(cbm_adr_sections_t *result, char *section_name, char *buf,
int buf_len) {
if (!section_name || result->count >= ST_BUF_16) {
return;
}
/* Trim trailing whitespace */
while (buf_len > 0 && (buf[buf_len - SKIP_ONE] == '\n' || buf[buf_len - SKIP_ONE] == ' ')) {
buf[--buf_len] = '\0';
}
/* Skip leading whitespace */
char *trimmed = buf;
while (*trimmed == '\n' || *trimmed == ' ') {
trimmed++;
}
result->keys[result->count] = section_name;
result->values[result->count] = heap_strdup(trimmed);
result->count++;
}
/* Try to extract a canonical section header from a "## Header" line.
* Returns heap-allocated header string if canonical, NULL otherwise. */
static char *adr_try_section_header(const char *line, int line_len) {
if (line_len <= ST_HEADER_PREFIX || line[0] != '#' || line[SKIP_ONE] != '#' ||
line[PAIR_LEN] != ' ') {
return NULL;
}
char header[CBM_SZ_64];
int hlen = line_len - ST_HEADER_PREFIX;
if (hlen >= (int)sizeof(header)) {
hlen = (int)sizeof(header) - SKIP_ONE;
}
memcpy(header, line + 3, hlen);
header[hlen] = '\0';
while (hlen > 0 && (header[hlen - SKIP_ONE] == ' ' || header[hlen - SKIP_ONE] == '\t' ||
header[hlen - SKIP_ONE] == '\r')) {
header[--hlen] = '\0';
}
if (!is_canonical_section(header)) {
return NULL;
}
return heap_strdup(header);
}
/* Append a line to the current section content buffer. */
static void adr_append_line(char *buf, int buf_sz, int *len, const char *line, int line_len) {
if (*len > 0) {
buf[(*len)++] = '\n';
}
if (*len + line_len < buf_sz - SKIP_ONE) {
memcpy(buf + *len, line, line_len);
*len += line_len;
buf[*len] = '\0';
}
}
cbm_adr_sections_t cbm_adr_parse_sections(const char *content) {
cbm_adr_sections_t result;
memset(&result, 0, sizeof(result));
if (!content || !content[0]) {
return result;
}
const char *p = content;
char *current_section = NULL;
char current_content[ST_SQL_BUF] = "";
int content_len = 0;
while (*p) {
const char *eol = strchr(p, '\n');
int line_len = eol ? (int)(eol - p) : (int)strlen(p);
char *new_section = adr_try_section_header(p, line_len);
if (new_section) {
adr_save_section(&result, current_section, current_content, content_len);
current_section = new_section;
current_content[0] = '\0';
content_len = 0;
} else if (current_section && (content_len > 0 || line_len > 0)) {
adr_append_line(current_content, (int)sizeof(current_content), &content_len, p,
line_len);
}
p = eol ? eol + SKIP_ONE : p + line_len;
}
adr_save_section(&result, current_section, current_content, content_len);
return result;
}
/* Append a section to the render buffer.
*
* snprintf returns the length it WOULD have written, so a section larger than
* the space left would otherwise push pos past buf_sz; the next call would then
* compute a wrapped (huge) remaining size from (buf_sz - pos) and write out of
* bounds. Clamp pos into [0, buf_sz-1] after every write so each snprintf gets
* a positive size and the returned cursor never escapes the buffer. */
static int adr_render_section(char *buf, int buf_sz, int pos, const char *key, const char *value) {
if (buf_sz <= 0) {
return 0;
}
if (pos < 0) {
pos = 0;
}
if (pos >= buf_sz) {
return buf_sz - SKIP_ONE;
}
if (pos > 0) {
pos += snprintf(buf + pos, (size_t)(buf_sz - pos), "\n\n");
if (pos >= buf_sz) {
return buf_sz - SKIP_ONE;
}
}
pos += snprintf(buf + pos, (size_t)(buf_sz - pos), "## %s\n%s", key, value);
if (pos >= buf_sz) {
pos = buf_sz - SKIP_ONE;
}
return pos;
}
char *cbm_adr_render(const cbm_adr_sections_t *sections) {
if (!sections || sections->count == 0) {
return heap_strdup("");
}
char buf[ST_MAX_DEGREE * ST_GROWTH] = "";
int pos = 0;
bool rendered[ST_MAX_SECTIONS] = {false};
/* Canonical sections first, in order */
for (int c = 0; c < canonical_section_count; c++) {
for (int i = 0; i < sections->count; i++) {
if (rendered[i]) {
continue;
}
if (strcmp(sections->keys[i], canonical_sections[c]) == 0) {
pos = adr_render_section(buf, (int)sizeof(buf), pos, sections->keys[i],
sections->values[i]);
rendered[i] = true;
break;
}
}
}
/* Non-canonical sections alphabetically */
int extra[ST_BUF_16];
int nextra = 0;
for (int i = 0; i < sections->count; i++) {
if (!rendered[i]) {
extra[nextra++] = i;
}
}
for (int i = SKIP_ONE; i < nextra; i++) {
int j = i;
while (j > 0 && strcmp(sections->keys[extra[j]], sections->keys[extra[j - SKIP_ONE]]) < 0) {
int tmp = extra[j];
extra[j] = extra[j - SKIP_ONE];
extra[j - SKIP_ONE] = tmp;
j--;
}
}
for (int i = 0; i < nextra; i++) {
int idx = extra[i];
pos = adr_render_section(buf, (int)sizeof(buf), pos, sections->keys[idx],
sections->values[idx]);
}
return heap_strdup(buf);
}
int cbm_adr_validate_content(const char *content, char *errbuf, int errbuf_size) {
cbm_adr_sections_t sections = cbm_adr_parse_sections(content);
char missing[CBM_SZ_256] = "";
int mlen = 0;
int nmissing = 0;
for (int c = 0; c < canonical_section_count; c++) {
bool found = false;
for (int i = 0; i < sections.count; i++) {
if (strcmp(sections.keys[i], canonical_sections[c]) == 0) {
found = true;
break;
}
}
if (!found) {
if (mlen > 0) {
mlen += snprintf(missing + mlen, sizeof(missing) - mlen, ", ");
}
mlen += snprintf(missing + mlen, sizeof(missing) - mlen, "%s", canonical_sections[c]);
nmissing++;
}
}
cbm_adr_sections_free(&sections);
if (nmissing > 0) {
snprintf(errbuf, errbuf_size,
"missing required sections: %s. All 6 required: PURPOSE, STACK, ARCHITECTURE, "
"PATTERNS, TRADEOFFS, PHILOSOPHY",
missing);
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_adr_validate_section_keys(const char **keys, int count, char *errbuf, int errbuf_size) {
char invalid[CBM_SZ_256] = "";
int ilen = 0;
int ninvalid = 0;
/* Collect and sort invalid keys */
const char *inv_keys[ST_MAX_SECTIONS];
int inv_n = 0;
for (int i = 0; i < count; i++) {
if (!is_canonical_section(keys[i])) {
if (inv_n < ST_BUF_16) {
inv_keys[inv_n++] = keys[i];
}
}
}
/* Sort alphabetically */
for (int i = SKIP_ONE; i < inv_n; i++) {
int j = i;
while (j > 0 && strcmp(inv_keys[j], inv_keys[j - SKIP_ONE]) < 0) {
const char *tmp = inv_keys[j];
inv_keys[j] = inv_keys[j - SKIP_ONE];
inv_keys[j - SKIP_ONE] = tmp;
j--;
}
}
for (int i = 0; i < inv_n; i++) {
if (ilen > 0) {
ilen += snprintf(invalid + ilen, sizeof(invalid) - ilen, ", ");
}
ilen += snprintf(invalid + ilen, sizeof(invalid) - ilen, "%s", inv_keys[i]);
ninvalid++;
}
if (ninvalid > 0) {
snprintf(errbuf, errbuf_size,
"invalid section names: %s. Valid sections: PURPOSE, STACK, ARCHITECTURE, "
"PATTERNS, TRADEOFFS, PHILOSOPHY",
invalid);
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
void cbm_adr_sections_free(cbm_adr_sections_t *s) {
if (!s) {
return;
}
for (int i = 0; i < s->count; i++) {
free(s->keys[i]);
free(s->values[i]);
}
memset(s, 0, sizeof(*s));
}
int cbm_store_adr_store(cbm_store_t *s, const char *project, const char *content) {
char now[CBM_SZ_32];
iso_now(now, sizeof(now));
const char *sql =
"INSERT INTO project_summaries (project, summary, source_hash, created_at, updated_at) "
"VALUES (?1, ?2, '', ?3, ?4) "
"ON CONFLICT(project) DO UPDATE SET summary=excluded.summary, "
"updated_at=excluded.updated_at";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "adr_store");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
bind_text(stmt, ST_COL_2, content);
bind_text(stmt, ST_COL_3, now);
bind_text(stmt, ST_COL_4, now);
int rc = sqlite3_step(stmt);
sqlite3_finalize(stmt);
return (rc == SQLITE_DONE) ? CBM_STORE_OK : CBM_STORE_ERR;
}
int cbm_store_adr_get(cbm_store_t *s, const char *project, cbm_adr_t *out) {
if (!s || !s->db || !project || !out) {
return CBM_STORE_ERR;
}
memset(out, 0, sizeof(*out));
/* ADR storage was added after the original graph schema. A readable
* legacy generation without project_summaries has no ADR to preserve; it
* is not a read failure and must remain replaceable by a full reindex. */
sqlite3_stmt *table_stmt = NULL;
int rc = sqlite3_prepare_v2(
s->db,
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='project_summaries' LIMIT 1;",
CBM_NOT_FOUND, &table_stmt, NULL);
if (rc != SQLITE_OK) {
store_set_error_sqlite(s, "adr_get table probe");
return CBM_STORE_ERR;
}
rc = sqlite3_step(table_stmt);
if (rc == SQLITE_DONE) {
sqlite3_finalize(table_stmt);
return CBM_STORE_NOT_FOUND;
}
if (rc != SQLITE_ROW || sqlite3_finalize(table_stmt) != SQLITE_OK) {
store_set_error_sqlite(s, "adr_get table probe step");
return CBM_STORE_ERR;
}
const char *sql = "SELECT project, summary, created_at, updated_at FROM project_summaries "
"WHERE project=?1";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "adr_get");
return CBM_STORE_ERR;
}
if (bind_text(stmt, SKIP_ONE, project) != SQLITE_OK) {
store_set_error_sqlite(s, "adr_get bind");
sqlite3_finalize(stmt);
return CBM_STORE_ERR;
}
rc = sqlite3_step(stmt);
if (rc != SQLITE_ROW) {
if (rc != SQLITE_DONE) {
store_set_error_sqlite(s, "adr_get step");
}
sqlite3_finalize(stmt);
if (rc == SQLITE_DONE) {
store_set_error(s, "no ADR found");
return CBM_STORE_NOT_FOUND;
}
return CBM_STORE_ERR;
}
out->project = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
out->content = heap_strdup((const char *)sqlite3_column_text(stmt, SKIP_ONE));
out->created_at = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_2));
out->updated_at = heap_strdup((const char *)sqlite3_column_text(stmt, CBM_SZ_3));
rc = sqlite3_finalize(stmt);
if (rc != SQLITE_OK) {
cbm_store_adr_free(out);
store_set_error_sqlite(s, "adr_get finalize");
return CBM_STORE_ERR;
}
/* Every selected column is NOT NULL in the schema. A NULL here therefore
* means either allocation failure or corrupt data; never return a partial
* ADR that a full rebuild could silently drop during publication. */
if (!out->project || !out->content || !out->created_at || !out->updated_at) {
cbm_store_adr_free(out);
store_set_error(s, "adr_get: failed to copy complete ADR");
return CBM_STORE_ERR;
}
return CBM_STORE_OK;
}
int cbm_store_adr_delete(cbm_store_t *s, const char *project) {
const char *sql = "DELETE FROM project_summaries WHERE project=?1";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "adr_delete");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
int rc = sqlite3_step(stmt);
int changes = sqlite3_changes(s->db);
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
return CBM_STORE_ERR;
}
if (changes == 0) {
store_set_error(s, "no ADR found");
return CBM_STORE_NOT_FOUND;
}
return CBM_STORE_OK;
}
int cbm_store_adr_update_sections(cbm_store_t *s, const char *project, const char **keys,
const char **values, int count, cbm_adr_t *out) {
/* Get existing ADR */
cbm_adr_t existing;
int rc = cbm_store_adr_get(s, project, &existing);
if (rc != CBM_STORE_OK) {
store_set_error(s, "no existing ADR to update");
return rc;
}
/* Parse existing sections */
cbm_adr_sections_t sections = cbm_adr_parse_sections(existing.content);
cbm_store_adr_free(&existing);
/* Merge new sections */
for (int i = 0; i < count; i++) {
bool found = false;
for (int j = 0; j < sections.count; j++) {
if (strcmp(sections.keys[j], keys[i]) == 0) {
free(sections.values[j]);
sections.values[j] = heap_strdup(values[i]);
found = true;
break;
}
}
if (!found && sections.count < ST_BUF_16) {
sections.keys[sections.count] = heap_strdup(keys[i]);
sections.values[sections.count] = heap_strdup(values[i]);
sections.count++;
}
}
/* Render merged */
char *merged = cbm_adr_render(&sections);
cbm_adr_sections_free(&sections);
/* Check length */
if ((int)strlen(merged) > CBM_ADR_MAX_LENGTH) {
char msg[CBM_SZ_128];
snprintf(msg, sizeof(msg), "merged ADR exceeds %d chars (%d chars)", CBM_ADR_MAX_LENGTH,
(int)strlen(merged));
store_set_error(s, msg);
free(merged);
return CBM_STORE_ERR;
}
/* Store merged */
rc = cbm_store_adr_store(s, project, merged);
free(merged);
if (rc != CBM_STORE_OK) {
return rc;
}
return cbm_store_adr_get(s, project, out);
}
void cbm_store_adr_free(cbm_adr_t *adr) {
if (!adr) {
return;
}
safe_str_free(&adr->project);
safe_str_free(&adr->content);
safe_str_free(&adr->created_at);
safe_str_free(&adr->updated_at);
memset(adr, 0, sizeof(*adr));
}
/* ── Architecture doc discovery ────────────────────────────────── */
int cbm_store_find_architecture_docs(cbm_store_t *s, const char *project, char ***out, int *count) {
const char *sql = "SELECT file_path FROM nodes WHERE project=?1 AND label='File' "
"AND (file_path LIKE '%ARCHITECTURE.md' OR file_path LIKE '%ADR.md' "
"OR file_path LIKE '%DECISIONS.md' OR file_path LIKE 'docs/adr/%' "
"OR file_path LIKE 'doc/adr/%' OR file_path LIKE 'adr/%') "
"ORDER BY file_path LIMIT 20";
sqlite3_stmt *stmt = NULL;
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
store_set_error_sqlite(s, "find_arch_docs");
return CBM_STORE_ERR;
}
bind_text(stmt, SKIP_ONE, project);
int cap = ST_INIT_CAP_8;
int n = 0;
char **arr = malloc(cap * sizeof(char *));
int scan_rc31;
while ((scan_rc31 = sqlite3_step(stmt)) == SQLITE_ROW) {
if (n >= cap) {
cap *= ST_GROWTH;
arr = safe_realloc(arr, cap * sizeof(char *));
}
arr[n++] = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
}
if (scan_rc31 != SQLITE_DONE) { /* SCANCHK:31:stmt */
store_set_error_sqlite(s, "row scan aborted");
sqlite3_finalize(stmt);
for (int di = 0; di < n; di++) {
free(arr[di]);
}
free(arr);
*out = NULL;
*count = 0;
return CBM_STORE_ERR;
}
sqlite3_finalize(stmt);
*out = arr;
*count = n;
return CBM_STORE_OK;
}
/* ── Memory management ──────────────────────────────────────────── */
void cbm_node_free_fields(cbm_node_t *n) {
safe_str_free(&n->project);
safe_str_free(&n->label);
safe_str_free(&n->name);
safe_str_free(&n->qualified_name);
safe_str_free(&n->file_path);
safe_str_free(&n->properties_json);
}
void cbm_store_free_nodes(cbm_node_t *nodes, int count) {
if (!nodes) {
return;
}
for (int i = 0; i < count; i++) {
cbm_node_free_fields(&nodes[i]);
}
free(nodes);
}
void cbm_store_free_edges(cbm_edge_t *edges, int count) {
if (!edges) {
return;
}
for (int i = 0; i < count; i++) {
safe_str_free(&edges[i].project);
safe_str_free(&edges[i].type);
safe_str_free(&edges[i].properties_json);
}
free(edges);
}
void cbm_project_free_fields(cbm_project_t *p) {
safe_str_free(&p->name);
safe_str_free(&p->indexed_at);
safe_str_free(&p->root_path);
}
void cbm_store_free_projects(cbm_project_t *projects, int count) {
if (!projects) {
return;
}
for (int i = 0; i < count; i++) {
cbm_project_free_fields(&projects[i]);
}
free(projects);
}
void cbm_store_free_file_hashes(cbm_file_hash_t *hashes, int count) {
if (!hashes) {
return;
}
for (int i = 0; i < count; i++) {
safe_str_free(&hashes[i].project);
safe_str_free(&hashes[i].rel_path);
safe_str_free(&hashes[i].sha256);
}
free(hashes);
}
/* ── Vector search ────────────────────────��──────────────────────── */
int cbm_store_count_vectors(cbm_store_t *s, const char *project) {
if (!s || !project) {
return 0;
}
sqlite3_stmt *stmt = NULL;
const char *sql = "SELECT count(*) FROM node_vectors WHERE project = ?1";
if (sqlite3_prepare_v2(s->db, sql, SQLITE_AUTO_LEN, &stmt, NULL) != SQLITE_OK) {
return 0;
}
sqlite3_bind_text(stmt, SKIP_ONE, project, SQLITE_AUTO_LEN, SQLITE_STATIC);
int count = 0;
if (sqlite3_step(stmt) == SQLITE_ROW) {
count = sqlite3_column_int(stmt, 0);
}
sqlite3_finalize(stmt);
return count;
}
void cbm_store_free_vector_results(cbm_vector_result_t *results, int count) {
if (!results) {
return;
}
for (int i = 0; i < count; i++) {
free(results[i].name);
free(results[i].qualified_name);
free(results[i].file_path);
free(results[i].label);
}
free(results);
}
/* Per-keyword scoring: score each keyword independently against each node
* vector, then combine using min(cosine_k) across keywords. This ensures
* ALL keywords must be relevant, not just the average. */
enum {
VS_VEC_DIM = 768,
VS_SPARSE_NNZE = 8,
VS_RI_SEED = 0x52494E44,
VS_MAX_KW = 32,
VS_STR_BUF = 16,
};
/* Try to look up an enriched int8 vector for `token` in the token_vectors
* table. On success, writes the de-quantized float representation to
* `out` and returns true. */
static bool vs_load_enriched_vector(cbm_store_t *s, const char *project, const char *token,
float *out) {
sqlite3_stmt *tv_stmt = NULL;
const char *tv_sql = "SELECT vector, idf FROM token_vectors"
" WHERE project = ?1 AND token = ?2 LIMIT 1";
if (sqlite3_prepare_v2(s->db, tv_sql, SQLITE_AUTO_LEN, &tv_stmt, NULL) != SQLITE_OK) {
return false;
}
bool found = false;
sqlite3_bind_text(tv_stmt, SKIP_ONE, project, SQLITE_AUTO_LEN, SQLITE_STATIC);
sqlite3_bind_text(tv_stmt, ST_COL_2, token, SQLITE_AUTO_LEN, SQLITE_STATIC);
if (sqlite3_step(tv_stmt) == SQLITE_ROW) {
const int8_t *vec = (const int8_t *)sqlite3_value_blob(sqlite3_column_value(tv_stmt, 0));
int vec_len = sqlite3_column_bytes(tv_stmt, 0);
if (vec && vec_len == VS_VEC_DIM) {
for (int d = 0; d < VS_VEC_DIM; d++) {
out[d] = (float)vec[d] / CBM_STORE_INT8_MAX;
}
found = true;
}
}
sqlite3_finalize(tv_stmt);
return found;
}
/* Sparse-random-index fallback for tokens not present in the enriched table. */
static void vs_fill_sparse_random(const char *token, float *out) {
uint64_t seed = XXH3_64bits(token, strlen(token));
for (int i = 0; i < VS_SPARSE_NNZE; i++) {
uint64_t h = XXH3_64bits_withSeed(&i, sizeof(i), seed + VS_RI_SEED);
int pos = (int)(h % VS_VEC_DIM);
float sign = (h & SKIP_ONE) ? CBM_STORE_UNIT_POS_F : -CBM_STORE_UNIT_POS_F;
out[pos] += sign;
}
}
/* Clamp one float into the int8 representable range. */
static int8_t vs_clamp_int8(float v) {
if (v > CBM_STORE_INT8_MAX) {
return (int8_t)CBM_STORE_INT8_MAX;
}
if (v < -CBM_STORE_INT8_MAX) {
return (int8_t)-CBM_STORE_INT8_MAX;
}
return (int8_t)v;
}
/* Normalize + int8 quantize a float vector. Returns false if the vector is
* effectively zero (magnitude below epsilon), in which case `dst` is left in
* an indeterminate state and the caller should skip this keyword. */
static bool vs_normalize_and_quantize(const float *src, int8_t *dst) {
float mag = 0.0F;
for (int d = 0; d < VS_VEC_DIM; d++) {
mag += src[d] * src[d];
}
mag = sqrtf(mag);
if (mag < CBM_STORE_DENOM_EPS_F) {
return false;
}
float inv = CBM_STORE_UNIT_POS_F / mag;
for (int d = 0; d < VS_VEC_DIM; d++) {
dst[d] = vs_clamp_int8(src[d] * inv * CBM_STORE_INT8_MAX);
}
return true;
}
/* Build int8 query vectors for each keyword. Returns the number of
* successfully built vectors (may be less than keyword_count when some
* keywords produce zero-magnitude vectors). */
static int vs_build_keyword_vectors(cbm_store_t *s, const char *project, const char **keywords,
int keyword_count, int8_t (*kw_vecs)[VS_VEC_DIM]) {
int actual_kw = 0;
for (int k = 0; k < keyword_count && actual_kw < VS_MAX_KW; k++) {
if (!keywords[k] || !keywords[k][0]) {
continue;
}
float kw_f[VS_VEC_DIM];
memset(kw_f, 0, sizeof(kw_f));
if (!vs_load_enriched_vector(s, project, keywords[k], kw_f)) {
vs_fill_sparse_random(keywords[k], kw_f);
}
if (vs_normalize_and_quantize(kw_f, kw_vecs[actual_kw])) {
actual_kw++;
}
}
return actual_kw;
}
/* Compute the per-keyword min cosine score between a node's int8 vector and
* each of the query vectors. Returns 0.0 if the node vector is unavailable
* or mis-sized. */
static double vs_min_cosine_score(const int8_t *node_vec, int node_vec_len,
const int8_t (*kw_vecs)[VS_VEC_DIM], int actual_kw) {
if (!node_vec || node_vec_len != VS_VEC_DIM) {
return 0.0;
}
double min_score = CBM_STORE_UNIT_POS_D;
for (int k = 0; k < actual_kw; k++) {
int32_t dot = 0;
int32_t ma = 0;
int32_t mb = 0;
for (int d = 0; d < VS_VEC_DIM; d++) {
dot += (int32_t)kw_vecs[k][d] * (int32_t)node_vec[d];
ma += (int32_t)kw_vecs[k][d] * (int32_t)kw_vecs[k][d];
mb += (int32_t)node_vec[d] * (int32_t)node_vec[d];
}
double denom = sqrt((double)ma) * sqrt((double)mb);
double cos_k = denom > CBM_STORE_DENOM_EPS_D ? (double)dot / denom : 0.0;
if (cos_k < min_score) {
min_score = cos_k;
}
}
return min_score;
}
/* Append one candidate row read from the scan statement into the result
* vector. Grows the results array geometrically on demand. Returns the
* (possibly grown) results pointer, or NULL on allocation failure. */
static cbm_vector_result_t *vs_append_result(cbm_vector_result_t *results, int *count, int *cap,
sqlite3_stmt *stmt,
const int8_t (*kw_vecs)[VS_VEC_DIM], int actual_kw) {
if (*count >= *cap) {
int nc = *cap < CBM_SZ_16 ? CBM_SZ_16 : *cap * ST_COL_2;
cbm_vector_result_t *grown = realloc(results, (size_t)nc * sizeof(cbm_vector_result_t));
if (!grown) {
return NULL;
}
results = grown;
*cap = nc;
}
int idx = (*count)++;
results[idx].node_id = sqlite3_column_int64(stmt, 0);
const char *name = (const char *)sqlite3_column_text(stmt, SKIP_ONE);
const char *qn = (const char *)sqlite3_column_text(stmt, ST_COL_2);
const char *fp = (const char *)sqlite3_column_text(stmt, ST_COL_3);
const char *label = (const char *)sqlite3_column_text(stmt, ST_COL_4);
results[idx].name = name ? strdup(name) : strdup("");
results[idx].qualified_name = qn ? strdup(qn) : strdup("");
results[idx].file_path = fp ? strdup(fp) : strdup("");
results[idx].label = label ? strdup(label) : strdup("");
const int8_t *node_vec = (const int8_t *)sqlite3_column_blob(stmt, ST_COL_6);
int node_vec_len = sqlite3_column_bytes(stmt, ST_COL_6);
results[idx].score = vs_min_cosine_score(node_vec, node_vec_len, kw_vecs, actual_kw);
return results;
}
int cbm_store_vector_search(cbm_store_t *s, const char *project, const char **keywords,
int keyword_count, int limit, cbm_vector_result_t **out,
int *out_count) {
*out = NULL;
*out_count = 0;
if (!s || !project || !keywords || keyword_count <= 0) {
return CBM_STORE_ERR;
}
int8_t kw_vecs[VS_MAX_KW][VS_VEC_DIM];
int actual_kw = vs_build_keyword_vectors(s, project, keywords, keyword_count, kw_vecs);
if (actual_kw == 0) {
return CBM_STORE_OK;
}
/* Scan all node vectors, compute per-keyword cosine, take min.
* We use the FIRST keyword as the SQL sort (for top-K pre-filter),
* then re-score with min across all keywords in the append helper. */
const char *sql = "SELECT n.id, n.name, n.qualified_name, n.file_path, n.label,"
" cbm_cosine_i8(v.vector, ?1) as score, v.vector"
" FROM node_vectors v"
" INNER JOIN nodes n ON n.id = v.node_id"
" WHERE v.project = ?2"
" AND n.label IN (" CBM_SQL_CALLABLE_OR_TYPE_LABELS ")"
" ORDER BY score DESC"
" LIMIT ?3";
sqlite3_stmt *stmt = NULL;
int prep_rc = sqlite3_prepare_v2(s->db, sql, SQLITE_AUTO_LEN, &stmt, NULL);
if (prep_rc != SQLITE_OK) {
(void)fprintf(stderr, "vector_search: %s\n", sqlite3_errmsg(s->db));
return CBM_STORE_ERR;
}
/* Use first keyword for SQL pre-filter, fetch more candidates for re-ranking */
int fetch_limit = (limit > 0 ? limit : CBM_SZ_16) * ST_COL_5;
sqlite3_bind_blob(stmt, SKIP_ONE, kw_vecs[0], VS_VEC_DIM, SQLITE_STATIC);
sqlite3_bind_text(stmt, ST_COL_2, project, SQLITE_AUTO_LEN, SQLITE_STATIC);
sqlite3_bind_int(stmt, ST_COL_3, fetch_limit);
{
char kw_buf[VS_STR_BUF];
char fl_buf[VS_STR_BUF];
snprintf(kw_buf, sizeof(kw_buf), "%d", actual_kw);
snprintf(fl_buf, sizeof(fl_buf), "%d", fetch_limit);
cbm_log_info("vector_search.exec", "kw_count", kw_buf, "fetch_limit", fl_buf, "project",
project);
}
cbm_vector_result_t *results = NULL;
int count = 0;
int cap = 0;
int step_rc = 0;
while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) {
cbm_vector_result_t *grown =
vs_append_result(results, &count, &cap, stmt, kw_vecs, actual_kw);
if (!grown) {
break;
}
results = grown;
}
if (step_rc != SQLITE_DONE) {
char rc_buf[VS_STR_BUF];
snprintf(rc_buf, sizeof(rc_buf), "%d", step_rc);
cbm_log_warn("vector_search.step_error", "rc", rc_buf, "msg", sqlite3_errmsg(s->db));
}
{
char cnt_buf[VS_STR_BUF];
snprintf(cnt_buf, sizeof(cnt_buf), "%d", count);
cbm_log_info("vector_search.done", "candidates", cnt_buf);
}
sqlite3_finalize(stmt);
/* Re-sort by min-score (SQL sorted by first keyword only) */
for (int i = 0; i < count - SKIP_ONE; i++) {
for (int j = i + SKIP_ONE; j < count; j++) {
if (results[j].score > results[i].score) {
cbm_vector_result_t tmp = results[i];
results[i] = results[j];
results[j] = tmp;
}
}
}
/* Trim to requested limit */
int final_limit = limit > 0 ? limit : CBM_SZ_16;
if (count > final_limit) {
for (int i = final_limit; i < count; i++) {
free(results[i].name);
free(results[i].qualified_name);
free(results[i].file_path);
free(results[i].label);
}
count = final_limit;
}
*out = results;
*out_count = count;
return CBM_STORE_OK;
}