/* * 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 #include #include #include "foundation/constants.h" #include "foundation/arena.h" #include "foundation/hash_table.h" #include "foundation/mem_core.h" #include "foundation/sha256.h" #include 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_COL_10 = 10, 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 = 48, /* LIKE pre-filters + anchored-name range bounds add binds */ 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_NAME_RANGE_PREFIX = 64, /* longest anchored name prefix seeked on */ ST_NAME_RANGE_LETTERS = 3, /* case-insensitive: letters expanded into case variants */ ST_NAME_RANGE_MAX = 8, /* 2^ST_NAME_RANGE_LETTERS name ranges (UNION ALL arms) */ ST_NAME_RANGE_ARM_SQL = 96, /* per-arm SQL beyond the select list and WHERE */ 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, ST_BFS_CTE_ROW_MULTIPLIER = 8, ST_BFS_MAX_CTE_ROWS = 4096, /* SQLite database header (fileformat2 §1.3) */ ST_DB_HEADER_LEN = 100, ST_HDR_WRITE_VERSION = 18, /* 1 = rollback journal, 2 = WAL */ ST_HDR_READ_VERSION = 19, ST_HDR_ROLLBACK = 1, ST_HDR_CHANGE_COUNTER = 24, ST_HDR_PAGE_COUNT = 28, ST_BE_SHIFT_8 = 8, ST_BE_SHIFT_16 = 16, ST_BE_SHIFT_24 = 24, }; #define SLEN(s) (sizeof(s) - 1) #include "store/store.h" #include "store/graph_mem.h" #include #include "foundation/compat_fs.h" #include "foundation/platform.h" #include "foundation/compat.h" #include "foundation/log.h" #include "foundation/mem_core.h" #include "foundation/compat_regex.h" #include "callable_sig.h" /* cbm_qn_callable_base_len: base-match tier */ #include "foundation/mem_core.h" /* cbm_alloc: pattern buffers */ #include "foundation/str_util.h" #define XXH_INLINE_ALL #include "xxhash/xxhash.h" #include #include #include #include #include #include #include /* ── 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 void *progress_owner; /* exclusive native progress guard token */ const char *db_path; /* heap-allocated, or NULL for :memory: */ char errbuf[CBM_SZ_512]; bool read_batch; /* a read batch's transaction is open */ uint64_t read_batch_started_ms; /* when it was last (re)begun */ /* In-memory adjacency (graph_mem.h). Only a read-only query store of a * sealed (rollback-journal) DB qualifies, keyed by its generation and * file state (store_rollback_file_state). Acquired on the first walk, * released at close. */ bool query_store; bool graph_mem_tried; cbm_graph_mem_t *graph_mem; cbm_graph_mem_types_t graph_mem_degree_mask; /* search's in_deg/out_deg types */ /* 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; 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. */ /* ── Read batch ──────────────────────────────────────────────────── * A read tool issues thousands of small statements. In autocommit mode each * one takes and drops the database file lock and stats the journal and WAL * files — most of a whole-graph count(*)'s time. A read batch runs them in one * deferred read transaction, renewed every ST_READ_BATCH_MS at the next * statement: a writer never waits behind the batch much longer than that, and * every statement still reads data committed at most that long ago (while the * batch holds a rollback-journal database's shared lock, no commit happens). * Committing a read transaction is allowed with read statements still * stepping; their lock is then held until they finish, as before. */ enum { ST_READ_BATCH_MS = 100 }; bool cbm_store_read_batch_begin(cbm_store_t *s) { if (!s || !s->db || s->read_batch || !sqlite3_get_autocommit(s->db)) { return false; } if (sqlite3_exec(s->db, "BEGIN DEFERRED;", NULL, NULL, NULL) != SQLITE_OK) { return false; } s->read_batch = true; s->read_batch_started_ms = cbm_now_ms(); return true; } void cbm_store_read_batch_end(cbm_store_t *s) { if (!s || !s->read_batch) { return; } s->read_batch = false; if (s->db && !sqlite3_get_autocommit(s->db)) { (void)sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL); } } void cbm_store_read_batch_expire_for_test(cbm_store_t *s) { if (s && s->read_batch) { uint64_t now = cbm_now_ms(); s->read_batch_started_ms = now > ST_READ_BATCH_MS ? now - ST_READ_BATCH_MS : 0; } } static void read_batch_renew(cbm_store_t *s) { if (!s->read_batch || cbm_now_ms() - s->read_batch_started_ms < ST_READ_BATCH_MS) { return; } cbm_store_read_batch_end(s); (void)cbm_store_read_batch_begin(s); } static sqlite3_stmt *prepare_cached(cbm_store_t *s, sqlite3_stmt **slot, const char *sql) { if (!s || !s->db) { return NULL; } read_batch_renew(s); 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 ─────────────────────────────────────────────────────── */ bool cbm_store_edges_schema_current(cbm_store_t *s) { sqlite3_stmt *probe = NULL; if (!s || !s->db || sqlite3_prepare_v2(s->db, "SELECT local_name_gen FROM edges LIMIT 0;", CBM_NOT_FOUND, &probe, NULL) != SQLITE_OK) { sqlite3_finalize(probe); return false; } sqlite3_finalize(probe); return true; } 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` says which of three things * happened, and `detail` means something different in each: * * "parse_partial" the file WAS indexed, but the parse tree had * ERROR/MISSING regions. detail = 1-based line * ranges, "start-end,start-end", with an optional * trailing "+" saying N more ranges were dropped * by the producer's cap. Read those lines. * "parse_unusable" the file WAS indexed, but one range covers 80% or * more of it, so naming the lines is useless advice. * detail = the same range string. Read the source. * a skip phase the file was NOT indexed at all: "read", * "extract" or "oversized". detail = the reason. * * The first two are easy to confuse with the third, and the difference * matters to a reader: a skipped file is absent from the graph, while * the other two are present but incomplete. Name a new kind so that * distinction stays obvious — "parse_failed" would read as a skip. * * 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)" ");" "CREATE INDEX IF NOT EXISTS idx_index_coverage_project_kind " "ON index_coverage(project, kind);" /* Derived candidate lookup: inbound traces read only this index, not * every file's diagnostic JSON. An empty candidate marks truncated or * malformed evidence, whose omitted candidates cannot be ruled out. */ "CREATE TABLE IF NOT EXISTS index_unresolved_candidates (" " project TEXT NOT NULL," " candidate TEXT NOT NULL," " rel_path TEXT NOT NULL," " kind TEXT NOT NULL DEFAULT 'unresolved_calls' CHECK(kind='unresolved_calls')," " PRIMARY KEY(project, candidate, rel_path)," " FOREIGN KEY(project, rel_path, kind) " " REFERENCES index_coverage(project, rel_path, kind) ON DELETE CASCADE" ");" /* Observed runtime calls (ingest_traces). Deliberately SEPARATE from * the graph tables (like index_coverage) and keyed by QUALIFIED NAME, * not node id: node ids are reassigned on every reindex, so id-keyed * observation would silently rot — qualified names survive by * construction and join at read time. One row per (caller, callee, * run label); count accumulates across ingests of the same run. */ "CREATE TABLE IF NOT EXISTS observed_calls (" " project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE," " caller_qn TEXT NOT NULL," " callee_qn TEXT NOT NULL," " label TEXT NOT NULL DEFAULT ''," " count INTEGER NOT NULL DEFAULT 1," " first_seen TEXT NOT NULL," " last_seen TEXT NOT NULL," " PRIMARY KEY (project, caller_qn, callee_qn, label)" ");" /* Whole observed journeys, kept alongside the pair decomposition so * the Flows view can show "this path actually ran", not only * per-edge markers. path_json is the JSON array of qualified names. */ "CREATE TABLE IF NOT EXISTS observed_paths (" " project TEXT NOT NULL REFERENCES projects(name) ON DELETE CASCADE," " label TEXT NOT NULL DEFAULT ''," " path_json TEXT NOT NULL," " count INTEGER NOT NULL DEFAULT 1," " first_seen TEXT NOT NULL," " last_seen TEXT NOT NULL," " PRIMARY KEY (project, label, path_json)" ");" /* 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," " unresolved_calls_complete INTEGER NOT NULL DEFAULT 0" ");"; int rc = exec_sql(s, ddl); if (rc != CBM_STORE_OK) { return rc; } /* Existing writable stores retain their ordinary coverage metadata, but * unresolved capture is untrusted until a full generation records it. */ sqlite3_stmt *coverage_columns = NULL; if (sqlite3_prepare_v2(s->db, "PRAGMA table_info(index_coverage_meta);", CBM_NOT_FOUND, &coverage_columns, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "coverage meta schema prepare"); return CBM_STORE_ERR; } bool have_unresolved_complete = false; int column_rc; while ((column_rc = sqlite3_step(coverage_columns)) == SQLITE_ROW) { const char *name = (const char *)sqlite3_column_text(coverage_columns, SKIP_ONE); if (name && strcmp(name, "unresolved_calls_complete") == 0) { have_unresolved_complete = true; } } sqlite3_finalize(coverage_columns); if (column_rc != SQLITE_DONE) { store_set_error_sqlite(s, "coverage meta schema scan"); return CBM_STORE_ERR; } if (!have_unresolved_complete && exec_sql(s, "ALTER TABLE index_coverage_meta ADD COLUMN " "unresolved_calls_complete INTEGER NOT NULL DEFAULT 0;") != CBM_STORE_OK) { return CBM_STORE_ERR; } /* 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. */ if (!cbm_store_edges_schema_current(s)) { cbm_log_warn("store.schema", "result", "incompatible", "missing", "edges.local_name_gen"); return CBM_STORE_ERR; } /* 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). * * `body` (#518/#519) carries each node's prose — its docstring — so a * question phrased in words rather than identifiers still finds the node. * It is deliberately NOT an index-format change: IF NOT EXISTS leaves a * legacy four-column table exactly as it is, cbm_store_fts_rebuild() * probes for the column before naming it, and bm25()'s surplus weight is * inert on a table that has no fifth column. A database written by an * older build therefore keeps opening and searching — without prose — * instead of forcing every user to reindex. */ { 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, body," " 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; } /* ── FTS backfill ───────────────────────────────────────────────── */ /* Prose source for nodes_fts.body: the docstring each node already carries in * its properties JSON. Deriving it costs no new column on `nodes` and no new * table, which is exactly why CBM_INDEX_FORMAT_VERSION does not move and no * user is forced to reindex. * - the LIKE prefilter keeps the JSON parse off the large majority of nodes * that have no docstring at all; * - json_valid() guards the parse because json_extract() RAISES on malformed * properties, and pre-fix databases contain such rows (see the reverted * is_entry_point expression index in create_user_indexes) — unguarded, one * bad row would abort the entire backfill. */ #define FTS_BODY_EXPR \ "CASE WHEN properties LIKE '%\"docstring\"%' AND json_valid(properties) " \ "THEN json_extract(properties, '$.docstring') END" /* nodes_fts.qualified_name: the QN without the C-macro fence. A macro's QN is * `.NAME#macro` (CBM_MACRO_QN_SUFFIX in internal/cbm/cbm.h), and `#` * separates tokens, so the fence would add the word "macro" to every macro * node a second time (the label column already says Macro). On a C repository * that is thousands of rows outscoring the few nodes that carry the word in * their NAME, enough to push those out of the BM25 candidate window. */ #define FTS_QN_EXPR \ "CASE WHEN label = 'Macro' AND substr(qualified_name, -6) = '#macro' " \ "THEN substr(qualified_name, 1, length(qualified_name) - 6) " \ "ELSE qualified_name END" enum { FTS_SQL_BUF = 768 }; /* Does nodes_fts carry the `body` column? A database created by an older * build has only the four identifier columns, and CREATE VIRTUAL TABLE IF NOT * EXISTS does not widen it. Probe rather than assume: naming a column that * is not there would fail the backfill outright, and the contract for a legacy * database is "still searchable, just without prose". */ static bool fts_has_body_column(cbm_store_t *s) { sqlite3_stmt *probe = NULL; if (sqlite3_prepare_v2(s->db, "SELECT body FROM nodes_fts LIMIT 0;", CBM_NOT_FOUND, &probe, NULL) != SQLITE_OK) { return false; } sqlite3_finalize(probe); return true; } /* One backfill attempt. CBM_STORE_NOT_FOUND means the statement would not * even prepare (missing table, column or SQL function) so the caller may retry * with a narrower shape; CBM_STORE_ERR means the write itself failed. */ static int fts_backfill_try(cbm_store_t *s, const char *project, int64_t after_id, bool with_body, bool camel) { char sql[FTS_SQL_BUF]; int n = snprintf(sql, sizeof(sql), "INSERT INTO nodes_fts (rowid, name, qualified_name, label, file_path%s)" " SELECT id, %s, " FTS_QN_EXPR ", label, file_path%s FROM nodes%s;", with_body ? ", body" : "", camel ? "cbm_camel_split(name)" : "name", with_body ? ", " FTS_BODY_EXPR : "", project ? " WHERE project = ?1 AND id > ?2" : ""); if (n <= 0 || (size_t)n >= sizeof(sql)) { return CBM_STORE_ERR; } sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { return CBM_STORE_NOT_FOUND; } if (project) { sqlite3_bind_text(stmt, ST_COL_1, project, CBM_NOT_FOUND, BIND_TRANSIENT); sqlite3_bind_int64(stmt, ST_COL_2, after_id); } int rc = sqlite3_step(stmt) == SQLITE_DONE ? CBM_STORE_OK : CBM_STORE_ERR; if (rc != CBM_STORE_OK) { store_set_error_sqlite(s, "fts_backfill"); } sqlite3_finalize(stmt); return rc; } int cbm_store_fts_rebuild(cbm_store_t *s, const char *project, int64_t after_id) { if (!s || !s->db) { return CBM_STORE_ERR; } /* Wholesale only: the incremental caller adds rows to a live index. */ if (!project && exec_sql(s, "INSERT INTO nodes_fts(nodes_fts) VALUES('delete-all');") != CBM_STORE_OK) { return CBM_STORE_ERR; } /* Degrade one capability at a time, widest first. Dropping `body` covers a * legacy four-column table or a build without JSON1; dropping * cbm_camel_split covers a connection where the function is not registered * (the pre-existing fallback). Each attempt is a single statement, so a * failed one rolls itself back before the next runs. */ const bool with_body = fts_has_body_column(s); const struct { bool body; bool camel; } ladder[] = {{true, true}, {true, false}, {false, true}, {false, false}}; int rc = CBM_STORE_NOT_FOUND; for (size_t i = 0; i < sizeof(ladder) / sizeof(ladder[0]); i++) { if (ladder[i].body && !with_body) { continue; } rc = fts_backfill_try(s, project, after_id, ladder[i].body, ladder[i].camel); if (rc == CBM_STORE_OK) { return rc; } } return rc; } 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; } /* #1419: page cache for read-write on-disk connections, in SQLite's negative * KiB form. sqlite_writer.c gives every index 64 KiB pages, so SQLite's * default 2000 KiB holds only ~31 of them, while a delta's purge and patch * dirty pages across the node and edge tables and all of their indexes. The * transaction then spills dirty pages into the WAL and rewrites them over and * over (5.2 GiB of WAL writes for a one-line change on a 294 MB django index). * 64 MiB = 1024 pages, the budget bulk mode already used; the cache only grows * as pages are touched. Query connections keep the default. */ static const char ST_WRITE_CACHE_SQL[] = "PRAGMA cache_size = -65536;"; /* 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 (~64 MiB: the default * 1000-page autocheckpoint at the 64 KiB page size sqlite_writer.c * gives every index), 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; } rc = exec_sql(s, ST_WRITE_CACHE_SQL); 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; } int rc = cbm_regcomp(re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB); if (rc != 0) { free(re); sqlite3_result_error(ctx, rc == CBM_REG_ETOOBIG ? CBM_REG_ETOOBIG_REASON : "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; } int rc = cbm_regcomp(re, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB | CBM_REG_ICASE); if (rc != 0) { free(re); sqlite3_result_error(ctx, rc == CBM_REG_ETOOBIG ? CBM_REG_ETOOBIG_REASON : "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; } /* 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). Returns false with s->db * closed when the DB cannot be opened. */ static bool query_open_first_access(cbm_store_t *s, const char *db_path) { char open_path[4096]; if (!cbm_path_for_file_api(db_path, open_path, sizeof(open_path))) { return false; } 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)) { return false; } char uri[ST_QUERY_URI_MAX]; if (!build_immutable_uri(db_path, uri, sizeof(uri))) { return false; } 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); s->db = NULL; return false; } } return true; } #ifdef CBM_ENABLE_TEST_SEAMS static void (*g_first_access_hook)(void *ctx) = NULL; static void *g_first_access_hook_ctx = NULL; void cbm_store_query_first_access_hook_for_testing(void (*hook)(void *ctx), void *ctx) { g_first_access_hook = hook; g_first_access_hook_ctx = ctx; } static void query_first_access_hook(void) { if (g_first_access_hook) { g_first_access_hook(g_first_access_hook_ctx); } } #else static void query_first_access_hook(void) {} #endif 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; } /* One query connection at a time takes its first look at the WAL. On an * idle DB (no connection open, -shm reset) that first read runs WAL * recovery, rebuilding the shared wal-index header under SQLite's write * lock, while another opener reads the same header lock-free before it * takes its read lock (walTryBeginRead). Across processes SQLite's file * locks order the two; between this process's request threads nothing * did, and TSan reported walTryBeginRead against walIndexRecover from * concurrent index_repository requests. Holding one process-wide mutex * across open + first access puts a recovery before the next connection's * first read. The probe is one sqlite_master read, so the hold is short. */ sqlite3_mutex *first_access = sqlite3_mutex_alloc(SQLITE_MUTEX_STATIC_APP1); sqlite3_mutex_enter(first_access); bool opened = query_open_first_access(s, db_path); query_first_access_hook(); sqlite3_mutex_leave(first_access); if (!opened) { free(s); return NULL; } s->db_path = heap_strdup(db_path); s->query_store = true; /* 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; } cbm_store_read_batch_end(s); /* 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); 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); cbm_graph_mem_release(s->graph_mem); 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;"); } /* One exclusive native progress owner per connection. SQLite has no getter * for an externally installed handler; direct replacement during this borrow * is outside the request-owned connection contract. */ static bool store_progress_acquire(cbm_store_t *s, const void *owner, int interval, int (*callback)(void *), void *context) { if (!s || !s->db || !owner || s->progress_owner) { return false; } s->progress_owner = owner; sqlite3_progress_handler(s->db, interval, callback, context); return true; } static bool store_progress_release(cbm_store_t *s, const void *owner) { if (!s || !s->db || s->progress_owner != owner) { return false; } sqlite3_progress_handler(s->db, 0, NULL, NULL); s->progress_owner = NULL; return true; } struct cbm_store_read_scope { CBMArena arena; cbm_store_t *store; cbm_store_cancel_fn cancel; void *context; int status; int saved_query_only; int saved_busy_timeout; bool query_only_saved; bool busy_timeout_saved; bool query_only_changed; bool busy_timeout_changed; bool transaction; bool progress_owned; sqlite3_stmt *pin; }; int cbm_store_read_scope_fail(cbm_store_read_scope_t *scope, int status) { if (!scope) { return CBM_STORE_ERR; } if (scope->status == CBM_STORE_OK) { scope->status = status == CBM_STORE_CANCELLED ? CBM_STORE_CANCELLED : CBM_STORE_ERR; } return scope->status; } /* This helper is also called from SQLite. It must not enter SQLite itself. */ static int store_scope_poll(cbm_store_read_scope_t *scope) { if (scope->status == CBM_STORE_OK && scope->cancel && scope->cancel(scope->context)) { scope->status = CBM_STORE_CANCELLED; } return scope->status; } static int store_scope_progress(void *context) { return store_scope_poll((cbm_store_read_scope_t *)context) != CBM_STORE_OK; } int cbm_store_read_scope_check(cbm_store_read_scope_t *scope) { if (!scope) { return CBM_STORE_ERR; } if (store_scope_poll(scope) != CBM_STORE_OK) { return scope->status; } if (!scope->transaction || !scope->store || !scope->store->db || scope->store->progress_owner != scope || sqlite3_get_autocommit(scope->store->db)) { return cbm_store_read_scope_fail(scope, CBM_STORE_ERR); } return CBM_STORE_OK; } cbm_store_t *cbm_store_read_scope_store(const cbm_store_read_scope_t *scope) { return scope ? scope->store : NULL; } static bool store_scope_pragma_get(cbm_store_t *s, const char *sql, int *out) { sqlite3_stmt *stmt = NULL; bool ok = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) == SQLITE_OK; if (ok) { ok = sqlite3_step(stmt) == SQLITE_ROW && sqlite3_column_type(stmt, 0) == SQLITE_INTEGER; if (ok) { *out = sqlite3_column_int(stmt, 0); ok = sqlite3_step(stmt) == SQLITE_DONE; } } if (sqlite3_finalize(stmt) != SQLITE_OK) { ok = false; } return ok; } /* No callback is invoked during cleanup. Even failed open has to report a * restoration error distinctly: the caller has no scope left to inspect. */ static int store_scope_cleanup(cbm_store_read_scope_t *scope) { cbm_store_t *s = scope->store; bool clean = true; if (scope->progress_owned) { clean = store_progress_release(s, scope); scope->progress_owned = false; } if (scope->pin) { /* reset may report the preceding interrupted step, not a new cleanup * failure. finalize still destroys this exclusively owned statement. */ sqlite3_reset(scope->pin); if (sqlite3_finalize(scope->pin) != SQLITE_OK) { clean = false; } scope->pin = NULL; } if (scope->transaction) { if (sqlite3_get_autocommit(s->db)) { cbm_store_read_scope_fail(scope, CBM_STORE_ERR); } else if (exec_sql(s, "ROLLBACK;") != CBM_STORE_OK || !sqlite3_get_autocommit(s->db)) { clean = false; } scope->transaction = false; } if (scope->query_only_changed && scope->query_only_saved) { const char *sql = scope->saved_query_only ? "PRAGMA query_only=ON;" : "PRAGMA query_only=OFF;"; int restored = -1; if (exec_sql(s, sql) != CBM_STORE_OK || !store_scope_pragma_get(s, "PRAGMA query_only;", &restored) || restored != scope->saved_query_only) { clean = false; } } if (scope->busy_timeout_changed && scope->busy_timeout_saved) { int restored = -1; if (sqlite3_busy_timeout(s->db, scope->saved_busy_timeout) != SQLITE_OK || !store_scope_pragma_get(s, "PRAGMA busy_timeout;", &restored) || restored != scope->saved_busy_timeout) { clean = false; } } int status = clean ? scope->status : CBM_STORE_SCOPE_DISCARD; CBMArena arena = scope->arena; /* scope itself lives in this arena */ cbm_arena_destroy(&arena); return status; } int cbm_store_read_scope_close(cbm_store_read_scope_t *scope) { return scope ? store_scope_cleanup(scope) : CBM_STORE_OK; } int cbm_store_read_scope_open(cbm_store_t *s, cbm_store_cancel_fn cancel, void *context, cbm_store_read_scope_t **out) { if (out) { *out = NULL; } if (!s || !s->db || !out || s->progress_owner || !sqlite3_get_autocommit(s->db)) { return CBM_STORE_ERR; } for (sqlite3_stmt *stmt = sqlite3_next_stmt(s->db, NULL); stmt; stmt = sqlite3_next_stmt(s->db, stmt)) { if (sqlite3_stmt_busy(stmt)) { return CBM_STORE_ERR; } } CBMArena arena; cbm_arena_init(&arena); cbm_store_read_scope_t *scope = cbm_arena_calloc(&arena, sizeof(*scope)); if (!scope) { cbm_arena_destroy(&arena); return CBM_STORE_ERR; } scope->arena = arena; scope->store = s; scope->cancel = cancel; scope->context = context; if (store_scope_poll(scope) != CBM_STORE_OK) { return store_scope_cleanup(scope); } if (!store_progress_acquire(s, scope, 1000, store_scope_progress, scope)) { cbm_store_read_scope_fail(scope, CBM_STORE_ERR); return store_scope_cleanup(scope); } scope->progress_owned = true; if (!store_scope_pragma_get(s, "PRAGMA busy_timeout;", &scope->saved_busy_timeout) || scope->saved_busy_timeout < 0) { goto fail; } scope->busy_timeout_saved = true; scope->busy_timeout_changed = true; if (sqlite3_busy_timeout(s->db, 0) != SQLITE_OK || store_scope_poll(scope) != CBM_STORE_OK) { goto fail; } if (!store_scope_pragma_get(s, "PRAGMA query_only;", &scope->saved_query_only) || (scope->saved_query_only != 0 && scope->saved_query_only != 1)) { goto fail; } scope->query_only_saved = true; scope->query_only_changed = true; int query_only = 0; if (exec_sql(s, "PRAGMA query_only=ON;") != CBM_STORE_OK || !store_scope_pragma_get(s, "PRAGMA query_only;", &query_only) || query_only != 1 || store_scope_poll(scope) != CBM_STORE_OK) { goto fail; } int begin_status = exec_sql(s, "BEGIN DEFERRED;"); scope->transaction = !sqlite3_get_autocommit(s->db); if (begin_status != CBM_STORE_OK || !scope->transaction || store_scope_poll(scope) != CBM_STORE_OK) { goto fail; } /* BEGIN alone is not a snapshot. This real main-table read opens one even * for an empty graph; finalizing the statement retains the transaction. */ if (sqlite3_prepare_v2(s->db, "SELECT id FROM main.nodes LIMIT 1;", CBM_NOT_FOUND, &scope->pin, NULL) != SQLITE_OK) { goto fail; } int step = sqlite3_step(scope->pin); if (step != SQLITE_ROW && step != SQLITE_DONE) { goto fail; } if (sqlite3_reset(scope->pin) != SQLITE_OK) { goto fail; } int final = sqlite3_finalize(scope->pin); scope->pin = NULL; if (final != SQLITE_OK || cbm_store_read_scope_check(scope) != CBM_STORE_OK) { goto fail; } *out = scope; return CBM_STORE_OK; fail: cbm_store_read_scope_fail(scope, CBM_STORE_ERR); return store_scope_cleanup(scope); } /* ── Graph comparison ────────────────────────────────────────────── */ typedef struct { cbm_graph_compare_cancel_fn cancel; void *context; bool progress_cancelled; } graph_compare_progress_t; enum { GRAPH_COMPARE_PROGRESS_INTERVAL = 1000 }; typedef struct { sqlite3_stmt *stmt; cbm_graph_node_identity_t identity; bool has_row; } graph_node_cursor_t; typedef struct { sqlite3_stmt *stmt; cbm_graph_edge_identity_t identity; bool has_row; } graph_edge_cursor_t; #ifdef CBM_ENABLE_TEST_SEAMS static atomic_int graph_compare_test_bind_countdown = ATOMIC_VAR_INIT(CBM_NOT_FOUND); static atomic_int graph_compare_test_cancel_countdown = ATOMIC_VAR_INIT(CBM_NOT_FOUND); static atomic_bool graph_compare_test_progress_cancel = ATOMIC_VAR_INIT(false); void cbm_store_compare_test_fail_bind_after(int successful_binds) { atomic_store(&graph_compare_test_bind_countdown, successful_binds); } void cbm_store_compare_test_cancel_after(int successful_checks) { atomic_store(&graph_compare_test_cancel_countdown, successful_checks); } void cbm_store_compare_test_cancel_from_progress(bool enabled) { atomic_store(&graph_compare_test_progress_cancel, enabled); } static bool graph_compare_test_countdown_fires(atomic_int *countdown) { int remaining = atomic_load(countdown); while (remaining >= 0) { int next = remaining == 0 ? CBM_NOT_FOUND : remaining - 1; if (atomic_compare_exchange_weak(countdown, &remaining, next)) { return remaining == 0; } } return false; } #endif static bool graph_compare_is_cancelled(const graph_compare_progress_t *progress) { #ifdef CBM_ENABLE_TEST_SEAMS if (graph_compare_test_countdown_fires(&graph_compare_test_cancel_countdown)) { return true; } #endif return progress && progress->cancel && progress->cancel(progress->context); } static int graph_compare_progress(void *context) { graph_compare_progress_t *progress = (graph_compare_progress_t *)context; #ifdef CBM_ENABLE_TEST_SEAMS if (atomic_load(&graph_compare_test_progress_cancel)) { progress->progress_cancelled = true; return true; } #endif bool cancelled = graph_compare_is_cancelled(progress); progress->progress_cancelled = cancelled; return cancelled; } static int graph_compare_progress_interval(void) { #ifdef CBM_ENABLE_TEST_SEAMS if (atomic_load(&graph_compare_test_progress_cancel)) { return 1; } #endif return GRAPH_COMPARE_PROGRESS_INTERVAL; } static int graph_compare_bind_text(sqlite3_stmt *stmt, int column, const char *value) { #ifdef CBM_ENABLE_TEST_SEAMS if (graph_compare_test_countdown_fires(&graph_compare_test_bind_countdown)) { return SQLITE_NOMEM; } #endif return bind_text(stmt, column, value); } /* SQLite's BINARY collation compares the unsigned bytes of the UTF-8 text. * Keep the merge comparator byte-for-byte equivalent so neither cursor can * be advanced past a row that SQLite considers distinct. */ static int graph_binary_compare(const char *left, const char *right) { const unsigned char *a = (const unsigned char *)safe_str(left); const unsigned char *b = (const unsigned char *)safe_str(right); while (*a && *a == *b) { a++; b++; } return (*a > *b) - (*a < *b); } static int graph_node_compare(const cbm_graph_node_identity_t *left, const cbm_graph_node_identity_t *right) { int cmp = graph_binary_compare(left->qualified_name, right->qualified_name); if (cmp == 0) { cmp = graph_binary_compare(left->label, right->label); } if (cmp == 0) { cmp = graph_binary_compare(left->file_path, right->file_path); } return cmp; } static int graph_edge_compare(const cbm_graph_edge_identity_t *left, const cbm_graph_edge_identity_t *right) { int cmp = graph_node_compare(&left->source, &right->source); if (cmp == 0) { cmp = graph_node_compare(&left->target, &right->target); } if (cmp == 0) { cmp = graph_binary_compare(left->type, right->type); } if (cmp == 0) { cmp = graph_binary_compare(left->local_name_gen, right->local_name_gen); } return cmp; } static int graph_count_rows(cbm_store_t *store, const char *sql, const char *project, int64_t *out) { sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(store->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(store, "compare count prepare"); return CBM_STORE_ERR; } if (graph_compare_bind_text(stmt, SKIP_ONE, project) != SQLITE_OK) { store_set_error(store, "compare count bind failed"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } int step_rc = sqlite3_step(stmt); if (step_rc != SQLITE_ROW) { store_set_error_sqlite(store, "compare count"); sqlite3_finalize(stmt); return step_rc == SQLITE_INTERRUPT ? CBM_STORE_CANCELLED : CBM_STORE_ERR; } *out = sqlite3_column_int64(stmt, 0); step_rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (step_rc != SQLITE_DONE) { store_set_error_sqlite(store, "compare count finish"); return step_rc == SQLITE_INTERRUPT ? CBM_STORE_CANCELLED : CBM_STORE_ERR; } return CBM_STORE_OK; } static int graph_capture_project(cbm_store_t *store, const char *project, cbm_graph_compare_project_t *out) { cbm_project_t row = {0}; int rc = cbm_store_get_project(store, project, &row); if (rc != CBM_STORE_OK) { return rc; } if (!row.name || !row.indexed_at || !row.root_path) { cbm_project_free_fields(&row); store_set_error(store, "compare project allocation failed"); return CBM_STORE_ERR; } snprintf(out->generation, sizeof(out->generation), "%s", safe_str(row.indexed_at)); snprintf(out->index_mode, sizeof(out->index_mode), "unknown"); cbm_coverage_meta_t meta = {0}; rc = cbm_store_coverage_meta_get(store, project, &meta); if (rc == CBM_STORE_OK) { snprintf(out->generation, sizeof(out->generation), "%s", safe_str(meta.generation)); snprintf(out->index_mode, sizeof(out->index_mode), "%s", safe_str(meta.index_mode)); cbm_store_coverage_meta_clear(&meta); } else if (rc != CBM_STORE_NOT_FOUND) { cbm_project_free_fields(&row); return rc; } cbm_project_free_fields(&row); rc = graph_count_rows(store, "SELECT count(*) FROM nodes WHERE project=?1;", project, &out->node_count); if (rc != CBM_STORE_OK) { return rc; } return graph_count_rows(store, "SELECT count(*) FROM edges WHERE project=?1;", project, &out->edge_count); } static int graph_node_cursor_step(cbm_store_t *store, graph_node_cursor_t *cursor) { int rc = sqlite3_step(cursor->stmt); if (rc == SQLITE_DONE) { cursor->has_row = false; return CBM_STORE_OK; } if (rc != SQLITE_ROW) { store_set_error_sqlite(store, "compare node scan"); cursor->has_row = false; return rc == SQLITE_INTERRUPT ? CBM_STORE_CANCELLED : CBM_STORE_ERR; } cursor->identity.qualified_name = safe_str((const char *)sqlite3_column_text(cursor->stmt, 0)); cursor->identity.label = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_1)); cursor->identity.file_path = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_2)); cursor->has_row = true; return CBM_STORE_OK; } static int graph_edge_cursor_step(cbm_store_t *store, graph_edge_cursor_t *cursor) { int rc = sqlite3_step(cursor->stmt); if (rc == SQLITE_DONE) { cursor->has_row = false; return CBM_STORE_OK; } if (rc != SQLITE_ROW) { store_set_error_sqlite(store, "compare edge scan"); cursor->has_row = false; return rc == SQLITE_INTERRUPT ? CBM_STORE_CANCELLED : CBM_STORE_ERR; } cursor->identity.source.qualified_name = safe_str((const char *)sqlite3_column_text(cursor->stmt, 0)); cursor->identity.source.label = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_1)); cursor->identity.source.file_path = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_2)); cursor->identity.target.qualified_name = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_3)); cursor->identity.target.label = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_4)); cursor->identity.target.file_path = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_5)); cursor->identity.type = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_6)); cursor->identity.local_name_gen = safe_str((const char *)sqlite3_column_text(cursor->stmt, ST_COL_7)); cursor->has_row = true; return CBM_STORE_OK; } static int graph_prepare_node_cursor(cbm_store_t *store, const char *project, graph_node_cursor_t *cursor) { static const char sql[] = "SELECT qualified_name,label,replace(coalesce(file_path,''),'\\','/') " "FROM nodes WHERE project=?1 " "ORDER BY qualified_name COLLATE BINARY,label COLLATE BINARY," "replace(coalesce(file_path,''),'\\','/') COLLATE BINARY;"; if (sqlite3_prepare_v2(store->db, sql, CBM_NOT_FOUND, &cursor->stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(store, "compare node prepare"); return CBM_STORE_ERR; } if (graph_compare_bind_text(cursor->stmt, SKIP_ONE, project) != SQLITE_OK) { store_set_error(store, "compare node bind failed"); sqlite3_finalize(cursor->stmt); cursor->stmt = NULL; return CBM_STORE_ERR; } return graph_node_cursor_step(store, cursor); } static int graph_prepare_edge_cursor(cbm_store_t *store, const char *project, graph_edge_cursor_t *cursor) { static const char sql[] = "SELECT sn.qualified_name,sn.label,replace(coalesce(sn.file_path,''),'\\','/')," "tn.qualified_name,tn.label,replace(coalesce(tn.file_path,''),'\\','/'),e.type," "coalesce(e.local_name_gen,'') FROM edges e " "JOIN nodes sn ON sn.id=e.source_id JOIN nodes tn ON tn.id=e.target_id " "WHERE e.project=?1 ORDER BY " "sn.qualified_name COLLATE BINARY,sn.label COLLATE BINARY," "replace(coalesce(sn.file_path,''),'\\','/') COLLATE BINARY," "tn.qualified_name COLLATE BINARY,tn.label COLLATE BINARY," "replace(coalesce(tn.file_path,''),'\\','/') COLLATE BINARY," "e.type COLLATE BINARY,coalesce(e.local_name_gen,'') COLLATE BINARY;"; if (sqlite3_prepare_v2(store->db, sql, CBM_NOT_FOUND, &cursor->stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(store, "compare edge prepare"); return CBM_STORE_ERR; } if (graph_compare_bind_text(cursor->stmt, SKIP_ONE, project) != SQLITE_OK) { store_set_error(store, "compare edge bind failed"); sqlite3_finalize(cursor->stmt); cursor->stmt = NULL; return CBM_STORE_ERR; } return graph_edge_cursor_step(store, cursor); } static int graph_compare_nodes(cbm_store_t *base_store, const char *base_project, cbm_store_t *target_store, const char *target_project, graph_compare_progress_t *progress, cbm_graph_compare_node_fn callback, cbm_graph_compare_result_t *out) { graph_node_cursor_t base = {0}; graph_node_cursor_t target = {0}; int rc = graph_prepare_node_cursor(base_store, base_project, &base); if (rc == CBM_STORE_OK) { rc = graph_prepare_node_cursor(target_store, target_project, &target); } uint64_t merged = 0; while (rc == CBM_STORE_OK && (base.has_row || target.has_row)) { if ((merged++ & UINT64_C(255)) == 0 && graph_compare_is_cancelled(progress)) { rc = CBM_STORE_CANCELLED; break; } int cmp = !base.has_row ? 1 : (!target.has_row ? -1 : graph_node_compare(&base.identity, &target.identity)); if (cmp < 0) { out->nodes_removed_total++; if (callback && !callback(progress->context, false, &base.identity)) { rc = CBM_STORE_CALLBACK_ERR; break; } rc = graph_node_cursor_step(base_store, &base); } else if (cmp > 0) { out->nodes_added_total++; if (callback && !callback(progress->context, true, &target.identity)) { rc = CBM_STORE_CALLBACK_ERR; break; } rc = graph_node_cursor_step(target_store, &target); } else { rc = graph_node_cursor_step(base_store, &base); if (rc == CBM_STORE_OK) { rc = graph_node_cursor_step(target_store, &target); } } } sqlite3_finalize(base.stmt); sqlite3_finalize(target.stmt); return rc; } static int graph_compare_edges(cbm_store_t *base_store, const char *base_project, cbm_store_t *target_store, const char *target_project, graph_compare_progress_t *progress, cbm_graph_compare_edge_fn callback, cbm_graph_compare_result_t *out) { graph_edge_cursor_t base = {0}; graph_edge_cursor_t target = {0}; int rc = graph_prepare_edge_cursor(base_store, base_project, &base); if (rc == CBM_STORE_OK) { rc = graph_prepare_edge_cursor(target_store, target_project, &target); } uint64_t merged = 0; while (rc == CBM_STORE_OK && (base.has_row || target.has_row)) { if ((merged++ & UINT64_C(255)) == 0 && graph_compare_is_cancelled(progress)) { rc = CBM_STORE_CANCELLED; break; } int cmp = !base.has_row ? 1 : (!target.has_row ? -1 : graph_edge_compare(&base.identity, &target.identity)); if (cmp < 0) { out->edges_removed_total++; if (callback && !callback(progress->context, false, &base.identity)) { rc = CBM_STORE_CALLBACK_ERR; break; } rc = graph_edge_cursor_step(base_store, &base); } else if (cmp > 0) { out->edges_added_total++; if (callback && !callback(progress->context, true, &target.identity)) { rc = CBM_STORE_CALLBACK_ERR; break; } rc = graph_edge_cursor_step(target_store, &target); } else { rc = graph_edge_cursor_step(base_store, &base); if (rc == CBM_STORE_OK) { rc = graph_edge_cursor_step(target_store, &target); } } } sqlite3_finalize(base.stmt); sqlite3_finalize(target.stmt); return rc; } static bool graph_scan_limit_exceeded(int64_t base_count, int64_t target_count, uint64_t scan_limit) { if (base_count < 0 || target_count < 0 || (uint64_t)base_count > scan_limit || (uint64_t)target_count > scan_limit) { return true; } return (uint64_t)base_count > scan_limit - (uint64_t)target_count; } int cbm_store_compare_graphs(cbm_store_t *base_store, const char *base_project, cbm_store_t *target_store, const char *target_project, uint64_t scan_limit, cbm_graph_compare_cancel_fn cancel, cbm_graph_compare_node_fn on_node, cbm_graph_compare_edge_fn on_edge, void *context, cbm_graph_compare_result_t *out) { if (!base_store || !target_store || base_store == target_store || !base_project || !base_project[0] || !target_project || !target_project[0] || scan_limit == 0 || !out) { return CBM_STORE_ERR; } memset(out, 0, sizeof(*out)); graph_compare_progress_t progress = {.cancel = cancel, .context = context}; if (graph_compare_is_cancelled(&progress)) { return CBM_STORE_CANCELLED; } int rc = CBM_STORE_OK; bool base_transaction = false; bool target_transaction = false; int progress_interval = graph_compare_progress_interval(); if (!store_progress_acquire(base_store, &progress, progress_interval, graph_compare_progress, &progress)) { return CBM_STORE_ERR; } if (!store_progress_acquire(target_store, &progress, progress_interval, graph_compare_progress, &progress)) { store_progress_release(base_store, &progress); return CBM_STORE_ERR; } rc = exec_sql(base_store, "BEGIN;"); if (rc == CBM_STORE_OK) { base_transaction = true; rc = exec_sql(target_store, "BEGIN;"); } if (rc == CBM_STORE_OK) { target_transaction = true; rc = graph_capture_project(base_store, base_project, &out->base); } if (rc == CBM_STORE_OK) { rc = graph_capture_project(target_store, target_project, &out->target); } if (rc == CBM_STORE_OK && (graph_scan_limit_exceeded(out->base.node_count, out->target.node_count, scan_limit) || graph_scan_limit_exceeded(out->base.edge_count, out->target.edge_count, scan_limit))) { rc = CBM_STORE_SCAN_LIMIT; } if (rc == CBM_STORE_OK) { rc = graph_compare_nodes(base_store, base_project, target_store, target_project, &progress, on_node, out); } if (rc == CBM_STORE_OK) { rc = graph_compare_edges(base_store, base_project, target_store, target_project, &progress, on_edge, out); } if (rc == CBM_STORE_OK && (progress.progress_cancelled || graph_compare_is_cancelled(&progress))) { rc = CBM_STORE_CANCELLED; } if (rc != CBM_STORE_OK && (progress.progress_cancelled || graph_compare_is_cancelled(&progress))) { rc = CBM_STORE_CANCELLED; } store_progress_release(base_store, &progress); store_progress_release(target_store, &progress); if (target_transaction && exec_sql(target_store, "ROLLBACK;") != CBM_STORE_OK && rc == CBM_STORE_OK) { rc = CBM_STORE_ERR; } if (base_transaction && exec_sql(base_store, "ROLLBACK;") != CBM_STORE_OK && rc == CBM_STORE_OK) { rc = CBM_STORE_ERR; } if (rc != CBM_STORE_OK) { memset(out, 0, sizeof(*out)); } return rc; } /* ── 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 * 64 MiB 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, ST_WRITE_CACHE_SQL); } int cbm_store_end_bulk(cbm_store_t *s) { int rc = exec_sql(s, "PRAGMA synchronous = NORMAL;"); if (rc != CBM_STORE_OK) { return rc; } /* Back to the read-write cache, not SQLite's 2000 KiB default (#1419). */ return exec_sql(s, ST_WRITE_CACHE_SQL); } 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 ───────────────────────────────────────────────── */ static int rollback_savepoint_preserving_error(cbm_store_t *s, const char *rollback_sql, const char *release_sql) { char saved_error[sizeof(s->errbuf)]; snprintf(saved_error, sizeof(saved_error), "%s", s->errbuf); (void)sqlite3_exec(s->db, rollback_sql, NULL, NULL, NULL); (void)sqlite3_exec(s->db, release_sql, NULL, NULL, NULL); snprintf(s->errbuf, sizeof(s->errbuf), "%s", saved_error); return CBM_STORE_ERR; } static bool generation_uid_is_canonical(const char *uid) { if (!uid || strlen(uid) != 16) { return false; } for (size_t i = 0; i < 16; i++) { if (!((uid[i] >= '0' && uid[i] <= '9') || (uid[i] >= 'a' && uid[i] <= 'f'))) { return false; } } return true; } static bool generation_counter_parse(const char *text, uint64_t *out) { if (!text || !text[0] || (text[0] == '0' && text[1] != '\0')) { return false; } uint64_t value = 0; for (const unsigned char *p = (const unsigned char *)text; *p; p++) { if (*p < '0' || *p > '9') { return false; } uint64_t digit = (uint64_t)(*p - '0'); if (value > (UINT64_MAX - digit) / 10) { return false; } value = value * 10 + digit; } *out = value; return true; } /* Read and validate the two generation keys. Extra store_meta keys belong to * other store features and are deliberately ignored. */ static int generation_metadata_read(cbm_store_t *s, bool *absent, char uid[17], char counter[21], uint64_t *counter_value) { *absent = false; sqlite3_stmt *object = NULL; int rc = sqlite3_prepare_v2(s->db, "SELECT type FROM sqlite_schema WHERE name='store_meta' " "AND type IN ('table','view') LIMIT 1;", CBM_NOT_FOUND, &object, NULL); if (rc != SQLITE_OK) { store_set_error_sqlite(s, "store generation schema probe prepare"); return CBM_STORE_ERR; } rc = sqlite3_step(object); if (rc == SQLITE_DONE) { sqlite3_finalize(object); *absent = true; return CBM_STORE_OK; } if (rc != SQLITE_ROW) { store_set_error_sqlite(s, "store generation schema probe"); sqlite3_finalize(object); return CBM_STORE_ERR; } const char *object_type = (const char *)sqlite3_column_text(object, 0); bool is_table = object_type && strcmp(object_type, "table") == 0; sqlite3_finalize(object); if (!is_table) { store_set_error(s, "store generation metadata is not a table"); return CBM_STORE_ERR; } sqlite3_stmt *stmt = NULL; rc = sqlite3_prepare_v2(s->db, "SELECT k, v, typeof(v) FROM store_meta " "WHERE k IN ('db_uid','mutation_gen') ORDER BY k;", CBM_NOT_FOUND, &stmt, NULL); if (rc != SQLITE_OK) { store_set_error_sqlite(s, "store generation metadata prepare"); return CBM_STORE_ERR; } bool have_uid = false; bool have_counter = false; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { const char *key = (const char *)sqlite3_column_text(stmt, 0); const char *value = (const char *)sqlite3_column_text(stmt, 1); const char *value_type = (const char *)sqlite3_column_text(stmt, 2); if (!key || !value || !value_type || strcmp(value_type, "text") != 0) { store_set_error(s, "store generation metadata has a non-text value"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } if ((size_t)sqlite3_column_bytes(stmt, 0) != strlen(key) || (size_t)sqlite3_column_bytes(stmt, 1) != strlen(value) || (size_t)sqlite3_column_bytes(stmt, 2) != strlen(value_type)) { store_set_error(s, "store generation metadata contains an embedded NUL"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } if (strcmp(key, "db_uid") == 0 && !have_uid && generation_uid_is_canonical(value)) { snprintf(uid, 17, "%s", value); have_uid = true; } else if (strcmp(key, "mutation_gen") == 0 && !have_counter && generation_counter_parse(value, counter_value)) { snprintf(counter, 21, "%s", value); have_counter = true; } else { store_set_error(s, "store generation metadata is malformed"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } } if (rc != SQLITE_DONE) { store_set_error_sqlite(s, "store generation metadata scan"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } sqlite3_finalize(stmt); if (!have_uid || !have_counter) { store_set_error(s, "store generation metadata is incomplete"); return CBM_STORE_ERR; } return CBM_STORE_OK; } int cbm_store_generation_advance(cbm_store_t *s) { static const char savepoint[] = "SAVEPOINT cbm_generation_advance;"; static const char rollback[] = "ROLLBACK TO cbm_generation_advance;"; static const char release[] = "RELEASE cbm_generation_advance;"; if (!s || !s->db) { return CBM_STORE_ERR; } int rc = SQLITE_OK; if (exec_sql(s, savepoint) != CBM_STORE_OK) { return CBM_STORE_ERR; } bool absent = false; char uid[17] = {0}; char counter[21] = {0}; uint64_t counter_value = 0; if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK) { return rollback_savepoint_preserving_error(s, rollback, release); } if (absent) { if (sqlite3_exec(s->db, "CREATE TABLE store_meta (k TEXT PRIMARY KEY, v TEXT NOT NULL);" "INSERT INTO store_meta VALUES" "('db_uid', lower(hex(randomblob(8))));" "INSERT INTO store_meta VALUES('mutation_gen','0');", NULL, NULL, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "store generation metadata create"); return rollback_savepoint_preserving_error(s, rollback, release); } if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK || absent) { if (absent) { store_set_error(s, "store generation metadata create did not persist"); } return rollback_savepoint_preserving_error(s, rollback, release); } } if (counter_value == UINT64_MAX) { store_set_error(s, "store generation mutation counter overflow"); return rollback_savepoint_preserving_error(s, rollback, release); } char next_counter[21]; snprintf(next_counter, sizeof(next_counter), "%llu", (unsigned long long)(counter_value + 1)); sqlite3_stmt *update = NULL; if (sqlite3_prepare_v2(s->db, "UPDATE store_meta SET v=?1 WHERE k='mutation_gen' AND v=?2;", CBM_NOT_FOUND, &update, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "store generation advance prepare"); return rollback_savepoint_preserving_error(s, rollback, release); } bind_text(update, SKIP_ONE, next_counter); bind_text(update, ST_COL_2, counter); rc = sqlite3_step(update); sqlite3_finalize(update); if (rc != SQLITE_DONE || sqlite3_changes(s->db) != 1) { if (rc != SQLITE_DONE) { store_set_error_sqlite(s, "store generation advance"); } else { store_set_error(s, "store generation advance changed an unexpected row count"); } return rollback_savepoint_preserving_error(s, rollback, release); } if (exec_sql(s, release) != CBM_STORE_OK) { return rollback_savepoint_preserving_error(s, rollback, release); } return CBM_STORE_OK; } int cbm_store_upsert_project(cbm_store_t *s, const char *name, const char *root_path) { static const char savepoint[] = "SAVEPOINT cbm_upsert_project;"; static const char rollback[] = "ROLLBACK TO cbm_upsert_project;"; static const char release[] = "RELEASE cbm_upsert_project;"; if (!s || !s->db || exec_sql(s, savepoint) != CBM_STORE_OK) { return CBM_STORE_ERR; } 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 rollback_savepoint_preserving_error(s, rollback, release); } 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"); sqlite3_reset(stmt); return rollback_savepoint_preserving_error(s, rollback, release); } sqlite3_reset(stmt); if (cbm_store_generation_advance(s) != CBM_STORE_OK) { return rollback_savepoint_preserving_error(s, rollback, release); } if (exec_sql(s, release) != CBM_STORE_OK) { return rollback_savepoint_preserving_error(s, rollback, release); } return CBM_STORE_OK; } /* Opaque store generation for pagination cursors: "ug", * or "legacy" only when the DB genuinely predates store_meta. */ int cbm_store_generation(cbm_store_t *s, char *buf, size_t bufsz) { if (!s || !s->db || !buf || bufsz == 0) { return CBM_STORE_ERR; } bool absent = false; char uid[17] = {0}; char counter[21] = {0}; uint64_t counter_value = 0; if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK) { buf[0] = '\0'; return CBM_STORE_ERR; } (void)counter_value; int written = absent ? snprintf(buf, bufsz, "legacy") : snprintf(buf, bufsz, "u%sg%s", uid, counter); if (written < 0 || (size_t)written >= bufsz) { buf[0] = '\0'; store_set_error(s, "store generation output buffer is too small"); return CBM_STORE_ERR; } return CBM_STORE_OK; } int cbm_store_meta_get(cbm_store_t *s, const char *key, char **out) { if (!out) { return CBM_STORE_ERR; } *out = NULL; if (!s || !s->db || !key) { return CBM_STORE_ERR; } sqlite3_stmt *get = NULL; /* A store that never advanced its generation has no store_meta table: * that is "no value recorded", not an error. */ if (sqlite3_prepare_v2(s->db, "SELECT v FROM store_meta WHERE k = ?1;", CBM_NOT_FOUND, &get, NULL) != SQLITE_OK) { sqlite3_finalize(get); return CBM_STORE_NOT_FOUND; } bind_text(get, SKIP_ONE, key); int rc = sqlite3_step(get); if (rc == SQLITE_ROW) { const char *value = (const char *)sqlite3_column_text(get, 0); *out = cbm_mem_strdup(CBM_MEM_CLASS_STORE, value ? value : ""); sqlite3_finalize(get); return *out ? CBM_STORE_OK : CBM_STORE_ERR; } sqlite3_finalize(get); return rc == SQLITE_DONE ? CBM_STORE_NOT_FOUND : CBM_STORE_ERR; } int cbm_store_meta_put(cbm_store_t *s, const char *key, const char *value) { static const char savepoint[] = "SAVEPOINT cbm_meta_put;"; static const char rollback[] = "ROLLBACK TO cbm_meta_put;"; static const char release[] = "RELEASE cbm_meta_put;"; if (!s || !s->db || !key || !value) { return CBM_STORE_ERR; } if (exec_sql(s, savepoint) != CBM_STORE_OK) { return CBM_STORE_ERR; } /* store_meta is only ever created by the generation seed, which writes * db_uid + mutation_gen with it. Creating the table here without them * would make cbm_store_generation() report malformed metadata, so an * absent table is seeded through that one path. */ bool absent = false; char uid[17] = {0}; char counter[21] = {0}; uint64_t counter_value = 0; if (generation_metadata_read(s, &absent, uid, counter, &counter_value) != CBM_STORE_OK || (absent && cbm_store_generation_advance(s) != CBM_STORE_OK)) { return rollback_savepoint_preserving_error(s, rollback, release); } 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) { store_set_error_sqlite(s, "store_meta put prepare"); sqlite3_finalize(set); return rollback_savepoint_preserving_error(s, rollback, release); } bind_text(set, SKIP_ONE, key); bind_text(set, ST_COL_2, value); int rc = sqlite3_step(set); sqlite3_finalize(set); if (rc != SQLITE_DONE) { store_set_error_sqlite(s, "store_meta put"); return rollback_savepoint_preserving_error(s, rollback, release); } if (exec_sql(s, release) != CBM_STORE_OK) { return rollback_savepoint_preserving_error(s, rollback, release); } 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; } static int doc_links_table_state(cbm_store_t *s); 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';", }; /* doc_link_unresolved exists only in databases published by a build that * writes it; an older database has nothing to clean. */ int doc_links_state = doc_links_table_state(s); if (doc_links_state < 0) { (void)exec_sql(s, "ROLLBACK;"); return CBM_STORE_ERR; } if (doc_links_state > 0) { sqlite3_stmt *dl = NULL; if (sqlite3_prepare_v2(s->db, "DELETE FROM doc_link_unresolved WHERE project = ?1;", CBM_NOT_FOUND, &dl, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "delete project doc_links prepare"); (void)exec_sql(s, "ROLLBACK;"); return CBM_STORE_ERR; } bind_text(dl, SKIP_ONE, name); int dl_rc = sqlite3_step(dl); sqlite3_finalize(dl); if (dl_rc != SQLITE_DONE) { store_set_error_sqlite(s, "delete project doc_links"); (void)exec_sql(s, "ROLLBACK;"); return CBM_STORE_ERR; } } 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)); } static const char st_find_node_by_id_sql[] = "SELECT id, project, label, name, qualified_name, file_path, " "start_line, end_line, properties FROM nodes WHERE id = ?1;"; 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, st_find_node_by_id_sql); 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); if (val) { 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(cbm_store_t *s, const char *project, 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, "SELECT id, project, label, name, qualified_name, file_path, " "start_line, end_line, properties FROM nodes " "WHERE project = ?1;", project, NULL, out, count); } 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); } /* ── Streamed node scan ────────────────────────────────────────────── * cbm_store_find_nodes / cbm_store_find_nodes_by_label materialise every row * at once: 8.5 M kernel nodes with all their strings are 4.9 GB before the * caller sees the first one. The scan below hands the same rows out a page at * a time, and resets each page's statement before returning it, so no read * lock outlives a page: a writer waits behind one page, as behind one eager * lookup, never behind the caller's whole query. * * Pages continue by keyset along the index the eager query walks, so they * concatenate to the eager order: * all nodes idx_nodes_file (project, file_path): the NULL file_path rows by * id (index order puts NULL first), then by (file_path, id). A * page continues with the rest of its last row's file (by id), * then with the files after it: two exact index seeks, where a * (file_path, id) row-value bound seeks the file only and would * rescan a large file from its start on every page; * one label idx_nodes_label (project, label): by id. * open checks that plan with EXPLAIN QUERY PLAN on the eager query itself. * Any other plan (a changed schema, planner statistics) falls back to the * eager scan handed out in batches: the same rows, without the saving. */ #define NODE_SCAN_COLS \ "SELECT id, project, label, name, qualified_name, file_path, start_line, end_line, " \ "properties FROM nodes " typedef enum { NODE_SCAN_LABEL, /* label = ?2 AND id >= ?3 */ NODE_SCAN_NULL_FILE, /* file_path IS NULL AND id >= ?2 */ NODE_SCAN_FILE_FIRST, /* file_path IS NOT NULL: the first file page */ NODE_SCAN_FILE_SAME, /* file_path = ?2 AND id > ?3: the rest of the last file */ NODE_SCAN_FILE_AFTER, /* file_path > ?2: the files after it */ NODE_SCAN_STMTS, } node_scan_stmt_t; enum { NODE_SCAN_COL_FILE = 5 }; /* file_path in NODE_SCAN_COLS */ static const char *const node_scan_sql[NODE_SCAN_STMTS] = { NODE_SCAN_COLS "WHERE project = ?1 AND label = ?2 AND id >= ?3 ORDER BY id LIMIT ?4;", NODE_SCAN_COLS "WHERE project = ?1 AND file_path IS NULL AND id >= ?2 ORDER BY id LIMIT ?3;", NODE_SCAN_COLS "WHERE project = ?1 AND file_path IS NOT NULL ORDER BY file_path, id LIMIT ?2;", NODE_SCAN_COLS "WHERE project = ?1 AND file_path = ?2 AND id > ?3 ORDER BY id LIMIT ?4;", NODE_SCAN_COLS "WHERE project = ?1 AND file_path > ?2 ORDER BY file_path, id LIMIT ?3;", }; struct cbm_store_node_scan { cbm_store_t *store; char *project; char *label; bool paged; node_scan_stmt_t phase; /* LABEL, NULL_FILE, FILE_SAME or FILE_AFTER */ bool keyed; /* last_id (and for files last_file) hold the previous page's end */ int64_t last_id; sqlite3_value *last_file; sqlite3_stmt *stmts[NODE_SCAN_STMTS]; bool done; bool eager_failed; /* fallback: the eager scan itself failed; next reports it once */ cbm_node_t *eager; /* fallback: the eager scan's rows, handed out in order */ int eager_count; int eager_next; }; #ifdef CBM_ENABLE_TEST_SEAMS static atomic_int node_scan_test_fail_countdown = ATOMIC_VAR_INIT(CBM_NOT_FOUND); void cbm_store_node_scan_test_fail_page_after(int successful_pages) { atomic_store(&node_scan_test_fail_countdown, successful_pages); } #endif /* True when SQLite runs `sql` as the single index search `plan`. */ static bool node_scan_plan_is(cbm_store_t *s, const char *sql, const char *plan) { char eqp[CBM_SZ_512]; snprintf(eqp, sizeof(eqp), "EXPLAIN QUERY PLAN %s", sql); sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, eqp, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { sqlite3_finalize(stmt); return false; } int rows = 0; bool match = false; while (sqlite3_step(stmt) == SQLITE_ROW) { const char *detail = (const char *)sqlite3_column_text(stmt, ST_COL_3); match = rows == 0 && detail && strcmp(detail, plan) == 0; rows++; } sqlite3_finalize(stmt); return match && rows == SKIP_ONE; } cbm_store_node_scan_t *cbm_store_node_scan_open(cbm_store_t *s, const char *project, const char *label) { if (!s || !s->db) { return NULL; } cbm_store_node_scan_t *scan = cbm_calloc(CBM_MEM_CLASS_STORE, sizeof(*scan)); if (!scan) { return NULL; } scan->store = s; scan->project = cbm_mem_strdup(CBM_MEM_CLASS_STORE, project); scan->label = cbm_mem_strdup(CBM_MEM_CLASS_STORE, label); if ((project && !scan->project) || (label && !scan->label)) { cbm_store_node_scan_close(scan); return NULL; } scan->phase = label ? NODE_SCAN_LABEL : NODE_SCAN_NULL_FILE; /* NULL file_path rows first */ if (label) { scan->paged = node_scan_plan_is(s, NODE_SCAN_COLS "WHERE project = ?1 AND label = ?2;", "SEARCH nodes USING INDEX idx_nodes_label (project=? AND label=?)"); } else { scan->paged = node_scan_plan_is(s, NODE_SCAN_COLS "WHERE project = ?1;", "SEARCH nodes USING INDEX idx_nodes_file (project=?)"); } if (!scan->paged) { int rc = label ? cbm_store_find_nodes_by_label(s, project, label, &scan->eager, &scan->eager_count) : cbm_store_find_nodes(s, project, &scan->eager, &scan->eager_count); scan->eager_failed = rc != CBM_STORE_OK; } return scan; } bool cbm_store_node_scan_paged(const cbm_store_node_scan_t *scan) { return scan && scan->paged; } /* The current phase has no rows left. The NULL file_path rows hand over to * the files, the rest of a file to the files after it; a label's rows and * the files after the last one end the scan. */ static void node_scan_phase_done(cbm_store_node_scan_t *scan) { if (scan->phase == NODE_SCAN_NULL_FILE) { scan->keyed = false; scan->phase = NODE_SCAN_FILE_AFTER; } else if (scan->phase == NODE_SCAN_FILE_SAME) { scan->phase = NODE_SCAN_FILE_AFTER; /* after last_file */ } else { scan->done = true; } } static void node_scan_bind(cbm_store_node_scan_t *scan, sqlite3_stmt *stmt, node_scan_stmt_t which, int max) { int col = SKIP_ONE; bind_text(stmt, col++, scan->project); if (which == NODE_SCAN_LABEL) { bind_text(stmt, col++, scan->label); } if (which == NODE_SCAN_FILE_SAME || which == NODE_SCAN_FILE_AFTER) { sqlite3_bind_value(stmt, col++, scan->last_file); } if (which == NODE_SCAN_LABEL || which == NODE_SCAN_NULL_FILE) { sqlite3_bind_int64(stmt, col++, scan->keyed ? scan->last_id + SKIP_ONE : INT64_MIN); } else if (which == NODE_SCAN_FILE_SAME) { sqlite3_bind_int64(stmt, col++, scan->last_id); } sqlite3_bind_int(stmt, col, max); } /* One page of the current phase into out[0..max). A short page ends the * phase; the statement is reset before returning either way. */ static int node_scan_page(cbm_store_node_scan_t *scan, cbm_node_t *out, int max, int *got) { *got = 0; cbm_store_t *s = scan->store; node_scan_stmt_t which = scan->phase; if (which == NODE_SCAN_FILE_AFTER && !scan->keyed) { which = NODE_SCAN_FILE_FIRST; } bool after_id = which == NODE_SCAN_LABEL || which == NODE_SCAN_NULL_FILE || which == NODE_SCAN_FILE_SAME; if (after_id && scan->keyed && scan->last_id == INT64_MAX) { node_scan_phase_done(scan); /* nothing can follow the largest id */ return CBM_STORE_OK; } #ifdef CBM_ENABLE_TEST_SEAMS if (graph_compare_test_countdown_fires(&node_scan_test_fail_countdown)) { snprintf(s->errbuf, sizeof(s->errbuf), "node scan aborted: injected page failure"); return CBM_STORE_ERR; } #endif read_batch_renew(s); sqlite3_stmt **slot = &scan->stmts[which]; if (!*slot && sqlite3_prepare_v2(s->db, node_scan_sql[which], CBM_NOT_FOUND, slot, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "node scan prepare"); return CBM_STORE_ERR; } sqlite3_stmt *stmt = *slot; node_scan_bind(scan, stmt, which, max); int n = 0; int rc = SQLITE_ROW; while (n < max && (rc = sqlite3_step(stmt)) == SQLITE_ROW) { scan_node(stmt, &out[n]); n++; } bool failed = n < max && rc != SQLITE_DONE; if (n == max) { /* the statement still sits on the page's last row */ scan->keyed = true; scan->last_id = out[n - SKIP_ONE].id; if (which == NODE_SCAN_FILE_FIRST || which == NODE_SCAN_FILE_AFTER) { /* a new last file: continue with the rest of it */ sqlite3_value_free(scan->last_file); scan->last_file = sqlite3_value_dup(sqlite3_column_value(stmt, NODE_SCAN_COL_FILE)); failed = !scan->last_file; scan->phase = NODE_SCAN_FILE_SAME; } } sqlite3_reset(stmt); if (failed) { store_set_error_sqlite(s, "node scan aborted"); for (int i = 0; i < n; i++) { cbm_node_free_fields(&out[i]); } return CBM_STORE_ERR; } if (n < max) { node_scan_phase_done(scan); } *got = n; return CBM_STORE_OK; } int cbm_store_node_scan_next(cbm_store_node_scan_t *scan, cbm_node_t *out, int max, int *count) { *count = 0; if (!scan || max <= 0) { return CBM_STORE_OK; } if (scan->eager_failed) { scan->eager_failed = false; /* reported once; the scan then reads as exhausted */ return CBM_STORE_ERR; } if (!scan->paged) { int left = scan->eager_count - scan->eager_next; int n = left < max ? left : max; if (n > 0) { memcpy(out, scan->eager + scan->eager_next, (size_t)n * sizeof(cbm_node_t)); memset(scan->eager + scan->eager_next, 0, (size_t)n * sizeof(cbm_node_t)); scan->eager_next += n; } *count = n; return CBM_STORE_OK; } int n = 0; while (n < max && !scan->done) { int got = 0; if (node_scan_page(scan, out + n, max - n, &got) != CBM_STORE_OK) { /* A failed page yields no rows from this call and ends the scan; * cbm_store_error() names the failure. */ for (int i = 0; i < n; i++) { cbm_node_free_fields(&out[i]); } scan->done = true; return CBM_STORE_ERR; } n += got; } *count = n; return CBM_STORE_OK; } void cbm_store_node_scan_close(cbm_store_node_scan_t *scan) { if (!scan) { return; } for (int i = 0; i < NODE_SCAN_STMTS; i++) { sqlite3_finalize(scan->stmts[i]); } sqlite3_value_free(scan->last_file); cbm_store_free_nodes(scan->eager, scan->eager_count); /* rows not handed out */ cbm_free(CBM_MEM_CLASS_STORE, scan->project); cbm_free(CBM_MEM_CLASS_STORE, scan->label); cbm_free(CBM_MEM_CLASS_STORE, scan); } typedef struct { cbm_store_cancel_fn cancel; void *context; } file_outline_cancel_guard_t; static bool file_outline_cancelled(const file_outline_cancel_guard_t *guard) { return guard && guard->cancel && guard->cancel(guard->context); } static int file_outline_progress_cancel(void *context) { return file_outline_cancelled((const file_outline_cancel_guard_t *)context) ? 1 : 0; } static bool file_outline_build_sql(char *sql, size_t sql_size, bool count_only, int label_count) { int written = snprintf(sql, sql_size, count_only ? "SELECT COUNT(*) FROM nodes WHERE project = ?1 AND file_path = ?2 " "AND label NOT IN ('Module','Package','File','Folder','Project')" : "SELECT name, label, qualified_name, start_line, end_line FROM nodes " "WHERE project = ?1 AND file_path = ?2 " "AND label NOT IN ('Module','Package','File','Folder','Project')"); if (written < 0 || (size_t)written >= sql_size) { return false; } size_t used = (size_t)written; if (label_count > 0) { written = snprintf(sql + used, sql_size - used, " AND label IN ("); if (written < 0 || (size_t)written >= sql_size - used) { return false; } used += (size_t)written; for (int i = 0; i < label_count; i++) { written = snprintf(sql + used, sql_size - used, "%s?%d", i > 0 ? "," : "", i + 3); if (written < 0 || (size_t)written >= sql_size - used) { return false; } used += (size_t)written; } written = snprintf(sql + used, sql_size - used, ")"); if (written < 0 || (size_t)written >= sql_size - used) { return false; } used += (size_t)written; } if (!count_only) { int limit_bind = label_count + 3; int offset_bind = limit_bind + 1; written = snprintf(sql + used, sql_size - used, " ORDER BY start_line, end_line, label COLLATE BINARY, " "name COLLATE BINARY, qualified_name COLLATE BINARY, id " "LIMIT ?%d OFFSET ?%d", limit_bind, offset_bind); if (written < 0 || (size_t)written >= sql_size - used) { return false; } } return true; } static void file_outline_bind_common(sqlite3_stmt *stmt, const char *project, const char *file_path, const char *const *labels, int label_count) { bind_text(stmt, ST_COL_1, project); bind_text(stmt, ST_COL_2, file_path); for (int i = 0; i < label_count; i++) { bind_text(stmt, i + 3, labels[i]); } } void cbm_store_free_file_outline(cbm_file_outline_row_t *rows, int count) { if (!rows) { return; } for (int i = 0; i < count; i++) { free((char *)rows[i].name); free((char *)rows[i].label); free((char *)rows[i].qualified_name); } free(rows); } int cbm_store_get_file_outline(cbm_store_t *s, const char *project, const char *file_path, const char *const *labels, int label_count, int limit, int offset, cbm_store_cancel_fn cancel, void *cancel_context, cbm_file_outline_row_t **out, int *count, int *total) { if (out) { *out = NULL; } if (count) { *count = 0; } if (total) { *total = 0; } if (!s || !s->db || !project || !project[0] || !file_path || !file_path[0] || !out || !count || !total || label_count < 0 || label_count > CBM_STORE_FILE_OUTLINE_MAX_LABELS || (label_count > 0 && !labels) || limit < 1 || limit > CBM_STORE_FILE_OUTLINE_MAX_LIMIT || offset < 0) { return CBM_STORE_ERR; } for (int i = 0; i < label_count; i++) { if (!labels[i] || !labels[i][0]) { return CBM_STORE_ERR; } } file_outline_cancel_guard_t guard = {.cancel = cancel, .context = cancel_context}; if (file_outline_cancelled(&guard)) { return CBM_STORE_CANCELLED; } /* Even a NULL cancellation callback must claim ownership: otherwise * this operation's cleanup could remove a surrounding scope's handler. */ if (!store_progress_acquire(s, &guard, 1000, file_outline_progress_cancel, &guard)) { return CBM_STORE_ERR; } char sql[ST_SQL_BUF]; if (!file_outline_build_sql(sql, sizeof(sql), true, label_count)) { store_progress_release(s, &guard); return CBM_STORE_ERR; } 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, "file outline count prepare"); store_progress_release(s, &guard); return CBM_STORE_ERR; } file_outline_bind_common(stmt, project, file_path, labels, label_count); rc = sqlite3_step(stmt); if (rc == SQLITE_ROW) { *total = sqlite3_column_int(stmt, 0); } sqlite3_finalize(stmt); if (rc == SQLITE_INTERRUPT || file_outline_cancelled(&guard)) { store_progress_release(s, &guard); *total = 0; return CBM_STORE_CANCELLED; } if (rc != SQLITE_ROW) { store_set_error_sqlite(s, "file outline count"); store_progress_release(s, &guard); *total = 0; return CBM_STORE_ERR; } if (!file_outline_build_sql(sql, sizeof(sql), false, label_count)) { store_progress_release(s, &guard); *total = 0; return CBM_STORE_ERR; } stmt = NULL; rc = sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL); if (rc != SQLITE_OK) { store_set_error_sqlite(s, "file outline prepare"); store_progress_release(s, &guard); *total = 0; return CBM_STORE_ERR; } file_outline_bind_common(stmt, project, file_path, labels, label_count); sqlite3_bind_int(stmt, label_count + 3, limit); sqlite3_bind_int(stmt, label_count + 4, offset); cbm_file_outline_row_t *rows = calloc((size_t)limit, sizeof(*rows)); if (!rows) { sqlite3_finalize(stmt); store_progress_release(s, &guard); *total = 0; return CBM_STORE_ERR; } size_t text_bytes = 0U; int n = 0; bool was_cancelled = false; for (;;) { if (file_outline_cancelled(&guard)) { was_cancelled = true; break; } rc = sqlite3_step(stmt); if (rc != SQLITE_ROW) { break; } const char *name = (const char *)sqlite3_column_text(stmt, 0); const char *label = (const char *)sqlite3_column_text(stmt, 1); const char *qn = (const char *)sqlite3_column_text(stmt, 2); size_t row_bytes = (size_t)sqlite3_column_bytes(stmt, 0) + (size_t)sqlite3_column_bytes(stmt, 1) + (size_t)sqlite3_column_bytes(stmt, 2) + 3U; if (row_bytes > CBM_STORE_FILE_OUTLINE_MAX_TEXT_BYTES - text_bytes) { sqlite3_finalize(stmt); store_progress_release(s, &guard); cbm_store_free_file_outline(rows, n); *total = 0; return CBM_STORE_SCAN_LIMIT; } rows[n].name = heap_strdup(name ? name : ""); rows[n].label = heap_strdup(label ? label : ""); rows[n].qualified_name = heap_strdup(qn ? qn : ""); rows[n].start_line = sqlite3_column_int(stmt, 3); rows[n].end_line = sqlite3_column_int(stmt, 4); if (!rows[n].name || !rows[n].label || !rows[n].qualified_name) { sqlite3_finalize(stmt); store_progress_release(s, &guard); cbm_store_free_file_outline(rows, n + 1); *total = 0; return CBM_STORE_ERR; } text_bytes += row_bytes; n++; } was_cancelled = was_cancelled || rc == SQLITE_INTERRUPT || file_outline_cancelled(&guard); sqlite3_finalize(stmt); store_progress_release(s, &guard); if (was_cancelled) { cbm_store_free_file_outline(rows, n); *total = 0; return CBM_STORE_CANCELLED; } if (rc != SQLITE_DONE) { store_set_error_sqlite(s, "file outline rows"); cbm_store_free_file_outline(rows, n); *total = 0; return CBM_STORE_ERR; } *out = rows; *count = n; return CBM_STORE_OK; } 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); /* A step that does not yield a row is a failed read (SQLITE_CORRUPT, * SQLITE_BUSY, SQLITE_IOERR), not a count of zero. Report it through the * error channel this function already uses for a failed prepare, so a * caller cannot mistake an unreadable table for an empty project. */ int count = CBM_STORE_ERR; 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); /* Both arrays are STORE-class memory-core blocks: the caller releases them * with cbm_store_free_call_edges, the only free that matches. */ int cap = ST_INIT_CAP_16; int n = 0; int64_t *src = cbm_alloc(CBM_MEM_CLASS_STORE, (size_t)cap * sizeof(int64_t)); int64_t *tgt = cbm_alloc(CBM_MEM_CLASS_STORE, (size_t)cap * sizeof(int64_t)); if (!src || !tgt) { sqlite3_finalize(stmt); cbm_store_free_call_edges(src, tgt); store_set_error(s, "fetch_call_edges: out of memory"); return CBM_STORE_ERR; } 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) { size_t grown_bytes = (size_t)cap * ST_GROWTH * sizeof(int64_t); int64_t *grown_src = cbm_realloc(CBM_MEM_CLASS_STORE, src, grown_bytes); if (grown_src) { src = grown_src; } int64_t *grown_tgt = grown_src ? cbm_realloc(CBM_MEM_CLASS_STORE, tgt, grown_bytes) : NULL; if (grown_tgt) { tgt = grown_tgt; } if (!grown_src || !grown_tgt) { sqlite3_finalize(stmt); cbm_store_free_call_edges(src, tgt); store_set_error(s, "fetch_call_edges: out of memory"); return CBM_STORE_ERR; } cap *= ST_GROWTH; } 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) { cbm_store_free_call_edges(src, 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; } void cbm_store_free_call_edges(int64_t *src, int64_t *tgt) { cbm_free(CBM_MEM_CLASS_STORE, src); cbm_free(CBM_MEM_CLASS_STORE, tgt); } 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); /* See cbm_store_count_nodes: a non-row step is a failed read, not zero. * index_status reads this alongside the node count and already treats a * negative value as degraded. */ int count = CBM_STORE_ERR; 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 * "::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. */ /* Coverage kinds that never materialize in the miss graph. not_indexed_* * rows are by-design exclusions, not failures. unresolved_calls rows (#2265) * describe files that WERE indexed; their call evidence is reported by * check_index_coverage and trace_path, and as a File row it would also * replace a parse_partial row for the same path (rows arrive ordered by * rel_path, kind, and the File upsert keeps the last one). */ static bool cov_kind_outside_miss_graph(const char *kind) { static const char by_design_prefix[] = "not_indexed"; return strncmp(kind, by_design_prefix, sizeof(by_design_prefix) - 1) == 0 || strcmp(kind, "unresolved_calls") == 0; } /* 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 || cov_kind_outside_miss_graph(rows[i].kind)) { 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 void cov_dir_ids_free_entry(const char *key, void *val, void *ud) { (void)ud; free((char *)key); free(val); } static void cov_dir_ids_free(CBMHashTable *ht) { if (!ht) { return; } cbm_ht_foreach(ht, cov_dir_ids_free_entry, NULL); cbm_ht_free(ht); } 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 or unresolved_calls * evidence gets NO graph (not even a bare root node). */ int failure_count = 0; for (int i = 0; i < count; i++) { if (!rows[i].kind || !cov_kind_outside_miss_graph(rows[i].kind)) { 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; } /* Directory nodes repeat massively across failure rows (13k baseline files * under one tests/ subtree meant ~80k redundant upsert/edge round-trips — * 9.1 s of a 9.2 s coverage_replace on the TypeScript corpus). Dedup them * in-memory for the rebuild: first sight of a directory creates its node * and containment edge; every later file under it is a hash hit. */ CBMHashTable *dir_ids = cbm_ht_create(CBM_SZ_256); 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, and * unresolved_calls rows stay out so a parse_partial row for the same * file keeps its File node. */ if (rows[i].kind && cov_kind_outside_miss_graph(rows[i].kind)) { 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'; int64_t *cached = dir_ids ? (int64_t *)cbm_ht_get(dir_ids, pathbuf) : NULL; if (cached) { parent = *cached; *p = '/'; continue; } 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); if (fid <= 0) { *p = '/'; cov_dir_ids_free(dir_ids); 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) { *p = '/'; cov_dir_ids_free(dir_ids); cbm_store_free_coverage(rows, count); return CBM_STORE_ERR; } if (dir_ids) { int64_t *idv = (int64_t *)malloc(sizeof(*idv)); if (idv) { *idv = fid; char *kdup = strdup(pathbuf); if (kdup) { cbm_ht_set(dir_ids, kdup, idv); } else { free(idv); } } } *p = '/'; 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) { cov_dir_ids_free(dir_ids); 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) { cov_dir_ids_free(dir_ids); cbm_store_free_coverage(rows, count); return CBM_STORE_ERR; } } cov_dir_ids_free(dir_ids); 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; } /* Materialize once at publication, after replacement and deleted-file pruning. * json_each sees a safe marker array for malformed/legacy non-array payloads. * The side table cascades with its authoritative coverage rows. */ static int coverage_index_unresolved_candidates(cbm_store_t *s, const char *project) { static const char sql[] = "INSERT OR IGNORE INTO index_unresolved_candidates(project, candidate, rel_path) " "SELECT c.project, CASE WHEN j.type != 'object' OR " "json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, '$.truncated') = 1 " "THEN '' " "ELSE json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, '$.candidate') " "END, c.rel_path " "FROM index_coverage c, json_each(CASE " "WHEN json_valid(c.detail) THEN CASE WHEN json_type(c.detail) = 'array' " "THEN c.detail ELSE '[{\"truncated\":true}]' END " "ELSE '[{\"truncated\":true}]' END) j " "WHERE c.project = ?1 AND c.kind = 'unresolved_calls' AND " "(j.type != 'object' OR json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' " "END, '$.truncated') = 1 OR " "(json_type(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, '$.candidate') = " "'text' AND " "length(json_extract(CASE WHEN j.type = 'object' THEN j.value ELSE '{}' END, " "'$.candidate')) > 0));"; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "unresolved candidate index prepare"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); int rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { store_set_error_sqlite(s, "unresolved candidate index"); return CBM_STORE_ERR; } return CBM_STORE_OK; } /* The caller owns the coverage transaction and rolls it back on failure. */ static int coverage_write_metadata(cbm_store_t *s, const char *project, const cbm_coverage_meta_t *meta) { enum { COVERAGE_META_INITIAL_VERSION = 1 }; if (!meta) { 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"); 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"); return CBM_STORE_ERR; } return CBM_STORE_OK; } char recorded_at[CBM_SZ_64]; if (meta->recorded_at && meta->recorded_at[0]) { (void)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 : COVERAGE_META_INITIAL_VERSION; 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, unresolved_calls_complete) " "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) " "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, " "unresolved_calls_complete=?10;", CBM_NOT_FOUND, &up_meta, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "coverage meta upsert prepare"); 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, ST_COL_6, ignored_stored); sqlite3_bind_int(up_meta, ST_COL_7, ignored_total); sqlite3_bind_int(up_meta, ST_COL_8, coverage_version); sqlite3_bind_int(up_meta, ST_COL_9, meta->hash_records_complete); sqlite3_bind_int(up_meta, ST_COL_10, meta->unresolved_calls_complete); int meta_rc = sqlite3_step(up_meta); sqlite3_finalize(up_meta); if (meta_rc != SQLITE_DONE) { store_set_error_sqlite(s, "coverage meta upsert"); return CBM_STORE_ERR; } 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; } /* Sub-block timings (publish.timing style): coverage_replace measured 9 s * on the TypeScript corpus and the caller-level block could not say WHY — * delete, 13k row inserts with large error_ranges payloads, the NOT-IN * prune, meta, and COMMIT are very different suspects. */ struct timespec cov_t0; struct timespec cov_t1; long cov_ms[5] = {0, 0, 0, 0, 0}; long long cov_detail_bytes = 0; cbm_clock_gettime(CLOCK_MONOTONIC, &cov_t0); 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; } cbm_clock_gettime(CLOCK_MONOTONIC, &cov_t1); cov_ms[0] = (cov_t1.tv_sec - cov_t0.tv_sec) * 1000 + (cov_t1.tv_nsec - cov_t0.tv_nsec) / 1000000; cov_t0 = cov_t1; 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); cov_detail_bytes += rows[i].detail ? (long long)strlen(rows[i].detail) : 0; 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); cbm_clock_gettime(CLOCK_MONOTONIC, &cov_t1); cov_ms[1] = (cov_t1.tv_sec - cov_t0.tv_sec) * 1000 + (cov_t1.tv_nsec - cov_t0.tv_nsec) / 1000000; cov_t0 = cov_t1; /* 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; } cbm_clock_gettime(CLOCK_MONOTONIC, &cov_t1); cov_ms[2] = (cov_t1.tv_sec - cov_t0.tv_sec) * 1000 + (cov_t1.tv_nsec - cov_t0.tv_nsec) / 1000000; cov_t0 = cov_t1; if (coverage_index_unresolved_candidates(s, project) != CBM_STORE_OK) { (void)exec_sql(s, "ROLLBACK;"); return CBM_STORE_ERR; } if (coverage_write_metadata(s, project, meta) != CBM_STORE_OK) { (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; } cbm_clock_gettime(CLOCK_MONOTONIC, &cov_t1); cov_ms[3] = (cov_t1.tv_sec - cov_t0.tv_sec) * 1000 + (cov_t1.tv_nsec - cov_t0.tv_nsec) / 1000000; cov_t0 = cov_t1; int commit_rc = exec_sql(s, "COMMIT;"); cbm_clock_gettime(CLOCK_MONOTONIC, &cov_t1); cov_ms[4] = (cov_t1.tv_sec - cov_t0.tv_sec) * 1000 + (cov_t1.tv_nsec - cov_t0.tv_nsec) / 1000000; { char b0[ST_BUF_64]; char b1[ST_BUF_64]; char b2[ST_BUF_64]; char b3[ST_BUF_64]; char b4[ST_BUF_64]; char bn[ST_BUF_64]; char bb[ST_BUF_64]; snprintf(b0, sizeof(b0), "%ld", cov_ms[0]); snprintf(b1, sizeof(b1), "%ld", cov_ms[1]); snprintf(b2, sizeof(b2), "%ld", cov_ms[2]); snprintf(b3, sizeof(b3), "%ld", cov_ms[3]); snprintf(b4, sizeof(b4), "%ld", cov_ms[4]); snprintf(bn, sizeof(bn), "%d", count); snprintf(bb, sizeof(bb), "%lld", cov_detail_bytes); cbm_log_info("publish.timing.coverage", "del", b0, "rows", b1, "prune", b2, "meta", b3, "commit", b4, "row_count", bn, "detail_bytes", bb); } return commit_rc; } 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); } int cbm_store_coverage_get_unresolved_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 " "AND kind = 'unresolved_calls';"; return coverage_query_rows(s, project, rel_path, sql, out, count); } int cbm_store_coverage_has_unresolved_candidate(cbm_store_t *s, const char *project, const char *candidate, bool *out) { if (!out) { return CBM_STORE_ERR; } *out = false; if (!s || !s->db || !project || !candidate) { return CBM_STORE_ERR; } static const char sql[] = "SELECT 1 FROM index_unresolved_candidates " "WHERE project = ?1 AND candidate IN ('', ?2) LIMIT 1;"; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "unresolved candidate lookup prepare"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); bind_text(stmt, ST_COL_2, candidate); int rc = sqlite3_step(stmt); *out = rc == SQLITE_ROW; sqlite3_finalize(stmt); if (rc != SQLITE_ROW && rc != SQLITE_DONE) { store_set_error_sqlite(s, "unresolved candidate lookup"); return CBM_STORE_ERR; } return CBM_STORE_OK; } 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, unresolved_calls_complete " "FROM index_coverage_meta WHERE project = ?1;", CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { /* Query opens never migrate a legacy database. Preserve its ordinary * report while the absent new signal remains explicitly incomplete. */ 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, 0 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; out->unresolved_calls_complete = sqlite3_column_int(stmt, ST_COL_9) != 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); } /* ── Doc-link unresolved references ─────────────────────────────── */ /* Created at publish, not in init_schema: a database written by an older * build simply has no table, and readers treat that as "no doc-link data" * instead of failing (no index-format change). */ static const char DOC_LINKS_DDL[] = "CREATE TABLE IF NOT EXISTS doc_link_unresolved (" " project TEXT NOT NULL," " rel_path TEXT NOT NULL," " line INTEGER NOT NULL DEFAULT 0," " syntax TEXT NOT NULL DEFAULT ''," " raw TEXT NOT NULL DEFAULT ''," " reason TEXT NOT NULL" ");" "CREATE INDEX IF NOT EXISTS idx_doc_link_unresolved_path " "ON doc_link_unresolved(project, rel_path);"; /* 1 = the table exists, 0 = it does not, -1 = the probe failed. */ static int doc_links_table_state(cbm_store_t *s) { sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, "SELECT 1 FROM sqlite_master WHERE type = 'table' " "AND name = 'doc_link_unresolved';", CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "doc_links probe prepare"); return CBM_NOT_FOUND; } int rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc == SQLITE_ROW) { return SKIP_ONE; } if (rc == SQLITE_DONE) { return 0; } store_set_error_sqlite(s, "doc_links probe"); return CBM_NOT_FOUND; } int cbm_store_doc_links_replace(cbm_store_t *s, const char *project, const cbm_doc_link_row_t *rows, int count) { if (!s || !s->db || !project || count < 0 || (count > 0 && !rows)) { return CBM_STORE_ERR; } if (exec_sql(s, DOC_LINKS_DDL) != CBM_STORE_OK) { 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 doc_link_unresolved WHERE project = ?1;", CBM_NOT_FOUND, &del, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "doc_links 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, "doc_links delete"); (void)exec_sql(s, "ROLLBACK;"); return CBM_STORE_ERR; } sqlite3_stmt *ins = NULL; if (sqlite3_prepare_v2(s->db, "INSERT INTO doc_link_unresolved " "(project, rel_path, line, syntax, raw, reason) " "VALUES (?1, ?2, ?3, ?4, ?5, ?6);", CBM_NOT_FOUND, &ins, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "doc_links 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].reason) { continue; } bind_text(ins, SKIP_ONE, project); bind_text(ins, ST_COL_2, rows[i].rel_path); sqlite3_bind_int(ins, ST_COL_3, rows[i].line); bind_text(ins, CBM_SZ_4, rows[i].syntax ? rows[i].syntax : ""); bind_text(ins, CBM_SZ_5, rows[i].raw ? rows[i].raw : ""); bind_text(ins, CBM_SZ_6, rows[i].reason); if (sqlite3_step(ins) != SQLITE_DONE) { store_set_error_sqlite(s, "doc_links insert"); sqlite3_finalize(ins); (void)exec_sql(s, "ROLLBACK;"); return CBM_STORE_ERR; } sqlite3_reset(ins); } sqlite3_finalize(ins); return exec_sql(s, "COMMIT;"); } #ifdef CBM_ENABLE_TEST_SEAMS static atomic_uint_fast64_t doc_links_sample_field_copies = ATOMIC_VAR_INIT(0); static atomic_uint_fast64_t doc_links_sample_copied_bytes = ATOMIC_VAR_INIT(0); static atomic_uint_fast64_t doc_links_sample_requested_bytes = ATOMIC_VAR_INIT(0); static atomic_uint_fast64_t doc_links_sample_max_request_bytes = ATOMIC_VAR_INIT(0); static atomic_int doc_links_sample_alloc_countdown = ATOMIC_VAR_INIT(CBM_NOT_FOUND); static atomic_bool doc_links_sample_alloc_failed = ATOMIC_VAR_INIT(false); void cbm_store_doc_links_test_sample_stats_reset(void) { atomic_store_explicit(&doc_links_sample_field_copies, 0, memory_order_relaxed); atomic_store_explicit(&doc_links_sample_copied_bytes, 0, memory_order_relaxed); atomic_store_explicit(&doc_links_sample_requested_bytes, 0, memory_order_relaxed); atomic_store_explicit(&doc_links_sample_max_request_bytes, 0, memory_order_relaxed); } void cbm_store_doc_links_test_sample_stats(cbm_doc_links_sample_test_stats_t *out) { if (!out) { return; } out->field_copies = atomic_load_explicit(&doc_links_sample_field_copies, memory_order_relaxed); out->copied_bytes = atomic_load_explicit(&doc_links_sample_copied_bytes, memory_order_relaxed); out->requested_bytes = atomic_load_explicit(&doc_links_sample_requested_bytes, memory_order_relaxed); out->max_request_bytes = atomic_load_explicit(&doc_links_sample_max_request_bytes, memory_order_relaxed); } void cbm_store_doc_links_test_fail_sample_alloc_after(int successful_copies) { atomic_store_explicit(&doc_links_sample_alloc_failed, false, memory_order_relaxed); atomic_store_explicit(&doc_links_sample_alloc_countdown, successful_copies < 0 ? CBM_NOT_FOUND : successful_copies, memory_order_relaxed); } bool cbm_store_doc_links_test_sample_alloc_failed(void) { return atomic_load_explicit(&doc_links_sample_alloc_failed, memory_order_relaxed); } #endif /* Copy the exact bytes requested by the sample query, including embedded NUL. * Counters observe this allocation and copy, never the original column length. */ static char *doc_links_sample_copy(const void *source, size_t length) { if (length == SIZE_MAX || (length && !source)) { return NULL; } size_t bytes = length + SKIP_ONE; #ifdef CBM_ENABLE_TEST_SEAMS atomic_fetch_add_explicit(&doc_links_sample_requested_bytes, bytes, memory_order_relaxed); uint_fast64_t maximum = atomic_load_explicit(&doc_links_sample_max_request_bytes, memory_order_relaxed); while (maximum < bytes && !atomic_compare_exchange_weak_explicit( &doc_links_sample_max_request_bytes, &maximum, bytes, memory_order_relaxed, memory_order_relaxed)) {} if (graph_compare_test_countdown_fires(&doc_links_sample_alloc_countdown)) { atomic_store_explicit(&doc_links_sample_alloc_failed, true, memory_order_relaxed); return NULL; } #endif char *copy = cbm_alloc(CBM_MEM_CLASS_STORE, bytes); if (copy) { if (length) { memcpy(copy, source, length); } copy[length] = '\0'; #ifdef CBM_ENABLE_TEST_SEAMS atomic_fetch_add_explicit(&doc_links_sample_field_copies, 1, memory_order_relaxed); atomic_fetch_add_explicit(&doc_links_sample_copied_bytes, bytes, memory_order_relaxed); #endif } return copy; } /* Full getters and the original summary API retain their C-string behavior. */ static char *doc_links_field_copy(const char *source, bool sample) { if (!sample || !source) { return cbm_mem_strdup(CBM_MEM_CLASS_STORE, source); } return doc_links_sample_copy(source, strlen(source)); } /* Run a row query (columns rel_path, line, syntax, raw, reason) with the * project bound to ?1 and an optional integer limit bound to ?2. */ static int doc_links_query(cbm_store_t *s, const char *sql, const char *project, int limit, cbm_doc_link_row_t **out, int *count) { sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "doc_links get prepare"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); if (limit >= 0) { sqlite3_bind_int(stmt, ST_COL_2, limit); } int cap = ST_INIT_CAP_16; int n = 0; cbm_doc_link_row_t *arr = cbm_alloc(CBM_MEM_CLASS_STORE, (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) { cbm_doc_link_row_t *grown = cbm_realloc(CBM_MEM_CLASS_STORE, arr, (size_t)cap * ST_GROWTH * sizeof(*arr)); if (!grown) { sqlite3_finalize(stmt); cbm_store_free_doc_links(arr, n); return CBM_STORE_ERR; } arr = grown; cap *= ST_GROWTH; } cbm_doc_link_row_t *r = &arr[n]; r->rel_path = doc_links_field_copy((const char *)sqlite3_column_text(stmt, 0), limit >= 0); r->line = sqlite3_column_int(stmt, SKIP_ONE); r->syntax = doc_links_field_copy((const char *)sqlite3_column_text(stmt, ST_COL_2), limit >= 0); r->raw = doc_links_field_copy((const char *)sqlite3_column_text(stmt, ST_COL_3), limit >= 0); r->reason = doc_links_field_copy((const char *)sqlite3_column_text(stmt, CBM_SZ_4), limit >= 0); n++; if (!r->rel_path || !r->syntax || !r->raw || !r->reason) { sqlite3_finalize(stmt); cbm_store_free_doc_links(arr, n); return CBM_STORE_ERR; } } sqlite3_finalize(stmt); if (scan_rc != SQLITE_DONE) { store_set_error_sqlite(s, "doc_links scan"); cbm_store_free_doc_links(arr, n); return CBM_STORE_ERR; } *out = arr; *count = n; return CBM_STORE_OK; } int cbm_store_doc_links_get(cbm_store_t *s, const char *project, cbm_doc_link_row_t **out, int *count, bool *table_present) { if (!out || !count) { return CBM_STORE_ERR; } *out = NULL; *count = 0; if (table_present) { *table_present = false; } if (!s || !s->db || !project) { return CBM_STORE_ERR; } int state = doc_links_table_state(s); if (state < 0) { return CBM_STORE_ERR; } if (state == 0) { return CBM_STORE_OK; } if (table_present) { *table_present = true; } return doc_links_query(s, "SELECT rel_path, line, syntax, raw, reason FROM doc_link_unresolved " "WHERE project = ?1 ORDER BY rel_path, line, raw, syntax, reason;", project, CBM_NOT_FOUND, out, count); } int cbm_store_doc_links_summary(cbm_store_t *s, const char *project, cbm_doc_link_reason_count_t **reasons, int *reason_count, cbm_doc_link_row_t **samples, int *sample_count, int sample_limit, bool *table_present) { if (!reasons || !reason_count || !samples || !sample_count) { return CBM_STORE_ERR; } *reasons = NULL; *reason_count = 0; *samples = NULL; *sample_count = 0; if (table_present) { *table_present = false; } if (!s || !s->db || !project) { return CBM_STORE_ERR; } int state = doc_links_table_state(s); if (state < 0) { return CBM_STORE_ERR; } if (state == 0) { return CBM_STORE_OK; } if (table_present) { *table_present = true; } sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, "SELECT reason, COUNT(*) FROM doc_link_unresolved WHERE project = ?1 " "GROUP BY reason ORDER BY reason;", CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "doc_links summary prepare"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); int cap = ST_INIT_CAP_8; int n = 0; cbm_doc_link_reason_count_t *arr = cbm_alloc(CBM_MEM_CLASS_STORE, (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) { cbm_doc_link_reason_count_t *grown = cbm_realloc(CBM_MEM_CLASS_STORE, arr, (size_t)cap * ST_GROWTH * sizeof(*arr)); if (!grown) { sqlite3_finalize(stmt); cbm_store_free_doc_link_reasons(arr, n); return CBM_STORE_ERR; } arr = grown; cap *= ST_GROWTH; } arr[n].reason = cbm_mem_strdup(CBM_MEM_CLASS_STORE, (const char *)sqlite3_column_text(stmt, 0)); arr[n].count = sqlite3_column_int(stmt, SKIP_ONE); n++; if (!arr[n - SKIP_ONE].reason) { sqlite3_finalize(stmt); cbm_store_free_doc_link_reasons(arr, n); return CBM_STORE_ERR; } } sqlite3_finalize(stmt); if (scan_rc != SQLITE_DONE) { store_set_error_sqlite(s, "doc_links summary scan"); cbm_store_free_doc_link_reasons(arr, n); return CBM_STORE_ERR; } if (sample_limit > 0 && doc_links_query(s, "SELECT rel_path, line, syntax, raw, reason FROM doc_link_unresolved " "WHERE project = ?1 ORDER BY reason, rel_path, line, raw LIMIT ?2;", project, sample_limit, samples, sample_count) != CBM_STORE_OK) { cbm_store_free_doc_link_reasons(arr, n); return CBM_STORE_ERR; } *reasons = arr; *reason_count = n; return CBM_STORE_OK; } /* Both substr and length operate on bytes; ORDER BY retains the original * columns, so projection cannot change which fifty source rows are sampled. */ static bool doc_links_preview_field(sqlite3_stmt *stmt, int column, size_t cap, cbm_doc_link_preview_text_t *out) { /* SQLite's BLOB substr returns SQL NULL for a zero-byte BLOB. Its * separately computed integer length distinguishes that from a NULL * source or a failed nonempty projection. */ if (sqlite3_column_type(stmt, column + SKIP_ONE) != SQLITE_INTEGER) { return false; } sqlite3_int64 original = sqlite3_column_int64(stmt, column + SKIP_ONE); int projection_type = sqlite3_column_type(stmt, column); if (original < 0 || (projection_type != SQLITE_BLOB && (projection_type != SQLITE_NULL || original != 0))) { return false; } const void *source = sqlite3_column_blob(stmt, column); int bytes = sqlite3_column_bytes(stmt, column); if (bytes < 0 || (size_t)bytes > cap || (bytes && !source)) { return false; } uint64_t expected = (uint64_t)original < cap ? (uint64_t)original : cap; if ((uint64_t)bytes != expected) { return false; } out->text = doc_links_sample_copy(source, (size_t)bytes); if (!out->text) { return false; } out->length = (size_t)bytes; out->original_bytes = (uint64_t)original; return true; } int cbm_store_doc_links_preview(cbm_store_t *s, const char *project, cbm_doc_link_preview_row_t **out, int *count, bool *table_present) { if (!out || !count) { return CBM_STORE_ERR; } *out = NULL; *count = 0; if (table_present) { *table_present = false; } if (!s || !s->db || !project) { return CBM_STORE_ERR; } int state = doc_links_table_state(s); if (state < 0) { return CBM_STORE_ERR; } if (!state) { return CBM_STORE_OK; } if (table_present) { *table_present = true; } sqlite3_stmt *stmt = NULL; static const char sql[] = "SELECT substr(CAST(d.rel_path AS BLOB),1,?2),length(CAST(d.rel_path AS BLOB))," "d.line,substr(CAST(d.syntax AS BLOB),1,?3),length(CAST(d.syntax AS BLOB))," "substr(CAST(d.raw AS BLOB),1,?2),length(CAST(d.raw AS BLOB))," "substr(CAST(d.reason AS BLOB),1,?3),length(CAST(d.reason AS BLOB)) " "FROM doc_link_unresolved AS d WHERE d.project=?1 " "ORDER BY d.reason,d.rel_path,d.line,d.raw LIMIT ?4;"; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "doc_links preview prepare"); return CBM_STORE_ERR; } const int long_cap = CBM_DOC_LINK_PREVIEW_LONG_BYTES + CBM_DOC_LINK_PREVIEW_LOOKAHEAD; const int short_cap = CBM_DOC_LINK_PREVIEW_SHORT_BYTES + CBM_DOC_LINK_PREVIEW_LOOKAHEAD; if (bind_text(stmt, SKIP_ONE, project) != SQLITE_OK || sqlite3_bind_int(stmt, ST_COL_2, long_cap) != SQLITE_OK || sqlite3_bind_int(stmt, ST_COL_3, short_cap) != SQLITE_OK || sqlite3_bind_int(stmt, ST_COL_4, CBM_DOC_LINK_PREVIEW_ROWS) != SQLITE_OK) { store_set_error_sqlite(s, "doc_links preview bind"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } cbm_doc_link_preview_row_t *rows = cbm_calloc(CBM_MEM_CLASS_STORE, CBM_DOC_LINK_PREVIEW_ROWS * sizeof(*rows)); if (!rows) { sqlite3_finalize(stmt); return CBM_STORE_ERR; } int n = 0; int rc; while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) { if (n == CBM_DOC_LINK_PREVIEW_ROWS) { store_set_error(s, "doc_links preview row limit"); sqlite3_finalize(stmt); cbm_store_free_doc_link_previews(rows, n); return CBM_STORE_ERR; } cbm_doc_link_preview_row_t *row = &rows[n++]; row->line = sqlite3_column_int(stmt, ST_COL_2); if (!doc_links_preview_field(stmt, 0, (size_t)long_cap, &row->rel_path) || !doc_links_preview_field(stmt, ST_COL_3, (size_t)short_cap, &row->syntax) || !doc_links_preview_field(stmt, ST_COL_5, (size_t)long_cap, &row->raw) || !doc_links_preview_field(stmt, ST_COL_7, (size_t)short_cap, &row->reason)) { store_set_error(s, "doc_links preview copy failed"); sqlite3_finalize(stmt); cbm_store_free_doc_link_previews(rows, n); return CBM_STORE_ERR; } } sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { store_set_error_sqlite(s, "doc_links preview scan"); cbm_store_free_doc_link_previews(rows, n); return CBM_STORE_ERR; } *out = rows; *count = n; return CBM_STORE_OK; } void cbm_store_free_doc_link_previews(cbm_doc_link_preview_row_t *rows, int count) { if (!rows) { return; } for (int i = 0; i < count; i++) { cbm_free(CBM_MEM_CLASS_STORE, (char *)rows[i].rel_path.text); cbm_free(CBM_MEM_CLASS_STORE, (char *)rows[i].syntax.text); cbm_free(CBM_MEM_CLASS_STORE, (char *)rows[i].raw.text); cbm_free(CBM_MEM_CLASS_STORE, (char *)rows[i].reason.text); } cbm_free(CBM_MEM_CLASS_STORE, rows); } void cbm_store_free_doc_links(cbm_doc_link_row_t *rows, int count) { if (!rows) { return; } for (int i = 0; i < count; i++) { cbm_free(CBM_MEM_CLASS_STORE, (char *)rows[i].rel_path); cbm_free(CBM_MEM_CLASS_STORE, (char *)rows[i].syntax); cbm_free(CBM_MEM_CLASS_STORE, (char *)rows[i].raw); cbm_free(CBM_MEM_CLASS_STORE, (char *)rows[i].reason); } cbm_free(CBM_MEM_CLASS_STORE, rows); } void cbm_store_free_doc_link_reasons(cbm_doc_link_reason_count_t *reasons, int count) { if (!reasons) { return; } for (int i = 0; i < count; i++) { cbm_free(CBM_MEM_CLASS_STORE, (char *)reasons[i].reason); } cbm_free(CBM_MEM_CLASS_STORE, reasons); } /* ── 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 ───────────────────────────────────────── */ /* Row filter on the raw qualified_name + name columns (NULL keeps every row). */ typedef bool (*store_qn_keep_fn)(const char *qn, const char *name, const void *arg); /* Step `stmt` (columns as scan_node reads them) into a node array, keeping the * rows `keep` accepts. Finalizes `stmt`. */ static int store_collect_nodes(cbm_store_t *s, sqlite3_stmt *stmt, store_qn_keep_fn keep, const void *keep_arg, cbm_node_t **out, int *count) { 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 (keep && !keep((const char *)sqlite3_column_text(stmt, CBM_SZ_4), (const char *)sqlite3_column_text(stmt, CBM_SZ_3), keep_arg)) { continue; } 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; } /* "%." + text + tail with LIKE's wildcards ('%', '_') and the escape * character escaped, for `LIKE ? ESCAPE '\'`. Heap-owned. The caller's text * is matched literally at any length: a fixed 512-byte buffer used to * truncate a long suffix into a pattern that matched nothing, and an * unescaped '_' in `my_func` also matched `myXfunc`. */ static char *store_like_dot_suffix(const char *text, const char *tail) { size_t n = strlen(text); size_t tail_len = strlen(tail); char *pat = cbm_alloc(CBM_MEM_CLASS_STORE, (2 * n) + tail_len + ST_COL_3); /* "%." + NUL */ if (!pat) { return NULL; } size_t k = 0; pat[k++] = '%'; pat[k++] = '.'; for (size_t i = 0; i < n; i++) { if (text[i] == '%' || text[i] == '_' || text[i] == '\\') { pat[k++] = '\\'; } pat[k++] = text[i]; } memcpy(pat + k, tail, tail_len + SKIP_ONE); return pat; } int cbm_store_find_variant_spans_by_file(cbm_store_t *s, const char *project, const char *file_path, cbm_node_t **out, int *count) { *out = NULL; *count = 0; if (!s || !s->db || !project || !file_path) { return CBM_STORE_ERR; } /* The file's own nodes with variants (idx_nodes_file), and the nodes of * other files it writes a variant of (its File/Module DEFINES edges); * then each one's variant spans that lie in this file. */ const char *sql = "SELECT n.id, n.project, n.label, n.name, n.qualified_name, n.file_path," " CAST(json_extract(v.value, '$.start_line') AS INTEGER)," " CAST(json_extract(v.value, '$.end_line') AS INTEGER), n.properties" " FROM nodes n, json_each(n.properties, '$.variants') v" " WHERE n.id IN (" " SELECT id FROM nodes WHERE project = ?1 AND file_path = ?2" " AND json_array_length(properties, '$.variants') > 1" " UNION SELECT tgt.id FROM nodes src" " CROSS JOIN edges e ON e.source_id = src.id" " CROSS JOIN nodes tgt ON tgt.id = e.target_id" " WHERE src.project = ?1 AND src.file_path = ?2 AND src.label IN ('File','Module')" " AND e.type = 'DEFINES' AND tgt.file_path <> ?2" " AND json_array_length(tgt.properties, '$.variants') > 1)" " AND json_extract(v.value, '$.file_path') = ?2" " ORDER BY n.id, 7, 8"; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "variant spans prepare"); return CBM_STORE_ERR; } bind_text(stmt, SKIP_ONE, project); bind_text(stmt, ST_COL_2, file_path); return store_collect_nodes(s, stmt, NULL, NULL, out, count); } 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 || !suffix) { return CBM_STORE_ERR; } /* Match QNs ending with ".suffix" or exactly equal to suffix */ char *like_pattern = store_like_dot_suffix(suffix, ""); if (!like_pattern) { return CBM_STORE_ERR; } 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 ESCAPE '\\' 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 ESCAPE '\\' 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) { cbm_free(CBM_MEM_CLASS_STORE, like_pattern); 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); } rc = store_collect_nodes(s, stmt, NULL, NULL, out, count); cbm_free(CBM_MEM_CLASS_STORE, like_pattern); return rc; } /* ── FindNodesByQNBase ─────────────────────────────────────────── */ typedef struct { const char *base; size_t len; bool suffix; } store_qn_base_arg_t; /* ASCII case-insensitive equality: SQLite's LIKE folds exactly ASCII. */ static bool store_ascii_ieq(const char *a, const char *b, size_t n) { for (size_t i = 0; i < n; i++) { unsigned char x = (unsigned char)a[i]; unsigned char y = (unsigned char)b[i]; if (x >= 'A' && x <= 'Z') { x = (unsigned char)(x - 'A' + 'a'); } if (y >= 'A' && y <= 'Z') { y = (unsigned char)(y - 'A' + 'a'); } if (x != y) { return false; } } return true; } /* Keep a row whose QN carries a callable identity suffix (#2061) over `base`: * its base QN equals `base` (exact) or ends with "." + `base` (suffix mode, * ASCII case-insensitive like the LIKE suffix tier). An unsuffixed QN never * qualifies, so this tier finds nothing until a language mints suffixes. */ static bool store_qn_base_keep(const char *qn, const char *name, const void *argp) { const store_qn_base_arg_t *arg = argp; if (!qn || !name) { return false; } size_t base_len = cbm_qn_callable_base_len_named(qn, name); if (qn[base_len] == '\0') { return false; } if (!arg->suffix) { return base_len == arg->len && memcmp(qn, arg->base, arg->len) == 0; } return base_len > arg->len && qn[base_len - arg->len - SKIP_ONE] == '.' && store_ascii_ieq(qn + base_len - arg->len, arg->base, arg->len); } /* Exact mode reads the qualified_name index range [base "(", base "="): a * suffix starts with '(' (0x28) or '<' (0x3C). Suffix mode needs the leading * wildcard, like the suffix tier it follows. Both bounds are heap-owned. */ static bool store_qn_base_bounds(const char *base, bool suffix_match, char **lo, char **hi) { if (suffix_match) { *lo = store_like_dot_suffix(base, "(%"); *hi = store_like_dot_suffix(base, "<%"); } else { size_t n = strlen(base); *lo = cbm_alloc(CBM_MEM_CLASS_STORE, n + ST_COL_2); *hi = cbm_alloc(CBM_MEM_CLASS_STORE, n + ST_COL_2); if (*lo && *hi) { memcpy(*lo, base, n); memcpy(*hi, base, n); (*lo)[n] = '('; (*hi)[n] = '='; (*lo)[n + SKIP_ONE] = '\0'; (*hi)[n + SKIP_ONE] = '\0'; } } if (*lo && *hi) { return true; } cbm_free(CBM_MEM_CLASS_STORE, *lo); cbm_free(CBM_MEM_CLASS_STORE, *hi); return false; } int cbm_store_find_nodes_by_qn_base(cbm_store_t *s, const char *project, const char *base, bool suffix_match, cbm_node_t **out, int *count) { *out = NULL; *count = 0; if (!s || !s->db || !base || !base[0]) { return CBM_STORE_ERR; } static const char *const sql[2][2] = { /* [suffix_match][has project] */ {"SELECT id, project, label, name, qualified_name, file_path, start_line, end_line, " "properties FROM nodes WHERE qualified_name >= ?1 AND qualified_name < ?2", "SELECT id, project, label, name, qualified_name, file_path, start_line, end_line, " "properties FROM nodes WHERE project = ?1 AND qualified_name >= ?2 AND " "qualified_name < ?3"}, {"SELECT id, project, label, name, qualified_name, file_path, start_line, end_line, " "properties FROM nodes WHERE (qualified_name LIKE ?1 ESCAPE '\\' OR " "qualified_name LIKE ?2 ESCAPE '\\')", "SELECT id, project, label, name, qualified_name, file_path, start_line, end_line, " "properties FROM nodes WHERE project = ?1 AND (qualified_name LIKE ?2 ESCAPE '\\' OR " "qualified_name LIKE ?3 ESCAPE '\\')"}, }; char *lo = NULL; char *hi = NULL; if (!store_qn_base_bounds(base, suffix_match, &lo, &hi)) { return CBM_STORE_ERR; } sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql[suffix_match ? 1 : 0][project ? 1 : 0], CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { cbm_free(CBM_MEM_CLASS_STORE, lo); cbm_free(CBM_MEM_CLASS_STORE, hi); store_set_error_sqlite(s, "qn_base prepare"); return CBM_STORE_ERR; } int col = SKIP_ONE; if (project) { bind_text(stmt, col++, project); } bind_text(stmt, col++, lo); bind_text(stmt, col, hi); store_qn_base_arg_t arg = {base, strlen(base), suffix_match}; int rc = store_collect_nodes(s, stmt, store_qn_base_keep, &arg, out, count); cbm_free(CBM_MEM_CLASS_STORE, lo); cbm_free(CBM_MEM_CLASS_STORE, hi); return rc; } /* ── 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; } /* A node path can name a graph-only identity such as a Folder, * , or another synthetic source. Published indexes have * one canonical File node per scannable path, so prefer that exact set. * Legacy/manual stores without File nodes retain their previous node-path * fallback (minus Folder directories). Missing canonical paths stay in * the list so search_code still fails closed on a stale snapshot. */ const char *sql = "SELECT DISTINCT file_path FROM nodes " "WHERE project = ?1 AND label != 'Folder' " "AND file_path IS NOT NULL AND file_path != '' " "AND (label = 'File' OR NOT EXISTS (" "SELECT 1 FROM nodes WHERE project = ?1 AND label = 'File')) " "ORDER BY 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; } /* ── Index format version (PRAGMA user_version) ─────────────────── */ int cbm_store_get_format_version(cbm_store_t *s, int *out) { if (!s || !s->db || !out) { return CBM_STORE_ERR; } *out = 0; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, "PRAGMA user_version;", CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { return CBM_STORE_ERR; } if (sqlite3_step(stmt) == SQLITE_ROW) { *out = sqlite3_column_int(stmt, 0); } sqlite3_finalize(stmt); return CBM_STORE_OK; } /* PRAGMA values cannot be bound; format the integer constant in. Writes, so * only call on a read-write connection (never from configure_pragmas). */ int cbm_store_set_format_version(cbm_store_t *s, int version) { if (!s || !s->db) { return CBM_STORE_ERR; } char sql[ST_BUF_64]; snprintf(sql, sizeof(sql), "PRAGMA user_version = %d;", version); return exec_sql(s, sql); } /* ── 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 a file-path query over a list of files. Binding an IN list * needs one placeholder per element, so the SQL is assembled per chunk from * `parts`, with the placeholder list ?2..?n+1 between consecutive parts (a * query may name the list more than once); ?1 is the project. The chunk cap * keeps it bounded. Dedup across chunks happens in the caller's hash set. */ enum { DEPFILE_CHUNK = 200 }; static int file_query_chunk(cbm_store_t *s, const char *const *parts, int nparts, const char *what, 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_8K]; sql[0] = '\0'; if (nparts < 1) { return CBM_STORE_ERR; } int pos = 0; for (int part = 0; part < nparts; part++) { if (part > 0) { 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; } } } pos += snprintf(sql + pos, sizeof(sql) - (size_t)pos, "%s", parts[part]); 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, what); 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; } /* Run `parts` over every chunk of target_files; the targets themselves are * excluded from the result. */ static int file_query(cbm_store_t *s, const char *const *parts, int nparts, const char *what, 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". */ 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 = file_query_chunk(s, parts, nparts, what, 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; } 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) { static const char *const parts[] = { /* 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 (", /* 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. */ ") 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')"}; return file_query(s, parts, 2, "dependent_files prepare", project, target_files, target_count, out, out_count); } int cbm_store_get_variant_partner_files(cbm_store_t *s, const char *project, const char *const *files, int count, char ***out, int *out_count) { static const char *const parts[] = { /* A file that writes a variant of a node whose file_path is * another file. */ "SELECT tgt.file_path FROM nodes src" " CROSS JOIN edges e ON e.source_id = src.id" " CROSS JOIN nodes tgt ON e.target_id = tgt.id" " WHERE src.project = ?1 AND src.label IN ('File','Module') AND e.type = 'DEFINES'" " AND tgt.file_path <> src.file_path AND tgt.file_path <> ''" " AND json_array_length(tgt.properties, '$.variants') > 1" " AND src.file_path IN (", /* The other files that write a variant of a node in the file. */ ") UNION SELECT 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 e.type = 'DEFINES' AND src.label IN ('File','Module')" " AND src.file_path <> tgt.file_path AND src.file_path <> ''" " AND json_array_length(tgt.properties, '$.variants') > 1" " AND tgt.file_path IN (", ")"}; return file_query(s, parts, 3, "variant_partner_files prepare", project, files, count, out, out_count); } 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; } /* ── In-memory adjacency ─────────────────────────────────────────── */ /* The edge types search_graph's in_deg/out_deg count (cbm_store_search). */ static const char *const st_degree_types[] = {"CALLS", "USAGE", "CALL_REFERENCE", "INHERITS", "IMPLEMENTS"}; /* cbm_adj_degree(id, inbound): the in-memory twin of search's two * correlated COUNT(*) subqueries over st_degree_types. */ static void sqlite_graph_mem_degree(sqlite3_context *ctx, int argc, sqlite3_value **argv) { (void)argc; const cbm_store_t *s = sqlite3_user_data(ctx); sqlite3_result_int(ctx, cbm_graph_mem_degree(s->graph_mem, sqlite3_value_int64(argv[0]), sqlite3_value_int(argv[SKIP_ONE]) != 0, s->graph_mem_degree_mask)); } static uint32_t st_be32(const unsigned char *p) { return ((uint32_t)p[0] << ST_BE_SHIFT_24) | ((uint32_t)p[SKIP_ONE] << ST_BE_SHIFT_16) | ((uint32_t)p[ST_COL_2] << ST_BE_SHIFT_8) | (uint32_t)p[ST_COL_3]; } /* The committed state of a rollback-journal DB: its header's change counter * and page count, read through SQLite's own file handle while this * connection's read transaction holds the SHARED lock, so no commit can land * mid-read. Every rollback-mode commit bumps the counter (it is how SQLite * tells other connections their page cache is stale), and switching into or * out of WAL is itself such a commit. That makes the state exact where the * store generation is not: the cross-repo pass writes CROSS_* edges straight * into other projects' published DBs without stamping a generation. A * WAL-mode file is refused — a WAL commit need not touch the header — which * is the state of a published DB after any in-place writer switched it to * WAL; it keeps the SQL path until the next publish seals it again. */ static bool store_rollback_file_state(cbm_store_t *s, char *out, size_t out_sz) { sqlite3_file *file = NULL; unsigned char hdr[ST_DB_HEADER_LEN]; if (sqlite3_file_control(s->db, "main", SQLITE_FCNTL_FILE_POINTER, (void *)&file) != SQLITE_OK || !file || !file->pMethods || file->pMethods->xRead(file, hdr, (int)sizeof(hdr), 0) != SQLITE_OK) { return false; } if (hdr[ST_HDR_WRITE_VERSION] != ST_HDR_ROLLBACK || hdr[ST_HDR_READ_VERSION] != ST_HDR_ROLLBACK) { return false; } snprintf(out, out_sz, "c%u.p%u", st_be32(hdr + ST_HDR_CHANGE_COUNTER), st_be32(hdr + ST_HDR_PAGE_COUNT)); return true; } /* This store's adjacency layer, or NULL for the SQL path; tried once per * store. The layer's version is the store generation plus the file state, and * both — like a miss's edge scan — are read inside one read transaction, so a * layer holds exactly the edges of the version it is keyed to. */ static cbm_graph_mem_t *store_graph_mem(cbm_store_t *s) { if (s->graph_mem_tried) { return s->graph_mem; } s->graph_mem_tried = true; if (!s->query_store || !s->db || !s->db_path) { return NULL; } bool own_txn = sqlite3_get_autocommit(s->db) != 0; if (own_txn && sqlite3_exec(s->db, "BEGIN DEFERRED;", NULL, NULL, NULL) != SQLITE_OK) { return NULL; } char generation[CBM_SZ_128]; char state[CBM_SZ_64]; char version[CBM_SZ_256]; /* The generation read is the transaction's first read: the SHARED lock * the file-state read relies on is held from here on. */ if (cbm_store_generation(s, generation, sizeof(generation)) == CBM_STORE_OK && strcmp(generation, "legacy") != 0 && store_rollback_file_state(s, state, sizeof(state))) { snprintf(version, sizeof(version), "%s/%s", generation, state); s->graph_mem = cbm_graph_mem_acquire(s->db, s->db_path, version); } if (own_txn) { (void)sqlite3_exec(s->db, "COMMIT;", NULL, NULL, NULL); } if (s->graph_mem && sqlite3_create_function(s->db, "cbm_adj_degree", ST_COL_2, SQLITE_UTF8 | SQLITE_DETERMINISTIC | SQLITE_DIRECTONLY, s, sqlite_graph_mem_degree, NULL, NULL) != SQLITE_OK) { cbm_graph_mem_release(s->graph_mem); s->graph_mem = NULL; } if (s->graph_mem) { s->graph_mem_degree_mask = cbm_graph_mem_type_mask(s->graph_mem, st_degree_types, (int)(sizeof(st_degree_types) / sizeof(st_degree_types[0]))); } return s->graph_mem; } /* ── 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; } /* Patterns run as POSIX ERE without REG_NEWLINE, so '^' anchors at the start of * the name only, and a pattern without '|' is one concatenation: every match * begins with the literal run after the '^'. A following '*', '?', '{' may * repeat the run's last character zero times and '+' may be combined with them * (glibc reads "a+?" as "(a+)?"), so that character is dropped; so is the one * before a backslash, which no rule here needs to interpret. */ int cbm_regex_anchored_prefix(const char *pattern, char *out, int out_sz) { if (!pattern || !out || out_sz <= 1 || pattern[0] != '^' || strchr(pattern, '|')) { if (out && out_sz > 0) { out[0] = '\0'; } return 0; } int len = 0; const char *p = pattern + SKIP_ONE; while (*p && len < out_sz - SKIP_ONE) { unsigned char c = (unsigned char)*p; if (c < ' ' || c > '~' || strchr(".[]()*+?{}|^$\\", c)) { break; } out[len++] = (char)c; p++; } if (len > 0 && *p && strchr("*+?{\\", *p)) { len--; } out[len] = '\0'; return len; } /* ── 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); } } /* Infra filters: a Resource's kind property and the namespace of its * obj_key "group/Kind/ns/name" (no part holds a '/', so `%/%//%` pins * the third; the pattern is built in SQL from the bound name, LIKE * metacharacters escaped). Properties are guarded: json_extract raises on * malformed JSON. */ static void search_where_infra(const cbm_search_params_t *params, char *where, int where_sz, int *wlen, int *nparams, search_bind_t *binds, int *bind_idx) { char clause[CBM_SZ_256]; if (params->kind && params->kind[0]) { snprintf(clause, sizeof(clause), "(CASE WHEN json_valid(n.properties) THEN json_extract(n.properties,'$.kind') " "END) = ?%d", *bind_idx + SKIP_ONE); *wlen = where_append(where, where_sz, *wlen, nparams, clause); where_bind_text(binds, bind_idx, params->kind); } if (params->namespace_name && params->namespace_name[0]) { snprintf(clause, sizeof(clause), "(CASE WHEN json_valid(n.properties) THEN json_extract(n.properties,'$.obj_key') " "END) LIKE ('%%/%%/' || replace(replace(replace(?%d,'\\','\\\\'),'%%','\\%%')," "'_','\\_') || '/%%') ESCAPE '\\'", *bind_idx + SKIP_ONE); *wlen = where_append(where, where_sz, *wlen, nparams, clause); where_bind_text(binds, bind_idx, params->namespace_name); } } /* 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); } } search_where_infra(params, where, where_sz, wlen, nparams, binds, bind_idx); 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; } /* Anchored name patterns ("^schedule$", "^kmalloc"): every matching name begins * with the pattern's literal prefix (cbm_regex_anchored_prefix), so it lies in a * half-open idx_nodes_name range [prefix, prefix with its last byte + 1). Under * iregexp the prefix matches in any letter case: the process never calls * setlocale, so the regex runs in the C locale and folds ASCII letters only, and * each of the first ST_NAME_RANGE_LETTERS letters doubles the ranges. The ranges * are ADDED to the full WHERE (LIKE hints and regex stay), so the rows are those * of the plain query — which instead scans every name of the project. */ typedef struct { char lo[ST_NAME_RANGE_MAX][ST_NAME_RANGE_PREFIX]; char hi[ST_NAME_RANGE_MAX][ST_NAME_RANGE_PREFIX]; int count; } search_name_ranges_t; static bool name_range_letter(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z'); } static char name_range_case(char c, bool lower) { if (lower && c >= 'A' && c <= 'Z') { return (char)(c - 'A' + 'a'); } if (!lower && c >= 'a' && c <= 'z') { return (char)(c - 'a' + 'A'); } return c; } static int search_name_ranges(const cbm_search_params_t *p, search_name_ranges_t *r) { r->count = 0; /* Without the project the index cannot seek the name; degree filters wrap * the query in a subselect this rewrite leaves alone. */ if (!p->project || !p->name_pattern || p->min_degree >= 0 || p->max_degree >= 0) { return 0; } char prefix[ST_NAME_RANGE_PREFIX]; int len = cbm_regex_anchored_prefix(p->name_pattern, prefix, (int)sizeof(prefix)); if (len == 0) { return 0; } /* A pattern the regex function rejects fails the plain query once a row * reaches the regex; fewer rows reach it here, so it keeps the plain query. */ int flags = CBM_REG_EXTENDED | CBM_REG_NOSUB | (p->case_sensitive ? 0 : CBM_REG_ICASE); cbm_regex_t re; if (cbm_regcomp(&re, p->name_pattern, flags) != 0) { return 0; } cbm_regfree(&re); int letters = 0; if (!p->case_sensitive) { int k = 0; for (; k < len; k++) { if (name_range_letter(prefix[k])) { if (letters == ST_NAME_RANGE_LETTERS) { break; } letters++; } } len = k; } int variants = SKIP_ONE << letters; for (int v = 0; v < variants; v++) { int bit = letters - SKIP_ONE; for (int i = 0; i < len; i++) { char c = prefix[i]; if (!p->case_sensitive && name_range_letter(c)) { /* Variant 0 is all upper case: ranges in ascending byte order. */ c = name_range_case(c, ((v >> bit) & SKIP_ONE) != 0); bit--; } r->lo[v][i] = c; r->hi[v][i] = c; } r->lo[v][len] = '\0'; r->hi[v][len] = '\0'; r->hi[v][len - SKIP_ONE]++; /* printable ASCII: never wraps */ } r->count = variants; return variants; } /* Main and count SQL over the name ranges: one UNION ALL arm per range, each * the plain WHERE plus its range, so every arm seeks idx_nodes_name. The arms * are disjoint and each yields (name, id) order, so ORDER BY 4, 1 (n.name, * n.id) merges them without a sort and the per-row degree subqueries run only * for rows the page emits; the count sums the per-range counts. On false * (allocation, bind slots) nothing is bound and the plain query runs. */ /* Append to buf[0..cap) at *len; false (nothing written past cap) when it * does not fit. */ static bool search_sql_append(char *buf, size_t cap, size_t *len, const char *fmt, ...) { if (*len >= cap) { return false; } va_list args; va_start(args, fmt); int wrote = vsnprintf(buf + *len, cap - *len, fmt, args); va_end(args); if (wrote < 0 || (size_t)wrote >= cap - *len) { return false; } *len += (size_t)wrote; return true; } static bool search_build_ranged_sql(const char *select_cols, const char *where, const search_name_ranges_t *r, int limit, int offset, search_bind_t *binds, int *bind_idx, char **out_sql, char **out_count_sql) { if (*bind_idx + (r->count * ST_COL_2) > ST_SEARCH_MAX_BINDS) { return false; } size_t cap = (strlen(select_cols) + strlen(where) + ST_NAME_RANGE_ARM_SQL) * (size_t)r->count + ST_NAME_RANGE_ARM_SQL; char *sql = cbm_alloc(CBM_MEM_CLASS_STORE, cap); char *cnt = cbm_alloc(CBM_MEM_CLASS_STORE, cap); int first_bind = *bind_idx; size_t sl = 0; size_t cl = 0; bool ok = sql && cnt && search_sql_append(cnt, cap, &cl, "SELECT "); for (int i = 0; ok && i < r->count; i++) { int lo = *bind_idx + SKIP_ONE; where_bind_text(binds, bind_idx, r->lo[i]); where_bind_text(binds, bind_idx, r->hi[i]); ok = search_sql_append(sql, cap, &sl, "%s%s FROM nodes n WHERE (%s) AND n.name >= ?%d AND n.name < ?%d", i > 0 ? " UNION ALL " : "", select_cols, where, lo, lo + SKIP_ONE) && search_sql_append(cnt, cap, &cl, "%s(SELECT COUNT(*) FROM nodes n WHERE (%s) AND n.name >= ?%d AND " "n.name < ?%d)", i > 0 ? " + " : "", where, lo, lo + SKIP_ONE); } ok = ok && search_sql_append(sql, cap, &sl, " ORDER BY 4, 1 LIMIT %d OFFSET %d", limit, offset); if (!ok) { /* Nothing is bound: the caller runs the plain query. */ *bind_idx = first_bind; cbm_free(CBM_MEM_CLASS_STORE, sql); cbm_free(CBM_MEM_CLASS_STORE, cnt); return false; } *out_sql = sql; *out_count_sql = cnt; return true; } 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 "; /* A degree filter evaluates both counts for EVERY candidate row (787k * Functions on the kernel: two B-tree seeks each, 1.9 s per pass). The * adjacency layer answers them from RAM; the query is otherwise the same, * so filter, order, page and total are unchanged. */ bool has_degree_filter = (params->min_degree >= 0 || params->max_degree >= 0); if (has_degree_filter && store_graph_mem(s)) { 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, " "cbm_adj_degree(n.id, 1) AS in_deg, cbm_adj_degree(n.id, 0) 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 */ 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); /* Anchored name pattern: the same rows through idx_nodes_name seeks. */ search_name_ranges_t name_ranges; char *ranged_sql = NULL; char *ranged_count_sql = NULL; if (nparams > 0 && search_name_ranges(params, &name_ranges) > 0) { (void)search_build_ranged_sql(select_cols, where, &name_ranges, limit, offset, binds, &bind_idx, &ranged_sql, &ranged_count_sql); } const char *main_sql = ranged_sql ? ranged_sql : sql; const char *total_sql = ranged_count_sql ? ranged_count_sql : count_sql; /* Execute count query */ sqlite3_stmt *cnt_stmt = NULL; int rc = sqlite3_prepare_v2(s->db, total_sql, CBM_NOT_FOUND, &cnt_stmt, NULL); cbm_free(CBM_MEM_CLASS_STORE, ranged_count_sql); /* prepare_v2 copies the text */ 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, main_sql, CBM_NOT_FOUND, &main_stmt, NULL); cbm_free(CBM_MEM_CLASS_STORE, ranged_sql); 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, int max_edges, cbm_edge_info_t **out_edges, int *out_edge_count, bool *out_truncated) { *out_edges = NULL; *out_edge_count = 0; *out_truncated = false; if (max_edges == 0) { return CBM_STORE_OK; } /* 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) { store_set_error_sqlite(s, "bfs edge ids"); return CBM_STORE_ERR; } 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) { store_set_error_sqlite(s, "bfs edge ids prepare"); return CBM_STORE_ERR; } sqlite3_bind_int64(ins, SKIP_ONE, start_id); if (sqlite3_step(ins) != SQLITE_DONE) { store_set_error_sqlite(s, "bfs edge root id"); sqlite3_finalize(ins); return CBM_STORE_ERR; } for (int i = 0; i < visited_count; i++) { sqlite3_reset(ins); sqlite3_bind_int64(ins, SKIP_ONE, visited[i].node.id); if (sqlite3_step(ins) != SQLITE_DONE) { store_set_error_sqlite(s, "bfs edge visited id"); sqlite3_finalize(ins); return CBM_STORE_ERR; } } sqlite3_finalize(ins); char limit_clause[ST_BUF_64] = ""; if (max_edges > 0) { snprintf(limit_clause, sizeof(limit_clause), " LIMIT %d", max_edges < INT_MAX ? max_edges + SKIP_ONE : max_edges); } 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) " "ORDER BY e.source_id, e.target_id, e.type%s", types_clause, limit_clause); sqlite3_stmt *estmt = NULL; int rc = sqlite3_prepare_v2(s->db, edge_sql, CBM_NOT_FOUND, &estmt, NULL); if (rc != SQLITE_OK) { store_set_error_sqlite(s, "bfs edges prepare"); return CBM_STORE_ERR; } 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); if (max_edges > 0 && en > max_edges) { en = max_edges; safe_str_free(&edges[en].from_name); safe_str_free(&edges[en].to_name); safe_str_free(&edges[en].type); safe_str_free(&edges[en].properties_json); *out_truncated = true; } *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; } } } static int bfs_cte_row_limit(int max_results) { int result_budget = max_results > 0 ? max_results : ST_INIT_CAP_16; long row_budget = (long)result_budget * ST_BFS_CTE_ROW_MULTIPLIER + SKIP_ONE; if (row_budget > ST_BFS_MAX_CTE_ROWS) { return ST_BFS_MAX_CTE_ROWS; } if (row_budget < ST_INIT_CAP_16) { return ST_INIT_CAP_16; } return (int)row_budget; } static int bfs_cte_row_limit_for_depth(int max_results, int max_depth) { int base = bfs_cte_row_limit(max_results); /* Reserve rows across the requested depth range. A result-sized cap can * be exhausted by a wide first hop before any deeper match is generated; * scaling by depth keeps the bounded traversal useful for *N..N queries * while the hard ceiling still protects the SQLite worker. */ long depth = max_depth > 0 ? max_depth : ST_INIT_CAP_16; long scaled = (long)base * depth; if (scaled > ST_BFS_MAX_CTE_ROWS) { return ST_BFS_MAX_CTE_ROWS; } return (int)scaled; } /* One store_bfs walk, shared by its SQL and in-memory paths. */ typedef struct { int64_t start_id; bool inbound; const char *types_clause; const char **edge_types; int edge_type_count; int max_depth; int max_results; int probe_limit; /* max_results + 1: saturation stays observable */ bool trail; } bfs_query_t; typedef struct { cbm_node_hop_t *visited; int count; int cap; } bfs_rows_t; static void bfs_rows_init(bfs_rows_t *rows) { rows->cap = ST_INIT_CAP_16; rows->count = 0; rows->visited = malloc(rows->cap * sizeof(cbm_node_hop_t)); } static cbm_node_hop_t *bfs_rows_next(bfs_rows_t *rows) { if (rows->count >= rows->cap) { rows->cap *= ST_GROWTH; rows->visited = safe_realloc(rows->visited, rows->cap * sizeof(cbm_node_hop_t)); } return &rows->visited[rows->count++]; } /* The recursive CTE: trail (relationship-unique, for Cypher) or shortest-path. */ static int bfs_visit_sql(cbm_store_t *s, const bfs_query_t *q, bfs_rows_t *rows, int *cte_rows, int *cte_row_limit) { char sql[CBM_SZ_4K]; const char *join_cond = q->inbound ? "e.target_id = bfs.node_id" : "e.source_id = bfs.node_id"; const char *next_id = q->inbound ? "e.source_id" : "e.target_id"; if (q->trail) { *cte_row_limit = bfs_cte_row_limit_for_depth(q->max_results, q->max_depth); snprintf(sql, sizeof(sql), "WITH RECURSIVE bfs(node_id, hop, edge_path) AS (" " SELECT %lld, 0, ''" " UNION" " SELECT %s, bfs.hop + 1," " CASE WHEN bfs.edge_path = '' THEN CAST(e.id AS TEXT)" " ELSE bfs.edge_path || ',' || e.id END" " FROM bfs" " JOIN edges e ON %s" " WHERE e.type IN (%s) AND bfs.hop < %d" " AND instr(',' || bfs.edge_path || ',', ',' || e.id || ',') = 0" /* SQLite's recursive queue is breadth-first by default. A * bounded trail budget must reach the requested depth before * a wide shallow layer consumes every row, so prioritize the * recursive hop (depth-first) while retaining deterministic * ordering in the outer result query. */ " ORDER BY 2 DESC, 1 ASC, 3 ASC" " LIMIT %d" ")" "SELECT DISTINCT n.id, n.project, n.label, n.name, n.qualified_name, " "n.file_path, n.start_line, n.end_line, n.properties, bfs.hop, " "(SELECT count(*) FROM bfs) " "FROM bfs JOIN nodes n ON n.id = bfs.node_id " "WHERE bfs.hop > 0 ORDER BY bfs.hop, n.id LIMIT %d;", (long long)q->start_id, next_id, join_cond, q->types_clause, q->max_depth, *cte_row_limit + SKIP_ONE, q->probe_limit); } else { snprintf(sql, sizeof(sql), /* SHORTEST-PATH semantics: the UNION dedupes (node, hop) pairs. * MIN(hop) GROUP BY node returns each node once at its minimal * distance, while relationship-trail state remains scoped to * Cypher variable-length expansion. */ "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 " "GROUP BY n.id " "ORDER BY hop, n.id " "LIMIT %d;", (long long)q->start_id, next_id, join_cond, q->types_clause, q->max_depth, q->probe_limit); } sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "bfs prepare"); return CBM_STORE_ERR; } /* Bind edge type parameters */ if (q->edge_type_count > 0) { for (int i = 0; i < q->edge_type_count; i++) { bind_text(stmt, i + SKIP_ONE, q->edge_types[i]); } } else { bind_text(stmt, SKIP_ONE, "CALLS"); } bfs_rows_init(rows); int scan_rc15; while ((scan_rc15 = sqlite3_step(stmt)) == SQLITE_ROW) { cbm_node_hop_t *row = bfs_rows_next(rows); scan_node(stmt, &row->node); row->hop = sqlite3_column_int(stmt, ST_COL_9); if (q->trail) { *cte_rows = sqlite3_column_int(stmt, ST_COL_10); } } if (scan_rc15 != SQLITE_DONE) { /* SCANCHK:15:stmt */ store_set_error_sqlite(s, "row scan aborted"); sqlite3_finalize(stmt); return CBM_STORE_ERR; } sqlite3_finalize(stmt); return CBM_STORE_OK; } /* The shortest-path walk from the adjacency layer: the same rows as the CTE's * JOIN nodes + ORDER BY hop, n.id + LIMIT, because cbm_graph_mem_bfs yields * every reached id at its minimal hop in (hop, id) order and the node rows are * read here in that order. An id with no node row is skipped, as the JOIN * skips it. */ static int bfs_visit_mem(cbm_store_t *s, const cbm_graph_mem_t *gm, const bfs_query_t *q, bfs_rows_t *rows) { static const char *const calls_only[] = {"CALLS"}; bool typed = q->edge_type_count > 0; cbm_graph_mem_types_t mask = cbm_graph_mem_type_mask(gm, typed ? q->edge_types : calls_only, typed ? q->edge_type_count : SKIP_ONE); cbm_graph_mem_hop_t *hops = NULL; int hop_count = 0; if (!cbm_graph_mem_bfs(gm, q->start_id, q->inbound, mask, q->max_depth, &hops, &hop_count)) { store_set_error(s, "bfs: adjacency walk out of memory"); return CBM_STORE_ERR; } bfs_rows_init(rows); int rc = CBM_STORE_OK; for (int i = 0; i < hop_count && rows->count < q->probe_limit && rc == CBM_STORE_OK; i++) { sqlite3_stmt *stmt = prepare_cached(s, &s->stmt_find_node_by_id, st_find_node_by_id_sql); if (!stmt) { rc = CBM_STORE_ERR; break; } sqlite3_bind_int64(stmt, SKIP_ONE, hops[i].id); int step = sqlite3_step(stmt); if (step == SQLITE_ROW) { cbm_node_hop_t *row = bfs_rows_next(rows); scan_node(stmt, &row->node); row->hop = hops[i].hop; } else if (step != SQLITE_DONE) { store_set_error_sqlite(s, "bfs node row"); rc = CBM_STORE_ERR; } sqlite3_reset(stmt); } cbm_graph_mem_hops_free(hops); return rc; } static int 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, bool trail, int max_edges, cbm_traverse_result_t *out) { memset(out, 0, sizeof(*out)); if (max_results < 0) { max_results = 0; } 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)); bfs_query_t q = { .start_id = start_id, .inbound = (direction != NULL) && (strcmp(direction, "inbound") == 0), .types_clause = types_clause, .edge_types = edge_types, .edge_type_count = edge_type_count, .max_depth = max_depth, .max_results = max_results, /* One extra row makes max_results saturation observable on both branches. */ .probe_limit = max_results < INT_MAX ? max_results + SKIP_ONE : max_results, .trail = trail, }; bfs_rows_t rows = {0}; int cte_rows = 0; int cte_row_limit = 0; /* A shortest-path walk is answered from the adjacency layer when this * store has one; trail mode keeps its relationship-unique CTE. */ cbm_graph_mem_t *gm = trail ? NULL : store_graph_mem(s); rc = gm ? bfs_visit_mem(s, gm, &q, &rows) : bfs_visit_sql(s, &q, &rows, &cte_rows, &cte_row_limit); cbm_node_hop_t *visited = rows.visited; int n = rows.count; if (rc != CBM_STORE_OK) { out->visited = visited; out->visited_count = n; return rc; } if (trail && cte_rows > cte_row_limit) { out->truncated = true; char limit_buf[16]; snprintf(limit_buf, sizeof(limit_buf), "%d", cte_row_limit); cbm_log_warn("cypher.trail_truncated", "cte_rows", limit_buf, "result", "partial"); } if (n > max_results) { /* The extra probe row fired: drop it and report the ceiling honestly. */ n = max_results; cbm_node_free_fields(&visited[n].node); out->truncated = true; } out->visited = visited; out->visited_count = n; /* Edge properties are a secondary, potentially dense all-pairs lookup. * Lean callers pass max_edges=0 and avoid it entirely; diagnostics callers * pass an explicit ceiling and receive an honest saturation bit. */ if (n > 0 && max_edges != 0) { rc = bfs_collect_edges(s, start_id, out->visited, n, types_clause, edge_types, edge_type_count, max_edges, &out->edges, &out->edge_count, &out->edges_truncated); if (rc != CBM_STORE_OK) { return rc; } } else { out->edges = NULL; out->edge_count = 0; } return CBM_STORE_OK; } 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) { return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results, false, -1, out); } int cbm_store_bfs_trail(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) { return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results, true, -1, out); } int cbm_store_bfs_with_edge_limit(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, int max_edges, cbm_traverse_result_t *out) { /* Bounded edge-data variant for lean callers: node traversal is the plain * shortest-path BFS; max_edges=0 skips the all-pairs edge lookup entirely. */ return store_bfs(s, start_id, direction, edge_types, edge_type_count, max_depth, max_results, false, max_edges, out); } /* 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; } /* Per-label and per-type counts. GROUP BY over the (project, label) index * decodes every entry to compare its group key; stepping through the distinct * values with MIN(col) > previous seeks and counting each value's index range * is ~1.6x faster (kernel, 8.5M nodes: 0.26 -> 0.16 s; 16.9M edges: 0.54 -> * 0.32 s). The values reach ORDER BY in the ascending order GROUP BY emitted * them in, so tied counts keep their order. label and type are NOT NULL. */ static const char ST_LABEL_COUNTS_SQL[] = "WITH RECURSIVE v(x) AS (SELECT MIN(label) FROM nodes WHERE project = ?1 UNION ALL " "SELECT (SELECT MIN(label) FROM nodes WHERE project = ?1 AND label > v.x) FROM v " "WHERE v.x IS NOT NULL) " "SELECT x, (SELECT COUNT(*) FROM nodes WHERE project = ?1 AND label = v.x) AS c " "FROM v WHERE x IS NOT NULL ORDER BY c DESC;"; static const char ST_TYPE_COUNTS_SQL[] = "WITH RECURSIVE v(x) AS (SELECT MIN(type) FROM edges WHERE project = ?1 UNION ALL " "SELECT (SELECT MIN(type) FROM edges WHERE project = ?1 AND type > v.x) FROM v " "WHERE v.x IS NOT NULL) " "SELECT x, (SELECT COUNT(*) FROM edges WHERE project = ?1 AND type = v.x) AS c " "FROM v WHERE x IS NOT NULL ORDER BY c DESC;"; /* 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 = ST_LABEL_COUNTS_SQL; 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 = ST_TYPE_COUNTS_SQL; 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]; /* Relations included: a view->table USAGE across package boundaries is * data-lineage coupling and belongs in the architecture picture. */ const char *nbase = "SELECT id, qualified_name, file_path FROM nodes WHERE project=?1 AND label IN " "(" CBM_SQL_CALLABLE_OR_TYPE_LABELS "," CBM_SQL_RELATION_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 "," CBM_SQL_RELATION_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]; /* GROUP BY +n.name, not n.name: with a bare column the planner walks * idx_nodes_name over EVERY node of the project to get groups in name order, * with a table lookup per node to test the label (6.3 s on the 8.5M-node * kernel graph, for 5 Package nodes). The unary plus keeps the value and its * BINARY collation but not the index, so the rows come from the * idx_nodes_label seek. The ORDER BY sorter keeps tied counts in group * emission order, which was name order from the index; a sorted GROUP BY * follows the ORDER BY direction instead, so the name tie-break is explicit. */ 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, +n.name LIMIT 15", base); } else { snprintf(sqlbuf, sizeof(sqlbuf), "%s GROUP BY +n.name ORDER BY cnt DESC, +n.name 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 = cbm_alloc(CBM_MEM_CLASS_OTHER, (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); } void cbm_leiden_free(cbm_louvain_result_t *result) { cbm_free(CBM_MEM_CLASS_OTHER, result); /* cbm_leiden allocates OTHER */ } /* ── 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 "," CBM_SQL_RELATION_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; } } } cbm_free(CBM_MEM_CLASS_OTHER, 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; } /* ── ADR heading scanning and splicing ──────────────────────────── */ /* Length of a code-fence run (``` or ~~~, after at most three spaces of * indent), or 0 when the line does not open or close a fence. */ static int adr_fence_run(const char *line, int line_len, char *ch_out) { int i = 0; while (i < line_len && i < ST_HEADER_PREFIX && line[i] == ' ') { i++; } if (i >= line_len || (line[i] != '`' && line[i] != '~')) { return 0; } char c = line[i]; int n = 0; while (i + n < line_len && line[i + n] == c) { n++; } if (n < ST_HEADER_PREFIX) { return 0; } *ch_out = c; return n; } /* True when the line is a closing fence for an open run of `open_n` `open_ch`: * same character, at least as long, and nothing but whitespace after it. */ static bool adr_fence_closes(const char *line, int line_len, char open_ch, int open_n) { char ch = 0; int n = adr_fence_run(line, line_len, &ch); if (n == 0 || ch != open_ch || n < open_n) { return false; } int i = 0; while (i < line_len && line[i] != open_ch) { i++; } i += n; while (i < line_len) { if (line[i] != ' ' && line[i] != '\t' && line[i] != '\r') { return false; } i++; } return true; } /* Name length of a "## NAME" heading line, or 0 when it is not a heading. * "###" fails on the third '#', so deeper markdown headings are body text. */ static int adr_heading_name_len(const char *line, int line_len) { if (line_len <= ST_HEADER_PREFIX || line[0] != '#' || line[SKIP_ONE] != '#' || line[PAIR_LEN] != ' ') { return 0; } int len = line_len - ST_HEADER_PREFIX; const char *name = line + ST_HEADER_PREFIX; while (len > 0 && (name[len - SKIP_ONE] == ' ' || name[len - SKIP_ONE] == '\t' || name[len - SKIP_ONE] == '\r')) { len--; } /* A leading space would not survive a render/scan round-trip. */ if (len == 0 || name[0] == ' ' || name[0] == '\t') { return 0; } return len; } /* True when the document leaves a code fence open. Such a document cannot be * spliced: every heading after the opener is hidden, so an update would append * a duplicate heading instead of replacing the existing one. */ static bool adr_has_unterminated_fence(const char *content) { const char *p = content; bool in_fence = false; char fence_ch = 0; int fence_n = 0; while (p && *p) { const char *eol = strchr(p, '\n'); int line_len = eol ? (int)(eol - p) : (int)strlen(p); if (in_fence) { if (adr_fence_closes(p, line_len, fence_ch, fence_n)) { in_fence = false; } } else { char ch = 0; int n = adr_fence_run(p, line_len, &ch); if (n > 0) { in_fence = true; fence_ch = ch; fence_n = n; } } if (!eol) { break; } p = eol + SKIP_ONE; } return in_fence; } int cbm_adr_scan_headings(const char *content, void (*cb)(void *ctx, const cbm_adr_heading_t *h), void *ctx) { if (!content || !cb) { return content ? CBM_STORE_ERR : CBM_STORE_OK; } if (adr_has_unterminated_fence(content)) { return CBM_STORE_ERR; } cbm_adr_heading_t pending; memset(&pending, 0, sizeof(pending)); bool have_pending = false; bool in_fence = false; char fence_ch = 0; int fence_n = 0; const char *p = content; while (*p) { const char *eol = strchr(p, '\n'); int line_len = eol ? (int)(eol - p) : (int)strlen(p); size_t line_off = (size_t)(p - content); if (in_fence) { if (adr_fence_closes(p, line_len, fence_ch, fence_n)) { in_fence = false; } } else { char ch = 0; int fn = adr_fence_run(p, line_len, &ch); if (fn > 0) { in_fence = true; fence_ch = ch; fence_n = fn; } else { int name_len = adr_heading_name_len(p, line_len); if (name_len > 0) { /* The previous heading's body ends where this one starts. */ if (have_pending) { pending.body_end = line_off; cb(ctx, &pending); } pending.name = p + ST_HEADER_PREFIX; pending.name_len = name_len; pending.heading_start = line_off; pending.body_start = eol ? line_off + (size_t)line_len + SKIP_ONE : line_off + (size_t)line_len; have_pending = true; } } } if (!eol) { break; } p = eol + SKIP_ONE; } if (have_pending) { pending.body_end = strlen(content); cb(ctx, &pending); } return CBM_STORE_OK; } int cbm_adr_check_structure(const char *content, char *errbuf, int errbuf_size) { if (content && adr_has_unterminated_fence(content)) { snprintf(errbuf, (size_t)errbuf_size, "the stored ADR leaves a code fence open, so its section boundaries are " "ambiguous and a section write could splice into a code sample; repair it " "with mode='update'"); return CBM_STORE_ERR; } return CBM_STORE_OK; } typedef struct { const char *name; int name_len; bool found; size_t body_start; size_t body_end; } adr_find_ctx_t; static void adr_find_cb(void *ctx, const cbm_adr_heading_t *h) { adr_find_ctx_t *f = (adr_find_ctx_t *)ctx; /* First occurrence wins, so a document that repeats a heading updates * deterministically rather than depending on scan order. */ if (f->found || h->name_len != f->name_len || memcmp(h->name, f->name, (size_t)f->name_len) != 0) { return; } f->found = true; f->body_start = h->body_start; f->body_end = h->body_end; } /* The line ending this code should WRITE into `doc`: whichever of "\r\n" and * "\n" the document already uses more often, LF on a tie or an empty document. * Lines that already exist are never rewritten — this decides only the * separator and the "## NAME" line an append adds, so a CRLF document does not * acquire a stray bare newline nobody asked for. */ static const char *adr_dominant_eol(const char *doc, size_t doc_len) { size_t crlf = 0; size_t lf = 0; for (size_t i = 0; i < doc_len; i++) { if (doc[i] != '\n') { continue; } if (i > 0 && doc[i - SKIP_ONE] == '\r') { crlf++; } else { lf++; } } return crlf > lf ? "\r\n" : "\n"; } /* Trailing line breaks, counting a "\r\n" PAIR as ONE break. Counting raw '\n' * characters gets the count right but loses the shape, and the separator that * is then appended has to match the ending it is continuing. */ static size_t adr_trailing_breaks(const char *doc, size_t doc_len) { size_t idx = doc_len; size_t breaks = 0; while (idx > 0 && (doc[idx - SKIP_ONE] == '\n' || doc[idx - SKIP_ONE] == '\r')) { bool was_lf = doc[idx - SKIP_ONE] == '\n'; idx--; if (was_lf && idx > 0 && doc[idx - SKIP_ONE] == '\r') { idx--; } breaks++; } return breaks; } char *cbm_adr_splice_section(const char *content, const char *name, const char *body) { if (!name || !body) { return NULL; } const char *doc = content ? content : ""; size_t doc_len = strlen(doc); /* Trailing newlines are stripped from the incoming body so that splicing * the same body twice is byte-identical: without this the separator run * would grow by one blank line on every repeat. */ size_t body_len = strlen(body); while (body_len > 0 && (body[body_len - SKIP_ONE] == '\n' || body[body_len - SKIP_ONE] == '\r')) { body_len--; } adr_find_ctx_t find; memset(&find, 0, sizeof(find)); find.name = name; find.name_len = (int)strlen(name); if (cbm_adr_scan_headings(doc, adr_find_cb, &find) != CBM_STORE_OK) { return NULL; } char *out = NULL; if (find.found) { /* Keep the replaced region's own trailing newline run, so the blank * line that separates this section from the next survives untouched. */ size_t ws = find.body_end; while (ws > find.body_start && (doc[ws - SKIP_ONE] == '\n' || doc[ws - SKIP_ONE] == '\r')) { ws--; } size_t ws_len = find.body_end - ws; size_t total = find.body_start + body_len + ws_len + (doc_len - find.body_end); out = malloc(total + SKIP_ONE); if (!out) { return NULL; } size_t at = 0; memcpy(out, doc, find.body_start); at += find.body_start; memcpy(out + at, body, body_len); at += body_len; memcpy(out + at, doc + ws, ws_len); at += ws_len; memcpy(out + at, doc + find.body_end, doc_len - find.body_end); at += doc_len - find.body_end; out[at] = '\0'; return out; } /* Absent: append. Enough line breaks are added to leave exactly one blank * line before the new heading — never fewer, and never rewriting the * breaks the document already ends with. Both the separator and the * heading line use the document's OWN ending: counting raw '\n' here would * drop a bare newline into a CRLF document, leaving it mixed. */ const char *eol = adr_dominant_eol(doc, doc_len); size_t eol_len = strlen(eol); size_t breaks = adr_trailing_breaks(doc, doc_len); size_t sep = 0; if (doc_len > 0 && breaks < PAIR_LEN) { sep = PAIR_LEN - breaks; } size_t name_len = strlen(name); size_t total = doc_len + (sep * eol_len) + ST_HEADER_PREFIX + name_len + eol_len + body_len; out = malloc(total + SKIP_ONE); if (!out) { return NULL; } size_t cursor = 0; memcpy(out, doc, doc_len); cursor += doc_len; for (size_t i = 0; i < sep; i++) { memcpy(out + cursor, eol, eol_len); cursor += eol_len; } memcpy(out + cursor, "## ", ST_HEADER_PREFIX); cursor += ST_HEADER_PREFIX; memcpy(out + cursor, name, name_len); cursor += name_len; memcpy(out + cursor, eol, eol_len); cursor += eol_len; memcpy(out + cursor, body, body_len); cursor += body_len; out[cursor] = '\0'; return out; } int cbm_adr_validate_section_name(const char *name, char *errbuf, int errbuf_size) { if (!name || !name[0]) { snprintf(errbuf, (size_t)errbuf_size, "section name must not be empty"); return CBM_STORE_ERR; } size_t len = strlen(name); if (len >= CBM_SZ_64) { snprintf(errbuf, (size_t)errbuf_size, "section name must be shorter than %d characters", CBM_SZ_64); return CBM_STORE_ERR; } /* Everything below would make the written "## NAME" line scan back as a * different heading, or as no heading at all. */ if (name[0] == '#') { snprintf(errbuf, (size_t)errbuf_size, "section name must not start with '#': %s", name); return CBM_STORE_ERR; } for (size_t i = 0; i < len; i++) { if (name[i] == '\n' || name[i] == '\r') { snprintf(errbuf, (size_t)errbuf_size, "section name must not contain a line break"); return CBM_STORE_ERR; } } if (name[0] == ' ' || name[0] == '\t' || name[len - SKIP_ONE] == ' ' || name[len - SKIP_ONE] == '\t') { snprintf(errbuf, (size_t)errbuf_size, "section name must not start or end with whitespace: '%s'", name); return CBM_STORE_ERR; } return CBM_STORE_OK; } int cbm_adr_validate_section_keys(const char **keys, int count, char *errbuf, int errbuf_size) { /* Section names used to be restricted to the six canonical headings. They * are now a convention rather than a privilege, so the only names refused * are the ones that could not round-trip through a "## NAME" line. */ for (int i = 0; i < count; i++) { if (cbm_adr_validate_section_name(keys[i], errbuf, errbuf_size) != CBM_STORE_OK) { return CBM_STORE_ERR; } } return CBM_STORE_OK; } /* ── Doc -> code candidates ─────────────────────────────────────── */ static const char DOC_CANDIDATES_DDL[] = "CREATE TABLE IF NOT EXISTS doc_link_candidates (" " project TEXT NOT NULL," " section_qn TEXT NOT NULL," " target_qn TEXT NOT NULL," " rank INTEGER NOT NULL," " score REAL NOT NULL," " p REAL NOT NULL," " evidence TEXT NOT NULL DEFAULT ''" ");" "CREATE INDEX IF NOT EXISTS idx_doc_link_candidates_section " "ON doc_link_candidates(project, section_qn);" "CREATE INDEX IF NOT EXISTS idx_doc_link_candidates_target " "ON doc_link_candidates(project, target_qn);"; int cbm_store_doc_candidates_replace(cbm_store_t *s, const char *project, const cbm_doc_candidate_t *rows, int count) { if (!s || !s->db || !project || count < 0 || (count > 0 && !rows)) { return CBM_STORE_ERR; } if (exec_sql(s, DOC_CANDIDATES_DDL) != CBM_STORE_OK || exec_sql(s, "BEGIN;") != CBM_STORE_OK) { return CBM_STORE_ERR; } sqlite3_stmt *del = NULL; sqlite3_stmt *ins = NULL; bool ok = sqlite3_prepare_v2(s->db, "DELETE FROM doc_link_candidates WHERE project = ?1;", CBM_NOT_FOUND, &del, NULL) == SQLITE_OK && sqlite3_prepare_v2(s->db, "INSERT INTO doc_link_candidates " "(project, section_qn, target_qn, rank, score, p, evidence) " "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7);", CBM_NOT_FOUND, &ins, NULL) == SQLITE_OK; if (ok) { bind_text(del, SKIP_ONE, project); ok = sqlite3_step(del) == SQLITE_DONE; } for (int i = 0; ok && i < count; i++) { if (!rows[i].section_qn || !rows[i].target_qn) { continue; } bind_text(ins, SKIP_ONE, project); bind_text(ins, ST_COL_2, rows[i].section_qn); bind_text(ins, ST_COL_3, rows[i].target_qn); sqlite3_bind_int(ins, ST_COL_4, rows[i].rank); sqlite3_bind_double(ins, ST_COL_5, rows[i].score); sqlite3_bind_double(ins, ST_COL_6, rows[i].p); bind_text(ins, CBM_SZ_7, rows[i].evidence ? rows[i].evidence : ""); ok = sqlite3_step(ins) == SQLITE_DONE; sqlite3_reset(ins); } sqlite3_finalize(del); sqlite3_finalize(ins); if (!ok) { store_set_error_sqlite(s, "doc_candidates replace"); (void)exec_sql(s, "ROLLBACK;"); return CBM_STORE_ERR; } return exec_sql(s, "COMMIT;"); } /* Does the database hold the candidates table? CBM_STORE_NOT_FOUND: no (a * database written by an older build). */ static int doc_candidates_table(cbm_store_t *s) { sqlite3_stmt *probe = NULL; if (sqlite3_prepare_v2(s->db, "SELECT 1 FROM sqlite_master WHERE type='table' AND " "name='doc_link_candidates' LIMIT 1;", CBM_NOT_FOUND, &probe, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "doc_candidates probe"); return CBM_STORE_ERR; } int rc = sqlite3_step(probe); sqlite3_finalize(probe); if (rc == SQLITE_DONE) { return CBM_STORE_NOT_FOUND; } if (rc != SQLITE_ROW) { store_set_error_sqlite(s, "doc_candidates probe step"); return CBM_STORE_ERR; } return CBM_STORE_OK; } /* Run a prepared candidates query (project ?1, key ?2, limit ?3; columns * section_qn, target_qn, rank, score, p, evidence) into *out; finalizes it. */ static int doc_candidates_read(sqlite3_stmt *stmt, const char *project, const char *key, int limit, cbm_doc_candidate_t **out, int *count) { bind_text(stmt, SKIP_ONE, project); bind_text(stmt, ST_COL_2, key); sqlite3_bind_int(stmt, ST_COL_3, limit); cbm_doc_candidate_t *rows = cbm_calloc(CBM_MEM_CLASS_STORE, (size_t)limit * sizeof(*rows)); if (!rows) { sqlite3_finalize(stmt); return CBM_STORE_ERR; } int n = 0; while (n < limit && sqlite3_step(stmt) == SQLITE_ROW) { rows[n].section_qn = cbm_mem_strdup(CBM_MEM_CLASS_STORE, (const char *)sqlite3_column_text(stmt, 0)); rows[n].target_qn = cbm_mem_strdup(CBM_MEM_CLASS_STORE, (const char *)sqlite3_column_text(stmt, SKIP_ONE)); rows[n].rank = sqlite3_column_int(stmt, ST_COL_2); rows[n].score = sqlite3_column_double(stmt, ST_COL_3); rows[n].p = sqlite3_column_double(stmt, ST_COL_4); rows[n].evidence = cbm_mem_strdup(CBM_MEM_CLASS_STORE, (const char *)sqlite3_column_text(stmt, ST_COL_5)); n++; } sqlite3_finalize(stmt); *out = rows; *count = n; return CBM_STORE_OK; } static int doc_candidates_query(cbm_store_t *s, const char *sql, const char *project, const char *key, int limit, cbm_doc_candidate_t **out, int *count) { *out = NULL; *count = 0; int rc = doc_candidates_table(s); if (rc != CBM_STORE_OK) { return rc; } sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "doc_candidates get"); return CBM_STORE_ERR; } return doc_candidates_read(stmt, project, key, limit, out, count); } int cbm_store_doc_candidates_get(cbm_store_t *s, const char *project, const char *section_qn, const char *target_qn, int limit, cbm_doc_candidate_t **out, int *count) { if (!s || !s->db || !project || !out || !count || (!section_qn == !target_qn) || limit <= 0) { return CBM_STORE_ERR; } const char *sql = section_qn ? "SELECT section_qn, target_qn, rank, score, p, evidence FROM " "doc_link_candidates WHERE project=?1 AND section_qn=?2 " "ORDER BY p DESC, score DESC, target_qn LIMIT ?3;" : "SELECT section_qn, target_qn, rank, score, p, evidence FROM " "doc_link_candidates WHERE project=?1 AND target_qn=?2 " "ORDER BY p DESC, score DESC, section_qn LIMIT ?3;"; return doc_candidates_query(s, sql, project, section_qn ? section_qn : target_qn, limit, out, count); } int cbm_store_doc_candidates_for_path(cbm_store_t *s, const char *project, const char *file_path, int limit, cbm_doc_candidate_t **out, int *count) { if (!s || !s->db || !project || !file_path || !file_path[0] || !out || !count || limit <= 0) { return CBM_STORE_ERR; } static const char SQL[] = "SELECT c.section_qn, c.target_qn, c.rank, c.score, c.p, c.evidence " "FROM doc_link_candidates c JOIN nodes n " "ON n.project = c.project AND n.qualified_name = c.target_qn " "WHERE c.project=?1 AND ((n.label='File' AND n.file_path=?2) OR " "(n.label='Folder' AND n.file_path <> '' AND " "substr(?2, 1, length(n.file_path) + 1) = n.file_path || '/')) " "ORDER BY c.p DESC, c.score DESC, c.section_qn LIMIT ?3;"; return doc_candidates_query(s, SQL, project, file_path, limit, out, count); } void cbm_store_doc_candidates_free(cbm_doc_candidate_t *rows, int count) { for (int i = 0; rows && i < count; i++) { cbm_free(CBM_MEM_CLASS_STORE, (void *)rows[i].section_qn); cbm_free(CBM_MEM_CLASS_STORE, (void *)rows[i].target_qn); cbm_free(CBM_MEM_CLASS_STORE, (void *)rows[i].evidence); } cbm_free(CBM_MEM_CLASS_STORE, rows); } 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) { if (!s || !s->db || !project || !keys || !values || !out) { return CBM_STORE_ERR; } /* The read-modify-write below must be ONE transaction. Three writers * replace this row wholesale — the indexing pipeline, the UI POST * /api/adr handler, and manage_adr mode='update' — so an unguarded * get/merge/store silently loses whichever of them commits between the * read and the UPSERT. BEGIN IMMEDIATE takes the write lock up front, so a * competing writer waits rather than being overwritten. */ if (cbm_store_begin(s) != CBM_STORE_OK) { return CBM_STORE_ERR; } /* Get existing ADR */ cbm_adr_t existing; int rc = cbm_store_adr_get(s, project, &existing); if (rc != CBM_STORE_OK) { (void)cbm_store_rollback(s); store_set_error(s, "no existing ADR to update"); return rc; } /* Splice each section in place. The document is NOT rebuilt from parsed * sections: that model drops the preamble, drops headings it does not * recognise (including a mis-cased '## Purpose', whose whole block then * disappears) and reorders the rest, so rendering from it would silently * rewrite text nobody asked to change. Replacing byte spans leaves every * untouched byte exactly as it was. */ char structure_err[CBM_SZ_256] = ""; if (cbm_adr_check_structure(existing.content, structure_err, (int)sizeof(structure_err)) != CBM_STORE_OK) { cbm_store_adr_free(&existing); (void)cbm_store_rollback(s); store_set_error(s, structure_err); return CBM_STORE_ERR; } char *merged = heap_strdup(existing.content ? existing.content : ""); cbm_store_adr_free(&existing); for (int i = 0; merged && i < count; i++) { char *next = cbm_adr_splice_section(merged, keys[i], values[i]); free(merged); merged = next; } if (!merged) { (void)cbm_store_rollback(s); store_set_error(s, "failed to splice ADR section"); return CBM_STORE_ERR; } /* 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)); free(merged); (void)cbm_store_rollback(s); store_set_error(s, msg); return CBM_STORE_ERR; } /* Store merged */ rc = cbm_store_adr_store(s, project, merged); free(merged); if (rc != CBM_STORE_OK) { (void)cbm_store_rollback(s); return rc; } /* Read back INSIDE the transaction so `out` is exactly what commits. */ rc = cbm_store_adr_get(s, project, out); if (rc != CBM_STORE_OK) { (void)cbm_store_rollback(s); return rc; } if (cbm_store_commit(s) != CBM_STORE_OK) { cbm_store_adr_free(out); (void)cbm_store_rollback(s); return CBM_STORE_ERR; } return CBM_STORE_OK; } 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; } /* L2 norm of every quantized keyword vector, computed once per search * instead of once per (node, keyword) pair inside the scoring loop. */ static void vs_keyword_norms(const int8_t (*kw_vecs)[VS_VEC_DIM], int actual_kw, double *kw_norms) { for (int k = 0; k < actual_kw; k++) { int32_t ma = 0; for (int d = 0; d < VS_VEC_DIM; d++) { ma += (int32_t)kw_vecs[k][d] * (int32_t)kw_vecs[k][d]; } kw_norms[k] = sqrt((double)ma); } } /* Compute the per-keyword min cosine score between a node's int8 vector and * each of the query vectors. `kw_norms[k]` is the precomputed L2 norm of * kw_vecs[k]; the node norm is computed once per node. The integer sums are * exact, so factoring the norms out of the pair loop leaves every score * bit-identical to the fused form. 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], const double *kw_norms, int actual_kw) { if (!node_vec || node_vec_len != VS_VEC_DIM) { return 0.0; } int32_t mb = 0; for (int d = 0; d < VS_VEC_DIM; d++) { mb += (int32_t)node_vec[d] * (int32_t)node_vec[d]; } double node_norm = sqrt((double)mb); double min_score = CBM_STORE_UNIT_POS_D; for (int k = 0; k < actual_kw; k++) { int32_t dot = 0; for (int d = 0; d < VS_VEC_DIM; d++) { dot += (int32_t)kw_vecs[k][d] * (int32_t)node_vec[d]; } double denom = kw_norms[k] * node_norm; 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 `*results`, * growing the array geometrically on demand. Returns CBM_STORE_OK, or * CBM_STORE_ERR on allocation failure — in which case `*results` still owns * exactly `*count` complete rows (a row is only counted once every string * copy succeeded), so the caller's usual free path stays valid. */ static int vs_append_result(cbm_vector_result_t **results, int *count, int *cap, sqlite3_stmt *stmt, const int8_t (*kw_vecs)[VS_VEC_DIM], const double *kw_norms, 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 CBM_STORE_ERR; } *results = grown; *cap = nc; } cbm_vector_result_t *row = &(*results)[*count]; row->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); row->name = strdup(name ? name : ""); row->qualified_name = strdup(qn ? qn : ""); row->file_path = strdup(fp ? fp : ""); row->label = strdup(label ? label : ""); if (!row->name || !row->qualified_name || !row->file_path || !row->label) { free(row->name); free(row->qualified_name); free(row->file_path); free(row->label); return CBM_STORE_ERR; } 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); row->score = vs_min_cosine_score(node_vec, node_vec_len, kw_vecs, kw_norms, actual_kw); (*count)++; return CBM_STORE_OK; } /* A lean index carries no node_vectors table at all. That is an empty * semantic universe, not a scan failure, and callers must be able to tell * the two apart — so probe the schema before preparing the scan. Returns * CBM_STORE_OK (present), CBM_STORE_NOT_FOUND (absent) or CBM_STORE_ERR. */ static int vs_probe_node_vectors_table(cbm_store_t *s) { sqlite3_stmt *probe = NULL; const char *sql = "SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_vectors' LIMIT 1;"; if (sqlite3_prepare_v2(s->db, sql, SQLITE_AUTO_LEN, &probe, NULL) != SQLITE_OK) { (void)fprintf(stderr, "vector_search: %s\n", sqlite3_errmsg(s->db)); return CBM_STORE_ERR; } int step_rc = sqlite3_step(probe); sqlite3_finalize(probe); if (step_rc == SQLITE_ROW) { return CBM_STORE_OK; } if (step_rc == SQLITE_DONE) { return CBM_STORE_NOT_FOUND; } (void)fprintf(stderr, "vector_search: %s\n", sqlite3_errmsg(s->db)); return CBM_STORE_ERR; } static int vs_ranked_result_cmp(const void *lhs, const void *rhs) { const cbm_vector_result_t *a = lhs; const cbm_vector_result_t *b = rhs; if (a->score > b->score) { return -1; } if (a->score < b->score) { return 1; } return (a->node_id > b->node_id) - (a->node_id < b->node_id); } 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; } int probe_rc = vs_probe_node_vectors_table(s); if (probe_rc != CBM_STORE_OK) { return probe_rc; } 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; } double kw_norms[VS_MAX_KW]; vs_keyword_norms(kw_vecs, actual_kw, kw_norms); /* Use the first keyword for a cheap candidate ordering, then score each * materialized candidate by min-cosine across every keyword. */ 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, n.id ASC" " 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; } /* A fixed 5x prefilter is normally ample, but it is not a proof: a later * candidate can have a lower first-keyword score and a much higher * all-keyword min-score. Expand until either the scan is exhausted or the * Kth final score is strictly above the best possible omitted score. This * makes top-K prefixes identical for every requested K, which semantic * offset pagination requires. */ int requested_limit = limit > 0 ? limit : CBM_SZ_16; int fetch_limit = requested_limit > INT_MAX / ST_COL_5 ? INT_MAX : requested_limit * 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); cbm_vector_result_t *results = NULL; int count = 0; for (;;) { int cap = 0; int step_rc; double omitted_score_ceiling = 0.0; sqlite3_bind_int(stmt, ST_COL_3, fetch_limit); while ((step_rc = sqlite3_step(stmt)) == SQLITE_ROW) { omitted_score_ceiling = sqlite3_column_double(stmt, ST_COL_5); if (vs_append_result(&results, &count, &cap, stmt, kw_vecs, kw_norms, actual_kw) != CBM_STORE_OK) { step_rc = SQLITE_NOMEM; break; } } 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)); cbm_store_free_vector_results(results, count); sqlite3_finalize(stmt); return CBM_STORE_ERR; } if (count > 1) { qsort(results, (size_t)count, sizeof(*results), vs_ranked_result_cmp); } bool scan_exhausted = count < fetch_limit; bool top_k_certified = scan_exhausted || count < requested_limit || results[requested_limit - 1].score > omitted_score_ceiling; if (top_k_certified || fetch_limit == INT_MAX) { break; } cbm_store_free_vector_results(results, count); results = NULL; count = 0; fetch_limit = fetch_limit > INT_MAX / ST_COL_2 ? INT_MAX : fetch_limit * ST_COL_2; sqlite3_reset(stmt); } { char kw_buf[VS_STR_BUF]; char fl_buf[VS_STR_BUF]; char cnt_buf[VS_STR_BUF]; snprintf(kw_buf, sizeof(kw_buf), "%d", actual_kw); snprintf(fl_buf, sizeof(fl_buf), "%d", fetch_limit); snprintf(cnt_buf, sizeof(cnt_buf), "%d", count); cbm_log_info("vector_search.done", "kw_count", kw_buf, "fetch_limit", fl_buf, "candidates", cnt_buf); } sqlite3_finalize(stmt); /* Trim to requested limit */ int final_limit = requested_limit; 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; } /* ── Observed runtime calls (ingest_traces) ───────────────────────── * * Qualified-name keyed on purpose: ids are reassigned on reindex, names * survive. Low-frequency writes (one ingest per test run), so these use * one-shot statements rather than the cached slots. */ static int observed_upsert(cbm_store_t *s, const char *sql, const char *project, const char *a, const char *b, const char *label, long long count) { if (!s || !s->db || !project || !a) return CBM_STORE_ERR; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, sql, -1, &stmt, NULL) != SQLITE_OK) { store_set_error_sqlite(s, "observed_upsert prepare"); return CBM_STORE_ERR; } char ts[CBM_SZ_64]; iso_now(ts, sizeof(ts)); sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, a, -1, SQLITE_TRANSIENT); if (b) sqlite3_bind_text(stmt, 3, b, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, b ? 4 : 3, label ? label : "", -1, SQLITE_TRANSIENT); sqlite3_bind_int64(stmt, b ? 5 : 4, count > 0 ? count : 1); sqlite3_bind_text(stmt, b ? 6 : 5, ts, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { store_set_error_sqlite(s, "observed_upsert"); return CBM_STORE_ERR; } return CBM_STORE_OK; } int cbm_store_observed_upsert_pair(cbm_store_t *s, const char *project, const char *caller_qn, const char *callee_qn, const char *label, long long count) { if (!caller_qn || !callee_qn) return CBM_STORE_ERR; return observed_upsert( s, "INSERT INTO observed_calls (project, caller_qn, callee_qn, label, count, first_seen," " last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6) " "ON CONFLICT(project, caller_qn, callee_qn, label) DO UPDATE SET" " count = count + excluded.count, last_seen = excluded.last_seen;", project, caller_qn, callee_qn, label, count); } int cbm_store_observed_upsert_path(cbm_store_t *s, const char *project, const char *path_json, const char *label, long long count) { if (!path_json) return CBM_STORE_ERR; return observed_upsert( s, "INSERT INTO observed_paths (project, label, path_json, count, first_seen, last_seen)" " VALUES (?1, ?3, ?2, ?4, ?5, ?5) " "ON CONFLICT(project, label, path_json) DO UPDATE SET" " count = count + excluded.count, last_seen = excluded.last_seen;", project, path_json, NULL, label, count); } long long cbm_store_observed_pair_total(cbm_store_t *s, const char *project) { if (!s || !s->db || !project) return -1; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, "SELECT COUNT(*) FROM observed_calls WHERE project=?1", -1, &stmt, NULL) != SQLITE_OK) return -1; sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); long long total = -1; if (sqlite3_step(stmt) == SQLITE_ROW) total = sqlite3_column_int64(stmt, 0); sqlite3_finalize(stmt); return total; } /* Evict the run label whose newest observation is oldest — whole runs at a * time, so a partially-evicted run can never masquerade as complete. * evicted_label_out (optional) receives a heap copy the caller frees. */ int cbm_store_observed_evict_oldest_label(cbm_store_t *s, const char *project, char **evicted_label_out) { if (evicted_label_out) *evicted_label_out = NULL; if (!s || !s->db || !project) return CBM_STORE_ERR; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, /* rowid tiebreak: last_seen has second resolution, * and eviction order must never be a timing lottery — * on a tie the earliest-inserted run is the oldest. */ "SELECT label FROM observed_calls WHERE project=?1 GROUP BY label" " ORDER BY MAX(last_seen) ASC, MIN(rowid) ASC LIMIT 1", -1, &stmt, NULL) != SQLITE_OK) return CBM_STORE_ERR; sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); char label[CBM_SZ_256] = {0}; bool found = false; if (sqlite3_step(stmt) == SQLITE_ROW) { const char *l = (const char *)sqlite3_column_text(stmt, 0); snprintf(label, sizeof(label), "%s", l ? l : ""); found = true; } sqlite3_finalize(stmt); if (!found) return CBM_STORE_ERR; static const char *const wipes[] = { "DELETE FROM observed_calls WHERE project=?1 AND label=?2", "DELETE FROM observed_paths WHERE project=?1 AND label=?2", }; for (size_t i = 0; i < sizeof(wipes) / sizeof(wipes[0]); i++) { if (sqlite3_prepare_v2(s->db, wipes[i], -1, &stmt, NULL) != SQLITE_OK) return CBM_STORE_ERR; sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, label, -1, SQLITE_TRANSIENT); int rc = sqlite3_step(stmt); sqlite3_finalize(stmt); if (rc != SQLITE_DONE) { store_set_error_sqlite(s, "observed_evict"); return CBM_STORE_ERR; } } if (evicted_label_out) { size_t len = strlen(label) + 1; char *copy = malloc(len); if (copy) memcpy(copy, label, len); *evicted_label_out = copy; } return CBM_STORE_OK; } /* Aggregate observation for one call edge: total count across runs, the * newest run's label and timestamp. Returns CBM_STORE_OK with *count_out=0 * when the pair was never observed (absence is data, not an error). */ int cbm_store_observed_lookup(cbm_store_t *s, const char *project, const char *caller_qn, const char *callee_qn, long long *count_out, char *label_buf, size_t label_cap, char *last_seen_buf, size_t last_seen_cap) { if (count_out) *count_out = 0; if (label_buf && label_cap) label_buf[0] = '\0'; if (last_seen_buf && last_seen_cap) last_seen_buf[0] = '\0'; if (!s || !s->db || !project || !caller_qn || !callee_qn) return CBM_STORE_ERR; sqlite3_stmt *stmt = NULL; if (sqlite3_prepare_v2(s->db, /* rowid tiebreak: last_seen has second resolution; * on a tie the later-inserted run is the newer one. */ "SELECT count, label, last_seen FROM observed_calls WHERE project=?1" " AND caller_qn=?2 AND callee_qn=?3" " ORDER BY last_seen DESC, rowid DESC", -1, &stmt, NULL) != SQLITE_OK) return CBM_STORE_ERR; sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 2, caller_qn, -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, callee_qn, -1, SQLITE_TRANSIENT); long long total = 0; bool first = true; while (sqlite3_step(stmt) == SQLITE_ROW) { total += sqlite3_column_int64(stmt, 0); if (first) { const char *l = (const char *)sqlite3_column_text(stmt, 1); const char *seen = (const char *)sqlite3_column_text(stmt, 2); if (label_buf && label_cap) snprintf(label_buf, label_cap, "%s", l ? l : ""); if (last_seen_buf && last_seen_cap) snprintf(last_seen_buf, last_seen_cap, "%s", seen ? seen : ""); first = false; } } sqlite3_finalize(stmt); if (count_out) *count_out = total; return CBM_STORE_OK; }