mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-10-26 06:43:15 +08:00
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>
8553 lines
309 KiB
C
8553 lines
309 KiB
C
/*
|
||
* 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(§ions);
|
||
|
||
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(§ions);
|
||
cbm_adr_sections_free(§ions);
|
||
|
||
/* 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;
|
||
}
|