fix(graph): IMPORTS edges keyed by local_name across buffer, store and dump

Distills PR #770 (graph-buffer dedup key) and completes the edge-uniqueness
contract it left partial: the contract lives in THREE places, and changing
only the buffer ships databases that violate their own UNIQUE constraint
(PRAGMA integrity_check: non-unique entry in sqlite_autoindex_edges_1).

A single 'import { A, B } from ./lib' produced ONE IMPORTS edge: the graph
buffer dedups edges on (source_id, target_id, type) and merge-replaces
properties on collision, so the second symbol silently overwrote the first.
pass_calls.c, pass_usages.c, pass_semantic.c and pass_lsp_cross.c parse one
local_name per IMPORTS edge for cross-file resolution, so the dropped
symbol's calls failed to resolve too — not just "who imports X" queries.

Changes, kept in sync across all three sites:

- graph_buffer.c (from #770): make_edge_key() folds local_name into the
  dedup key for IMPORTS edges only; all three key call-sites updated.
  Hardened beyond #770: EDGE_KEY_BUF bumped to 256 and oversized
  local_names are re-keyed with an FNV-1a hash of the full name instead
  of being silently truncated (two long names sharing a prefix must not
  collide back into one edge).
- store.c: edges gains local_name_gen, a VIRTUAL generated column
  (IMPORTS -> coalesce(json_extract(properties,'$.local_name'),''),
  else '' — NOT NULL because NULLs never conflict in a UNIQUE index);
  uniqueness widened to UNIQUE(source_id, target_id, type,
  local_name_gen) and the insert upsert's conflict target matches.
  init_schema probes pre-#768 DBs (no local_name_gen) and fails the
  open: SQLite cannot ALTER a table constraint in place, and an
  unopenable DB already takes the existing repair path — full index
  deletes + rebuilds, artifact import refuses and falls back to a
  reindex. Read-only query opens skip init_schema and keep working.
- sqlite_writer.c/.h: dump DDL matches the widened schema; the hand-built
  sqlite_autoindex_edges_1 comparator and entry builder include the
  local_name column; CBMDumpEdge carries local_name extracted via real
  JSON parsing (yyjson) so index entries match json_extract's unescaped
  values exactly, per the idx_edges_url_path precedent.
- artifact.h: CBM_ARTIFACT_SCHEMA_VERSION 1 -> 2 so old binaries refuse
  artifacts carrying the widened schema (their 3-column conflict target
  can no longer prepare against it).

Tests (reproduce-first, all red on the unfixed code):
- gbuf tier: multi-symbol import -> 2 IMPORTS edges; long-local_name
  truncation guard.
- store tier: distinct local_name coexists as 2 rows, same local_name
  still upserts, non-IMPORTS dedup unchanged.
- writer tier: dumped DB passes integrity_check with 2 sibling imports
  and exposes matching local_name_gen values.
- end-to-end: TS fixture through the real pipeline -> 2 queryable
  IMPORTS edges AND integrity_check ok; with only the buffer half of
  the fix this test still fails on integrity_check, proving the schema
  half is required.

Closes #768.

Co-authored-by: Alexandros Pappas <11921291+apappas1129@users.noreply.github.com>
Signed-off-by: Martin Vogel <martin.vogel.tech@gmail.com>
This commit is contained in:
Martin Vogel
2026-07-03 20:16:39 +02:00
parent 09148ab121
commit 6012f4626e
9 changed files with 460 additions and 44 deletions
+22 -10
View File
@@ -738,7 +738,7 @@ static uint8_t *build_node_record(const CBMDumpNode *n, int *out_len) {
}
// Build an edges table record: (id, project, source_id, target_id, type, properties)
// url_path_gen is a VIRTUAL generated column — NOT stored in the record.
// url_path_gen and local_name_gen are VIRTUAL generated columns — NOT stored in the record.
static uint8_t *build_edge_record(const CBMDumpEdge *e, int *out_len) {
RecordBuilder r;
rec_init(&r);
@@ -993,16 +993,17 @@ static uint8_t *build_index_entry_text_int_text_rowid(const char *t1, int64_t va
return cell;
}
// Build UNIQUE index entry for (text, text) + rowid (e.g., nodes unique(project, qualified_name))
// Build UNIQUE index entry for (int64, int64, text) + rowid (edges unique(source_id, target_id,
// type))
static uint8_t *build_index_entry_unique_2int_text_rowid(int64_t v1, int64_t v2, const char *text,
int64_t rowid, int *out_len) {
// Build UNIQUE index entry for (int64, int64, text, text) + rowid — edges
// unique(source_id, target_id, type, local_name_gen) (#768).
static uint8_t *build_index_entry_unique_2int_2text_rowid(int64_t v1, int64_t v2, const char *text,
const char *text2, int64_t rowid,
int *out_len) {
RecordBuilder r;
rec_init(&r);
rec_add_int(&r, v1);
rec_add_int(&r, v2);
rec_add_text(&r, text);
rec_add_text(&r, text2);
rec_add_int(&r, rowid);
int payload_len = 0;
uint8_t *payload = rec_finalize(&r, &payload_len);
@@ -1515,7 +1516,7 @@ static int cmp_edge_by_url_path(const void *a, const void *b) {
return cmp_i64(g_sort_edges[ia].id, g_sort_edges[ib].id);
}
// autoindex_edges_1: UNIQUE(source_id, target_id, type) + rowid
// autoindex_edges_1: UNIQUE(source_id, target_id, type, local_name_gen) + rowid (#768)
static int cmp_edge_by_src_tgt_type(const void *a, const void *b) {
int ia = *(const int *)a;
int ib = *(const int *)b;
@@ -1531,6 +1532,10 @@ static int cmp_edge_by_src_tgt_type(const void *a, const void *b) {
if (c) {
return c;
}
c = strcmp(safe_str(g_sort_edges[ia].local_name), safe_str(g_sort_edges[ib].local_name));
if (c) {
return c;
}
return cmp_i64(g_sort_edges[ia].id, g_sort_edges[ib].id);
}
@@ -1567,8 +1572,8 @@ static uint8_t *ecell_proj_source_type(const CBMDumpEdge *e, int *out_len) {
return build_index_entry_text_int_text_rowid(e->project, e->source_id, e->type, e->id, out_len);
}
static uint8_t *ecell_src_tgt_type(const CBMDumpEdge *e, int *out_len) {
return build_index_entry_unique_2int_text_rowid(e->source_id, e->target_id, e->type, e->id,
out_len);
return build_index_entry_unique_2int_2text_rowid(e->source_id, e->target_id, e->type,
safe_str(e->local_name), e->id, out_len);
}
static uint8_t *ecell_url_path(const CBMDumpEdge *e, int *out_len) {
const char *url = (e->url_path && e->url_path[0] != '\0') ? e->url_path : NULL;
@@ -2135,13 +2140,20 @@ static int write_db_after_nodes(write_db_ctx_t *w, uint32_t nodes_root) {
"CREATE INDEX idx_nodes_name ON nodes(project, name)"},
{"index", "idx_nodes_file", "nodes", idx_nodes_file_root,
"CREATE INDEX idx_nodes_file ON nodes(project, file_path)"},
// local_name_gen + widened UNIQUE (#768): must stay semantically
// identical to init_schema in src/store/store.c, and the hand-built
// sqlite_autoindex_edges_1 (cmp_edge_by_src_tgt_type +
// ecell_src_tgt_type) must produce exactly the values SQLite computes
// for local_name_gen, or integrity_check fails on the dumped DB.
{"table", "edges", "edges", edges_root,
"CREATE TABLE edges (\n\t\tid INTEGER PRIMARY KEY AUTOINCREMENT,\n\t\tproject TEXT NOT "
"NULL REFERENCES projects(name) ON DELETE CASCADE,\n\t\tsource_id INTEGER NOT NULL "
"REFERENCES nodes(id) ON DELETE CASCADE,\n\t\ttarget_id INTEGER NOT NULL REFERENCES "
"nodes(id) ON DELETE CASCADE,\n\t\ttype TEXT NOT NULL,\n\t\tproperties TEXT DEFAULT "
"'{}',\n\t\turl_path_gen TEXT GENERATED ALWAYS AS "
"(json_extract(properties,'$.url_path')),\n\t\tUNIQUE(source_id, target_id, type)\n\t)"},
"(json_extract(properties,'$.url_path')),\n\t\tlocal_name_gen TEXT GENERATED ALWAYS AS "
"(CASE WHEN type='IMPORTS' THEN coalesce(json_extract(properties,'$.local_name'),'') "
"ELSE '' END),\n\t\tUNIQUE(source_id, target_id, type, local_name_gen)\n\t)"},
{"index", "sqlite_autoindex_edges_1", "edges", autoindex_edges_root, NULL},
{"index", "idx_edges_source", "edges", idx_edges_source_root,
"CREATE INDEX idx_edges_source ON edges(source_id, type)"},
+5
View File
@@ -25,6 +25,11 @@ typedef struct {
const char *type;
const char *properties; // JSON string
const char *url_path; // extracted from properties by Go (for idx_edges_url_path)
const char *local_name; // for IMPORTS edges: the UNESCAPED
// json_extract(properties,'$.local_name') value; ""/NULL
// otherwise. Feeds sqlite_autoindex_edges_1 — must match
// what SQLite computes for the local_name_gen column or
// integrity_check reports the row missing from the index.
} CBMDumpEdge;
typedef struct {
+95 -19
View File
@@ -49,8 +49,11 @@ static inline void *intptr_to_ptr(intptr_t v) {
/* ── Internal types ──────────────────────────────────────────────── */
/* Edge key for dedup hash table — composite key as string "srcID:tgtID:type" */
#define EDGE_KEY_BUF CBM_SZ_128
/* Edge key for dedup hash table — composite key as string "srcID:tgtID:type",
* plus ":local_name" for IMPORTS edges (#768). 256 bytes fit two int64s, the
* type and a ~200-char local_name verbatim; longer local_names are re-keyed
* with a hash of the full name in make_edge_key (never silently truncated). */
#define EDGE_KEY_BUF CBM_SZ_256
/* Per-type or per-key edge list stored in hash tables as values */
typedef CBM_DYN_ARRAY(const cbm_gbuf_edge_t *) edge_ptr_array_t;
@@ -135,7 +138,52 @@ static void make_id_key(char *buf, size_t bufsz, int64_t id) {
snprintf(buf, bufsz, "%lld", (long long)id);
}
static void make_edge_key(char *buf, size_t bufsz, int64_t src, int64_t tgt, const char *type) {
/* FNV-1a 64-bit over a byte slice — for re-keying oversized local_names. */
static uint64_t fnv1a64(const char *s, size_t len) {
uint64_t h = 14695981039346656037ULL;
for (size_t i = 0; i < len; i++) {
h ^= (uint8_t)s[i];
h *= 1099511628211ULL;
}
return h;
}
/* IMPORTS edges carry exactly one imported symbol's local_name (#768): two
* named imports from the same specifier resolve to the same (source,
* target) pair but are distinct symbols. Key on local_name too so the
* second import doesn't dedup-collide with and overwrite the first —
* every pass that walks IMPORTS edges (pass_calls.c, pass_usages.c,
* pass_semantic.c, pass_lsp_cross.c) expects one local_name per edge, so
* losing an edge here silently breaks cross-file call resolution for
* whichever symbol got dropped, not just "who imports X" queries. Other
* edge types keep the plain (source,target,type) key: collapsing repeat
* edges of the same type between the same two nodes (e.g. multiple call
* sites) into one is the existing, intended dedup behavior there.
*
* A local_name too long for the key buffer is re-keyed with an FNV-1a hash
* of the FULL name instead of being truncated — a truncated key would
* collide two long names sharing a prefix and silently drop an edge again.
* The hash key is prefixed with byte 0x01, which cannot appear in the raw
* JSON slice (control characters must be \u-escaped in JSON), so hash keys
* can never collide with verbatim keys. */
static void make_edge_key(char *buf, size_t bufsz, int64_t src, int64_t tgt, const char *type,
const char *properties_json) {
if (properties_json && strcmp(type, "IMPORTS") == 0) {
static const char local_name_key[] = "\"local_name\":\"";
const char *ln = strstr(properties_json, local_name_key);
if (ln) {
ln += sizeof(local_name_key) - 1;
const char *end = strchr(ln, '"');
size_t ln_len = end ? (size_t)(end - ln) : strlen(ln);
int n = snprintf(buf, bufsz, "%lld:%lld:%s:%.*s", (long long)src, (long long)tgt, type,
(int)ln_len, ln);
if (n < 0 || (size_t)n >= bufsz) {
snprintf(buf, bufsz, "%lld:%lld:%s:\x01%016llx", (long long)src, (long long)tgt,
type, (unsigned long long)fnv1a64(ln, ln_len));
}
return;
}
}
snprintf(buf, bufsz, "%lld:%lld:%s", (long long)src, (long long)tgt, type);
}
@@ -244,7 +292,7 @@ static void remove_node_from_ptr_array(node_ptr_array_t *arr, int64_t node_id) {
static void unindex_edge(cbm_gbuf_t *gb, const cbm_gbuf_edge_t *e) {
char key[EDGE_KEY_BUF];
make_edge_key(key, sizeof(key), e->source_id, e->target_id, e->type);
make_edge_key(key, sizeof(key), e->source_id, e->target_id, e->type, e->properties_json);
const char *ekey = cbm_ht_get_key(gb->edge_by_key, key);
cbm_ht_delete(gb->edge_by_key, key);
free((void *)ekey);
@@ -919,7 +967,7 @@ int64_t cbm_gbuf_insert_edge(cbm_gbuf_t *gb, int64_t source_id, int64_t target_i
/* Check for dedup */
char key[EDGE_KEY_BUF];
make_edge_key(key, sizeof(key), source_id, target_id, type);
make_edge_key(key, sizeof(key), source_id, target_id, type, properties_json);
cbm_gbuf_edge_t *existing = cbm_ht_get(gb->edge_by_key, key);
if (existing) {
@@ -1032,7 +1080,8 @@ int cbm_gbuf_delete_edges_by_type(cbm_gbuf_t *gb, const char *type) {
cbm_gbuf_edge_t *e = gb->edges.items[i];
if (strcmp(e->type, type) == 0) {
char key[EDGE_KEY_BUF];
make_edge_key(key, sizeof(key), e->source_id, e->target_id, e->type);
make_edge_key(key, sizeof(key), e->source_id, e->target_id, e->type,
e->properties_json);
const char *ekey = cbm_ht_get_key(gb->edge_by_key, key);
cbm_ht_delete(gb->edge_by_key, key);
free((void *)ekey);
@@ -1182,15 +1231,16 @@ int cbm_gbuf_merge(cbm_gbuf_t *dst, cbm_gbuf_t *src) {
/* ── Dump / Flush ────────────────────────────────────────────────── */
/* Extract url_path value from a properties JSON string.
/* Extract a string property from a properties JSON string.
* Returns heap-allocated string or NULL. Caller must free.
* Parses real JSON: the dump writer feeds this value into idx_edges_url_path,
* whose backing column is GENERATED AS json_extract(properties,'$.url_path').
* Parses real JSON: the dump writer feeds these values into indexes whose
* backing columns are GENERATED AS json_extract(properties,'$.<key>').
* Naive byte slicing returned the ESCAPED text (and cut at embedded \\")
* while json_extract yields the unescaped value — the mismatch left rows
* "missing from index idx_edges_url_path" under PRAGMA integrity_check. */
static char *extract_url_path(const char *props) {
if (!props || !strstr(props, "\"url_path\"")) {
* "missing from index idx_edges_url_path" under PRAGMA integrity_check.
* key_quoted ("\"key\"") is a fast pre-filter to skip the JSON parse. */
static char *extract_prop_string(const char *props, const char *key_quoted, const char *key) {
if (!props || !strstr(props, key_quoted)) {
return NULL;
}
yyjson_doc *doc = yyjson_read(props, strlen(props), 0);
@@ -1198,7 +1248,7 @@ static char *extract_url_path(const char *props) {
return NULL;
}
char *out = NULL;
yyjson_val *v = yyjson_obj_get(yyjson_doc_get_root(doc), "url_path");
yyjson_val *v = yyjson_obj_get(yyjson_doc_get_root(doc), key);
if (v && yyjson_is_str(v)) {
const char *sv = yyjson_get_str(v);
out = cbm_strndup(sv, strlen(sv));
@@ -1207,6 +1257,16 @@ static char *extract_url_path(const char *props) {
return out;
}
static char *extract_url_path(const char *props) {
return extract_prop_string(props, "\"url_path\"", "url_path");
}
/* local_name feeds the hand-built sqlite_autoindex_edges_1 — its backing
* column local_name_gen is GENERATED only for IMPORTS edges (#768). */
static char *extract_local_name(const char *props) {
return extract_prop_string(props, "\"local_name\"", "local_name");
}
/* Remap a temp edge ID to its final sequential ID, or 0 if out of range. */
static int64_t remap_id(const int64_t *temp_to_final, int64_t max_temp_id, int64_t temp_id) {
return (temp_id < max_temp_id) ? temp_to_final[temp_id] : 0;
@@ -1265,9 +1325,11 @@ static CBMDumpNode *build_dump_nodes(cbm_gbuf_t *gb, int live_count, int64_t *te
return dump_nodes;
}
/* Build dump-ready edge array with remapped IDs. Returns url_paths via out param. */
/* Build dump-ready edge array with remapped IDs. Returns url_paths and
* local_names (heap string arrays owned by the caller) via out params. */
static CBMDumpEdge *build_dump_edges(cbm_gbuf_t *gb, const int64_t *temp_to_final,
int64_t max_temp_id, int *out_count, char ***out_url_paths) {
int64_t max_temp_id, int *out_count, char ***out_url_paths,
char ***out_local_names) {
/* Count valid edges (both endpoints resolved) */
int valid_edges = 0;
for (int i = 0; i < gb->edges.count; i++) {
@@ -1281,6 +1343,7 @@ static CBMDumpEdge *build_dump_edges(cbm_gbuf_t *gb, const int64_t *temp_to_fina
CBMDumpEdge *dump_edges =
malloc((size_t)(valid_edges > 0 ? valid_edges : SKIP_ONE) * sizeof(CBMDumpEdge));
char **url_paths = calloc((size_t)(valid_edges > 0 ? valid_edges : SKIP_ONE), sizeof(char *));
char **local_names = calloc((size_t)(valid_edges > 0 ? valid_edges : SKIP_ONE), sizeof(char *));
int idx = 0;
for (int i = 0; i < gb->edges.count; i++) {
@@ -1294,6 +1357,12 @@ static CBMDumpEdge *build_dump_edges(cbm_gbuf_t *gb, const int64_t *temp_to_fina
char *url_path = extract_url_path(e->properties_json);
url_paths[idx] = url_path;
/* IMPORTS only — mirrors the local_name_gen CASE in the edges DDL. */
char *local_name = (e->type && strcmp(e->type, "IMPORTS") == 0)
? extract_local_name(e->properties_json)
: NULL;
local_names[idx] = local_name;
const char *props = e->properties_json ? e->properties_json : "{}";
dump_edges[idx] = (CBMDumpEdge){
.id = idx + SKIP_ONE,
@@ -1303,12 +1372,14 @@ static CBMDumpEdge *build_dump_edges(cbm_gbuf_t *gb, const int64_t *temp_to_fina
.type = e->type,
.properties = props,
.url_path = url_path ? url_path : "",
.local_name = local_name ? local_name : "",
};
idx++;
}
*out_count = idx;
*out_url_paths = url_paths;
*out_local_names = local_names;
return dump_edges;
}
@@ -1360,12 +1431,15 @@ static void log_dump_summary(int node_count, int edge_count) {
cbm_log_info("gbuf.dump", "nodes", b1, "edges", b2);
}
static void free_dump_resources(char **url_paths, int edge_count, CBMDumpEdge *dump_edges,
CBMDumpNode *dump_nodes, int64_t *temp_to_final) {
static void free_dump_resources(char **url_paths, char **local_names, int edge_count,
CBMDumpEdge *dump_edges, CBMDumpNode *dump_nodes,
int64_t *temp_to_final) {
for (int i = 0; i < edge_count; i++) {
free(url_paths[i]);
free(local_names[i]);
}
free(url_paths);
free(local_names);
free(dump_edges);
free(dump_nodes);
free(temp_to_final);
@@ -1469,10 +1543,12 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) {
int edge_idx = 0;
char **url_paths = NULL;
char **local_names = NULL;
CBMDumpEdge *dump_edges = NULL;
if (rc == 0) {
CBM_PROF_START(t_build_edges);
dump_edges = build_dump_edges(gb, temp_to_final, max_temp_id, &edge_idx, &url_paths);
dump_edges =
build_dump_edges(gb, temp_to_final, max_temp_id, &edge_idx, &url_paths, &local_names);
CBM_PROF_END_N("dump", "3_build_dump_edges", t_build_edges, edge_idx);
release_and_remap_vectors(gb, temp_to_final, max_temp_id);
}
@@ -1489,7 +1565,7 @@ int cbm_gbuf_dump_to_sqlite(cbm_gbuf_t *gb, const char *path) {
}
log_dump_summary(node_idx, edge_idx);
free_dump_resources(url_paths, edge_idx, dump_edges, dump_nodes, temp_to_final);
free_dump_resources(url_paths, local_names, edge_idx, dump_edges, dump_nodes, temp_to_final);
free(src_nodes);
return rc;
}
+5 -2
View File
@@ -12,8 +12,11 @@
#include <stdbool.h>
/* Schema version — increment when DB schema changes (new tables/indexes).
* Import refuses artifacts with schema_version > current. */
#define CBM_ARTIFACT_SCHEMA_VERSION 1
* Import refuses artifacts with schema_version > current.
* v2: edges uniqueness widened to (source_id, target_id, type,
* local_name_gen) so sibling named imports coexist (#768) — old
* binaries cannot upsert against the widened constraint. */
#define CBM_ARTIFACT_SCHEMA_VERSION 2
#define CBM_ARTIFACT_FILENAME "graph.db.zst"
#define CBM_ARTIFACT_META "artifact.json"
+34 -2
View File
@@ -243,6 +243,14 @@ static int init_schema(cbm_store_t *s) {
" 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,"
@@ -251,7 +259,9 @@ static int init_schema(cbm_store_t *s) {
" type TEXT NOT NULL,"
" properties TEXT DEFAULT '{}',"
" url_path_gen TEXT GENERATED ALWAYS AS (json_extract(properties,'$.url_path')),"
" UNIQUE(source_id, target_id, type)"
" 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,"
@@ -266,6 +276,25 @@ static int init_schema(cbm_store_t *s) {
return rc;
}
/* Schema-compat probe (#768): DBs created before the local_name_gen
* discriminator still enforce UNIQUE(source_id,target_id,type) and lack
* the column — the widened upsert in cbm_store_insert_edge can neither
* prepare nor let two named imports coexist against them. SQLite cannot
* ALTER a table-level UNIQUE constraint in place, so fail the open:
* callers already treat an unopenable DB as incompatible (a full index
* deletes + rebuilds it, artifact import refuses and falls back to a
* reindex). Read-only query opens skip init_schema and keep working. */
{
sqlite3_stmt *probe = NULL;
if (sqlite3_prepare_v2(s->db, "SELECT local_name_gen FROM edges LIMIT 0;", CBM_NOT_FOUND,
&probe, NULL) != SQLITE_OK) {
cbm_log_warn("store.schema", "result", "incompatible", "missing",
"edges.local_name_gen");
return CBM_STORE_ERR;
}
sqlite3_finalize(probe);
}
/* FTS5 contentless virtual table for BM25 full-text search.
* Contentless (content='') means FTS5 stores only the inverted index,
* not a copy of the source text — required for camelCase tokenization
@@ -1479,11 +1508,14 @@ int cbm_store_upsert_node_batch(cbm_store_t *s, const cbm_node_t *nodes, int cou
/* ── 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) DO UPDATE SET "
"ON CONFLICT(source_id, target_id, type, local_name_gen) DO UPDATE SET "
"properties = json_patch(properties, ?5) "
"RETURNING id;");
if (!stmt) {
+86 -3
View File
@@ -7,6 +7,7 @@
#include "test_framework.h"
#include "graph_buffer/graph_buffer.h"
#include "store/store.h"
#include <string.h>
/* ── Node operations ───────────────────────────────────────────── */
@@ -173,6 +174,85 @@ TEST(gbuf_edge_dedup) {
PASS();
}
/* #768: two named imports from the same specifier (same source, same target
* file) must produce two distinct IMPORTS edges, keyed apart by local_name --
* not collapse into one edge that silently drops whichever import lost the
* dedup race. Re-inserting the SAME local_name (e.g. an idempotent re-index)
* must still dedup to one edge. */
TEST(gbuf_imports_multi_symbol_dedup) {
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp");
int64_t consumer =
cbm_gbuf_upsert_node(gb, "File", "consumer.ts", "pkg.consumer", "consumer.ts", 1, 1, "{}");
int64_t lib = cbm_gbuf_upsert_node(gb, "File", "lib.ts", "pkg.lib", "lib.ts", 1, 1, "{}");
int64_t eid_a = cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", "{\"local_name\":\"A\"}");
int64_t eid_b = cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", "{\"local_name\":\"B\"}");
ASSERT_GT(eid_a, 0);
ASSERT_GT(eid_b, 0);
ASSERT_NEQ(eid_a, eid_b); /* distinct symbols -> distinct edges */
ASSERT_EQ(cbm_gbuf_edge_count(gb), 2);
const cbm_gbuf_edge_t **edges = NULL;
int count = 0;
cbm_gbuf_find_edges_by_source_type(gb, consumer, "IMPORTS", &edges, &count);
ASSERT_EQ(count, 2);
ASSERT_TRUE(strstr(edges[0]->properties_json, "\"local_name\":\"A\"") != NULL ||
strstr(edges[1]->properties_json, "\"local_name\":\"A\"") != NULL);
ASSERT_TRUE(strstr(edges[0]->properties_json, "\"local_name\":\"B\"") != NULL ||
strstr(edges[1]->properties_json, "\"local_name\":\"B\"") != NULL);
/* Re-inserting the same symbol (idempotent re-index) still dedups. */
int64_t eid_a_again =
cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", "{\"local_name\":\"A\"}");
ASSERT_EQ(eid_a_again, eid_a);
ASSERT_EQ(cbm_gbuf_edge_count(gb), 2);
cbm_gbuf_free(gb);
PASS();
}
/* #768 hardening: the dedup key lives in a fixed-size stack buffer. Two long
* local_names sharing a prefix must NOT silently collide when the verbatim
* key would be truncated the key builder re-keys oversized local_names with
* a hash of the FULL name. Determinism must hold: re-inserting the same long
* name still dedups to the same edge. */
TEST(gbuf_imports_long_local_name_no_collision) {
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp");
int64_t consumer =
cbm_gbuf_upsert_node(gb, "File", "consumer.ts", "pkg.consumer", "consumer.ts", 1, 1, "{}");
int64_t lib = cbm_gbuf_upsert_node(gb, "File", "lib.ts", "pkg.lib", "lib.ts", 1, 1, "{}");
/* 300-char shared prefix, distinct 4-char tails — a truncated verbatim
* key keeps only the shared prefix and would merge the two edges. */
enum { LONG_PREFIX = 300 };
char name_a[LONG_PREFIX + 8];
char name_b[LONG_PREFIX + 8];
memset(name_a, 'x', LONG_PREFIX);
memset(name_b, 'x', LONG_PREFIX);
memcpy(name_a + LONG_PREFIX, "AAAA", 5);
memcpy(name_b + LONG_PREFIX, "BBBB", 5);
char props_a[512];
char props_b[512];
snprintf(props_a, sizeof(props_a), "{\"local_name\":\"%s\"}", name_a);
snprintf(props_b, sizeof(props_b), "{\"local_name\":\"%s\"}", name_b);
int64_t eid_a = cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", props_a);
int64_t eid_b = cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", props_b);
ASSERT_GT(eid_a, 0);
ASSERT_GT(eid_b, 0);
ASSERT_NEQ(eid_a, eid_b); /* prefix-sharing long names stay distinct */
ASSERT_EQ(cbm_gbuf_edge_count(gb), 2);
/* Hash re-keying is deterministic: same long name dedups. */
int64_t eid_a_again = cbm_gbuf_insert_edge(gb, consumer, lib, "IMPORTS", props_a);
ASSERT_EQ(eid_a_again, eid_a);
ASSERT_EQ(cbm_gbuf_edge_count(gb), 2);
cbm_gbuf_free(gb);
PASS();
}
TEST(gbuf_find_edges_by_source_type) {
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp");
int64_t a = cbm_gbuf_upsert_node(gb, "Function", "a", "pkg.a", "f.go", 1, 5, "{}");
@@ -367,9 +447,9 @@ TEST(gbuf_upsert_empty_qn) {
TEST(gbuf_upsert_same_qn_updates_all_fields) {
cbm_gbuf_t *gb = cbm_gbuf_new("test", "/tmp");
int64_t id1 = cbm_gbuf_upsert_node(gb, "Function", "old_name", "pkg.fn", "old.go", 1, 10,
"{\"k\":\"v1\"}");
"{\"k\":\"v1\"}");
int64_t id2 = cbm_gbuf_upsert_node(gb, "Method", "new_name", "pkg.fn", "new.go", 20, 30,
"{\"k\":\"v2\"}");
"{\"k\":\"v2\"}");
ASSERT_EQ(id1, id2);
ASSERT_EQ(cbm_gbuf_node_count(gb), 1);
@@ -637,7 +717,8 @@ TEST(gbuf_merge_overlapping_qns) {
cbm_gbuf_t *src = cbm_gbuf_new("test", "/tmp");
/* dst has node with QN "pkg.fn" */
cbm_gbuf_upsert_node(dst, "Function", "fn_old", "pkg.fn", "old.go", 1, 10, "{\"from\":\"dst\"}");
cbm_gbuf_upsert_node(dst, "Function", "fn_old", "pkg.fn", "old.go", 1, 10,
"{\"from\":\"dst\"}");
cbm_gbuf_upsert_node(dst, "Function", "unique_dst", "pkg.unique_dst", "u.go", 1, 5, "{}");
/* src has same QN with different fields — src should win */
@@ -941,6 +1022,8 @@ SUITE(graph_buffer) {
RUN_TEST(gbuf_delete_by_label);
RUN_TEST(gbuf_insert_edge);
RUN_TEST(gbuf_edge_dedup);
RUN_TEST(gbuf_imports_multi_symbol_dedup);
RUN_TEST(gbuf_imports_long_local_name_no_collision);
RUN_TEST(gbuf_find_edges_by_source_type);
RUN_TEST(gbuf_find_edges_by_target_type);
RUN_TEST(gbuf_find_edges_by_type);
+57
View File
@@ -23,6 +23,7 @@
#include <unistd.h>
#include "graph_buffer/graph_buffer.h"
#include "yyjson/yyjson.h"
#include "sqlite3.h" /* vendored/sqlite3 — PRAGMA integrity_check on dumped DBs */
/* ── Helper: create temp test repo with known layout ───────────── */
@@ -1693,6 +1694,61 @@ TEST(pipeline_python_project) {
PASS();
}
/* #768 end-to-end: `import { A, B } from './lib'` must survive the REAL
* pipeline (extract -> gbuf dedup -> raw SQLite dump) as TWO IMPORTS edges
* with distinct local_name and the dumped DB must satisfy its own schema.
* With only the graph-buffer half of the fix, both edges reach the dump but
* violate an unwidened UNIQUE(source_id,target_id,type), which PRAGMA
* integrity_check flags as a non-unique autoindex entry. */
TEST(pipeline_imports_multi_symbol_edges) {
const char *files[] = {"consumer.ts", "lib.ts"};
const char *contents[] = {
"import { A, B } from './lib';\n\nexport function useBoth() {\n return A() + B();\n}\n",
"export function A() {\n return 1;\n}\nexport function B() {\n return 2;\n}\n"};
if (setup_lang_repo(files, contents, 2) != 0)
FAIL("tmpdir");
char db[512];
snprintf(db, sizeof(db), "%s/test.db", g_lang_tmpdir);
cbm_pipeline_t *p = cbm_pipeline_new(g_lang_tmpdir, db, CBM_MODE_FULL);
ASSERT_NOT_NULL(p);
ASSERT_EQ(cbm_pipeline_run(p), 0);
/* The dumped DB must pass SQLite's own full integrity check — this is
* what catches a buffer-only fix shipping DBs that violate their own
* UNIQUE constraint. */
sqlite3 *raw = NULL;
ASSERT_EQ(sqlite3_open(db, &raw), SQLITE_OK);
sqlite3_stmt *stmt = NULL;
sqlite3_prepare_v2(raw, "PRAGMA integrity_check", -1, &stmt, NULL);
ASSERT_EQ(sqlite3_step(stmt), SQLITE_ROW);
const char *integrity = (const char *)sqlite3_column_text(stmt, 0);
ASSERT_STR_EQ(integrity, "ok");
sqlite3_finalize(stmt);
sqlite3_close(raw);
/* Both named imports must be queryable as separate IMPORTS edges. */
cbm_store_t *s = cbm_store_open_path(db);
ASSERT_NOT_NULL(s);
const char *proj = cbm_pipeline_project_name(p);
cbm_edge_t *edges = NULL;
int count = 0;
ASSERT_EQ(cbm_store_find_edges_by_type(s, proj, "IMPORTS", &edges, &count), CBM_STORE_OK);
ASSERT_EQ(count, 2);
ASSERT_TRUE(strstr(edges[0].properties_json, "\"local_name\":\"A\"") != NULL ||
strstr(edges[1].properties_json, "\"local_name\":\"A\"") != NULL);
ASSERT_TRUE(strstr(edges[0].properties_json, "\"local_name\":\"B\"") != NULL ||
strstr(edges[1].properties_json, "\"local_name\":\"B\"") != NULL);
cbm_store_free_edges(edges, count);
cbm_store_close(s);
cbm_pipeline_free(p);
teardown_lang_repo();
PASS();
}
TEST(pipeline_go_cross_package_call) {
/* Port of TestGoCrossPackageCallViaImport */
const char *files[] = {"main.go", "svc/handler.go"};
@@ -6197,6 +6253,7 @@ SUITE(pipeline) {
RUN_TEST(usages_kotlin_no_duplicate_calls);
/* Language integration tests */
RUN_TEST(pipeline_python_project);
RUN_TEST(pipeline_imports_multi_symbol_edges);
RUN_TEST(pipeline_go_cross_package_call);
RUN_TEST(pipeline_python_cross_module_call);
RUN_TEST(pipeline_go_type_classification);
+91
View File
@@ -121,6 +121,96 @@ TEST(sw_minimal_data) {
PASS();
}
/* #768 schema half: two IMPORTS edges between the same two files, keyed apart
* by local_name, must be representable in the dumped DB. The dump's edges DDL
* and the hand-built sqlite_autoindex_edges_1 must carry the local_name
* discriminator with the old UNIQUE(source_id,target_id,type) the two rows
* violate the dump's own constraint and PRAGMA integrity_check flags a
* non-unique autoindex entry. */
TEST(sw_imports_local_name_unique) {
char path[256];
ASSERT_EQ(make_temp_db(path, sizeof(path)), 0);
CBMDumpNode nodes[2] = {
{.id = 1,
.project = "test",
.label = "File",
.name = "consumer.ts",
.qualified_name = "test.consumer.ts.__file__",
.file_path = "consumer.ts",
.start_line = 1,
.end_line = 3,
.properties = "{}"},
{.id = 2,
.project = "test",
.label = "File",
.name = "lib.ts",
.qualified_name = "test.lib.ts.__file__",
.file_path = "lib.ts",
.start_line = 1,
.end_line = 2,
.properties = "{}"},
};
CBMDumpEdge edges[2] = {
{.id = 1,
.project = "test",
.source_id = 1,
.target_id = 2,
.type = "IMPORTS",
.properties = "{\"local_name\":\"A\"}",
.url_path = "",
.local_name = "A"},
{.id = 2,
.project = "test",
.source_id = 1,
.target_id = 2,
.type = "IMPORTS",
.properties = "{\"local_name\":\"B\"}",
.url_path = "",
.local_name = "B"},
};
int rc = cbm_write_db(path, "test", "/tmp/test", "2026-03-14T00:00:00Z", nodes, 2, edges, 2,
NULL, 0, NULL, 0);
ASSERT_EQ(rc, 0);
sqlite3 *db = NULL;
rc = sqlite3_open(path, &db);
ASSERT_EQ(rc, SQLITE_OK);
/* The dumped DB must satisfy its own UNIQUE constraint. */
sqlite3_stmt *stmt = NULL;
sqlite3_prepare_v2(db, "PRAGMA integrity_check", -1, &stmt, NULL);
rc = sqlite3_step(stmt);
ASSERT_EQ(rc, SQLITE_ROW);
const char *integrity = (const char *)sqlite3_column_text(stmt, 0);
ASSERT_STR_EQ(integrity, "ok");
sqlite3_finalize(stmt);
/* Both sibling imports are queryable. */
sqlite3_prepare_v2(db,
"SELECT COUNT(*) FROM edges "
"WHERE source_id=1 AND target_id=2 AND type='IMPORTS'",
-1, &stmt, NULL);
sqlite3_step(stmt);
ASSERT_EQ(sqlite3_column_int(stmt, 0), 2);
sqlite3_finalize(stmt);
/* The generated discriminator matches what the writer hand-indexed. */
sqlite3_prepare_v2(db, "SELECT local_name_gen FROM edges ORDER BY id", -1, &stmt, NULL);
rc = sqlite3_step(stmt);
ASSERT_EQ(rc, SQLITE_ROW);
ASSERT_STR_EQ((const char *)sqlite3_column_text(stmt, 0), "A");
rc = sqlite3_step(stmt);
ASSERT_EQ(rc, SQLITE_ROW);
ASSERT_STR_EQ((const char *)sqlite3_column_text(stmt, 0), "B");
sqlite3_finalize(stmt);
sqlite3_close(db);
unlink(path);
PASS();
}
TEST(sw_scale_and_indexes) {
char path[256];
ASSERT_EQ(make_temp_db(path, sizeof(path)), 0);
@@ -519,6 +609,7 @@ TEST(sw_oversized_node) {
SUITE(sqlite_writer) {
RUN_TEST(sw_minimal_data);
RUN_TEST(sw_imports_local_name_unique);
RUN_TEST(sw_scale_and_indexes);
RUN_TEST(sw_long_index_keys_overflow);
RUN_TEST(sw_empty);
+65 -8
View File
@@ -78,6 +78,67 @@ TEST(store_edge_dedup) {
PASS();
}
/* #768 schema half: the edges UNIQUE constraint must discriminate IMPORTS
* edges by local_name so two named imports from the same specifier coexist
* as two rows, while re-inserting the SAME (src,tgt,IMPORTS,local_name)
* still upserts onto one row. Non-IMPORTS uniqueness is unchanged. */
TEST(store_imports_edge_local_name_coexist) {
int64_t ids[2];
cbm_store_t *s = setup_store_with_nodes(2, ids);
cbm_edge_t ea = {.project = "test",
.source_id = ids[0],
.target_id = ids[1],
.type = "IMPORTS",
.properties_json = "{\"local_name\":\"A\"}"};
cbm_edge_t eb = {.project = "test",
.source_id = ids[0],
.target_id = ids[1],
.type = "IMPORTS",
.properties_json = "{\"local_name\":\"B\"}"};
int64_t ida = cbm_store_insert_edge(s, &ea);
int64_t idb = cbm_store_insert_edge(s, &eb);
ASSERT_GT(ida, 0);
ASSERT_GT(idb, 0);
ASSERT_NEQ(ida, idb); /* distinct local_name -> distinct rows */
/* Same (src,tgt,IMPORTS,local_name) again -> upsert onto the same row. */
int64_t ida_again = cbm_store_insert_edge(s, &ea);
ASSERT_EQ(ida_again, ida);
cbm_edge_t *edges = NULL;
int count = 0;
int rc = cbm_store_find_edges_by_type(s, "test", "IMPORTS", &edges, &count);
ASSERT_EQ(rc, CBM_STORE_OK);
ASSERT_EQ(count, 2);
ASSERT_TRUE(strstr(edges[0].properties_json, "\"local_name\":\"A\"") != NULL ||
strstr(edges[1].properties_json, "\"local_name\":\"A\"") != NULL);
ASSERT_TRUE(strstr(edges[0].properties_json, "\"local_name\":\"B\"") != NULL ||
strstr(edges[1].properties_json, "\"local_name\":\"B\"") != NULL);
cbm_store_free_edges(edges, count);
/* Non-IMPORTS dedup is unchanged: differing properties (no local_name)
* must still upsert onto one row, not fan out via a NULL discriminator. */
cbm_edge_t c1 = {.project = "test",
.source_id = ids[0],
.target_id = ids[1],
.type = "CALLS",
.properties_json = "{}"};
cbm_edge_t c2 = {.project = "test",
.source_id = ids[0],
.target_id = ids[1],
.type = "CALLS",
.properties_json = "{\"weight\":5}"};
int64_t idc1 = cbm_store_insert_edge(s, &c1);
int64_t idc2 = cbm_store_insert_edge(s, &c2);
ASSERT_GT(idc1, 0);
ASSERT_EQ(idc1, idc2);
cbm_store_close(s);
PASS();
}
TEST(store_edge_find_by_source_type) {
int64_t ids[3];
cbm_store_t *s = setup_store_with_nodes(3, ids);
@@ -478,10 +539,8 @@ TEST(store_edge_delete_by_project_preserves_others) {
int64_t idC = cbm_store_upsert_node(s, &nc);
int64_t idD = cbm_store_upsert_node(s, &nd);
cbm_edge_t e1 = {
.project = "alpha", .source_id = idA, .target_id = idB, .type = "CALLS"};
cbm_edge_t e2 = {
.project = "beta", .source_id = idC, .target_id = idD, .type = "CALLS"};
cbm_edge_t e1 = {.project = "alpha", .source_id = idA, .target_id = idB, .type = "CALLS"};
cbm_edge_t e2 = {.project = "beta", .source_id = idC, .target_id = idD, .type = "CALLS"};
cbm_store_insert_edge(s, &e1);
cbm_store_insert_edge(s, &e2);
@@ -538,10 +597,7 @@ TEST(store_edge_long_type_string) {
memset(long_type, 'X', 200);
long_type[200] = '\0';
cbm_edge_t e = {.project = "test",
.source_id = ids[0],
.target_id = ids[1],
.type = long_type};
cbm_edge_t e = {.project = "test", .source_id = ids[0], .target_id = ids[1], .type = long_type};
int64_t eid = cbm_store_insert_edge(s, &e);
ASSERT_GT(eid, 0);
@@ -581,6 +637,7 @@ TEST(store_edge_find_source_type_nonexistent) {
SUITE(store_edges) {
RUN_TEST(store_edge_insert_find);
RUN_TEST(store_edge_dedup);
RUN_TEST(store_imports_edge_local_name_coexist);
RUN_TEST(store_edge_find_by_source_type);
RUN_TEST(store_edge_find_by_target_type);
RUN_TEST(store_edge_find_by_type);