Files
codebase-memory-mcp/tests/test_graph_diff.h
T
Andrew Hundt ecc8e4d43b fix(graph): harden determinism and safety canaries
Stabilize several graph-producing paths and add stronger regression coverage before the incremental redesign work continues.

Key changes:

- resolve call import maps from resolved graph-buffer IMPORTS edges so re-exported targets are reused consistently

- make semantic, similarity, complexity, route/section upserts, and resolver scoring more deterministic across full/incremental and worker-order differences

- propagate incremental phase/persist failures instead of continuing toward publication after failed phases

- add canonical graph diff helpers and stricter incremental artifacts for full-vs-incremental comparison

- add source-safety lint coverage for MCP stdout, unsafe string APIs, and new raw env/fs API usage

- replace touched unsafe string copies with bounded or project-wrapper-based forms

Validation:

- make -f Makefile.cbm build/c/test-runner

- CBM_ONLY_SUITE=pipeline ./build/c/test-runner: 217 passed

- CBM_ONLY_SUITE=graph_buffer ./build/c/test-runner: 55 passed

- CBM_ONLY_SUITE=mcp ./build/c/test-runner: 117 passed

- CBM_ONLY_SUITE=simhash ./build/c/test-runner: 24 passed

- CBM_ONLY_SUITE=parallel ./build/c/test-runner: 21 passed

- CBM_ONLY_SUITE=watcher ./build/c/test-runner: 57 passed

- bash scripts/check-source-safety.sh: OK

Known remaining:

- CBM_ONLY_SUITE=incremental still fails incr_accuracy_vs_full: 159 passed, 1 failed. Current artifacts show stale IMPORTS/CALLS/USAGE targets in unchanged importers and downstream SEMANTICALLY_RELATED drift; this is tracked in the forensic notes as a fallback-first incremental redesign task.

Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
2026-06-30 00:38:07 -04:00

255 lines
9.7 KiB
C

/*
* test_graph_diff.h - Canonical graph comparison helpers for tests.
*
* These helpers compare graph facts by stable keys instead of transient row IDs.
* They are intentionally test-only so production store APIs stay unchanged.
*/
#ifndef TEST_GRAPH_DIFF_H
#define TEST_GRAPH_DIFF_H
#include <sqlite3.h>
#include "../src/foundation/compat.h"
#include "../src/foundation/constants.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
enum { TG_ROW_SET_INIT_CAP = CBM_SZ_128 };
typedef struct {
char **items;
int count;
int cap;
} tg_row_set_t;
static inline void tg_row_set_free(tg_row_set_t *rows) {
if (!rows) {
return;
}
for (int i = 0; i < rows->count; i++) {
free(rows->items[i]);
}
free(rows->items);
rows->items = NULL;
rows->count = 0;
rows->cap = 0;
}
static inline int tg_set_error(char *err, size_t err_sz, const char *msg) {
if (err && err_sz > 0) {
int n = snprintf(err, err_sz, "%s", msg ? msg : "graph diff failed");
if (n < 0 || (size_t)n >= err_sz) {
err[err_sz - 1] = '\0';
}
}
return CBM_NOT_FOUND;
}
static inline int tg_row_set_push(tg_row_set_t *rows, const char *row, char *err, size_t err_sz) {
if (rows->count == rows->cap) {
int next_cap = rows->cap ? rows->cap * PAIR_LEN : TG_ROW_SET_INIT_CAP;
char **next = (char **)realloc(rows->items, (size_t)next_cap * sizeof(*next));
if (!next) {
return tg_set_error(err, err_sz, "graph diff: out of memory growing row set");
}
rows->items = next;
rows->cap = next_cap;
}
rows->items[rows->count] = cbm_strdup(row ? row : "");
if (!rows->items[rows->count]) {
return tg_set_error(err, err_sz, "graph diff: out of memory copying row");
}
rows->count++;
return 0;
}
static inline int tg_collect_query(sqlite3 *db, const char *project, const char *sql,
tg_row_set_t *rows, char *err, size_t err_sz) {
sqlite3_stmt *stmt = NULL;
int rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
if (rc != SQLITE_OK) {
return tg_set_error(err, err_sz, sqlite3_errmsg(db));
}
rc = sqlite3_bind_text(stmt, 1, project, -1, SQLITE_TRANSIENT);
if (rc != SQLITE_OK) {
sqlite3_finalize(stmt);
return tg_set_error(err, err_sz, sqlite3_errmsg(db));
}
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
const unsigned char *txt = sqlite3_column_text(stmt, 0);
if (tg_row_set_push(rows, (const char *)txt, err, err_sz) != 0) {
sqlite3_finalize(stmt);
return CBM_NOT_FOUND;
}
}
sqlite3_finalize(stmt);
if (rc != SQLITE_DONE) {
return tg_set_error(err, err_sz, sqlite3_errmsg(db));
}
return 0;
}
static inline int tg_compare_rows(const char *kind, const tg_row_set_t *left,
const tg_row_set_t *right, char *err, size_t err_sz) {
if (left->count != right->count) {
if (err && err_sz > 0) {
int n = snprintf(err, err_sz, "%s count differs: left=%d right=%d", kind,
left->count, right->count);
if (n < 0 || (size_t)n >= err_sz) {
err[err_sz - 1] = '\0';
}
}
return CBM_NOT_FOUND;
}
int left_idx = 0;
int right_idx = 0;
while (left_idx < left->count && right_idx < right->count) {
int cmp = strcmp(left->items[left_idx], right->items[right_idx]);
if (cmp == 0) {
left_idx++;
right_idx++;
continue;
}
if (cmp < 0) {
if (err && err_sz > 0) {
int n = snprintf(err, err_sz,
"%s left-only row %d:\n left: %s\n right row %d: %s", kind,
left_idx, left->items[left_idx], right_idx,
right->items[right_idx]);
if (n < 0 || (size_t)n >= err_sz) {
err[err_sz - 1] = '\0';
}
}
return CBM_NOT_FOUND;
}
if (err && err_sz > 0) {
int n = snprintf(err, err_sz,
"%s right-only row %d:\n right: %s\n left row %d: %s", kind,
right_idx, right->items[right_idx], left_idx, left->items[left_idx]);
if (n < 0 || (size_t)n >= err_sz) {
err[err_sz - 1] = '\0';
}
}
return CBM_NOT_FOUND;
}
if (left_idx < left->count || right_idx < right->count) {
if (err && err_sz > 0) {
int n = snprintf(err, err_sz, "%s exhausted unevenly: left_row=%d right_row=%d", kind,
left_idx, right_idx);
if (n < 0 || (size_t)n >= err_sz) {
err[err_sz - 1] = '\0';
}
}
return CBM_NOT_FOUND;
}
return 0;
}
static inline int tg_compare_query(sqlite3 *left_db, sqlite3 *right_db, const char *project,
const char *kind, const char *sql, char *err,
size_t err_sz) {
tg_row_set_t left = {0};
tg_row_set_t right = {0};
int rc = tg_collect_query(left_db, project, sql, &left, err, err_sz);
if (rc == 0) {
rc = tg_collect_query(right_db, project, sql, &right, err, err_sz);
}
if (rc == 0) {
rc = tg_compare_rows(kind, &left, &right, err, err_sz);
}
tg_row_set_free(&left);
tg_row_set_free(&right);
return rc;
}
static inline int cbm_test_compare_canonical_graphs(const char *left_db_path,
const char *right_db_path,
const char *project, char *err,
size_t err_sz) {
static const char *nodes_sql =
"SELECT quote(label) || char(9) || quote(name) || char(9) || "
"quote(qualified_name) || char(9) || quote(coalesce(file_path,'')) || char(9) || "
"start_line || char(9) || end_line || char(9) || " /* properties below */
"COALESCE((SELECT group_concat(item, char(30)) FROM ("
"SELECT quote(je.key) || '=' || je.type || '=' || "
"COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item "
"FROM json_each(n.properties) AS je "
"ORDER BY je.key, je.type, CAST(je.value AS TEXT)"
")), '') "
"FROM nodes n WHERE project = ?1 "
"ORDER BY label, name, qualified_name, coalesce(file_path,''), start_line, end_line, "
"COALESCE((SELECT group_concat(item, char(30)) FROM ("
"SELECT quote(je.key) || '=' || je.type || '=' || "
"COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item "
"FROM json_each(n.properties) AS je "
"ORDER BY je.key, je.type, CAST(je.value AS TEXT)"
")), '');";
static const char *edges_sql =
"SELECT quote(s.label) || char(9) || quote(s.qualified_name) || char(9) || "
"quote(coalesce(s.file_path,'')) || char(9) || s.start_line || char(9) || "
"s.end_line || char(9) || quote(t.label) || char(9) || quote(t.qualified_name) || "
"char(9) || quote(coalesce(t.file_path,'')) || char(9) || t.start_line || char(9) || "
"t.end_line || char(9) || quote(e.type) || char(9) || "
"COALESCE((SELECT group_concat(item, char(30)) FROM ("
"SELECT quote(je.key) || '=' || je.type || '=' || "
"COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item "
"FROM json_each(e.properties) AS je "
"ORDER BY je.key, je.type, CAST(je.value AS TEXT)"
")), '') "
"FROM edges e "
"JOIN nodes s ON s.id = e.source_id "
"JOIN nodes t ON t.id = e.target_id "
"WHERE e.project = ?1 "
"ORDER BY s.label, s.qualified_name, coalesce(s.file_path,''), s.start_line, s.end_line, "
"t.label, t.qualified_name, coalesce(t.file_path,''), t.start_line, t.end_line, "
"e.type, COALESCE((SELECT group_concat(item, char(30)) FROM ("
"SELECT quote(je.key) || '=' || je.type || '=' || "
"COALESCE(quote(CAST(je.value AS TEXT)), 'NULL') AS item "
"FROM json_each(e.properties) AS je "
"ORDER BY je.key, je.type, CAST(je.value AS TEXT)"
")), '');";
static const char *hashes_sql =
"SELECT quote(rel_path) || char(9) || quote(sha256) || char(9) || mtime_ns || char(9) || "
"size FROM file_hashes WHERE project = ?1 ORDER BY rel_path;";
sqlite3 *left_db = NULL;
sqlite3 *right_db = NULL;
int rc = sqlite3_open_v2(left_db_path, &left_db, SQLITE_OPEN_READONLY, NULL);
if (rc != SQLITE_OK) {
if (left_db) {
tg_set_error(err, err_sz, sqlite3_errmsg(left_db));
sqlite3_close(left_db);
} else {
tg_set_error(err, err_sz, "graph diff: cannot open left DB");
}
return CBM_NOT_FOUND;
}
rc = sqlite3_open_v2(right_db_path, &right_db, SQLITE_OPEN_READONLY, NULL);
if (rc != SQLITE_OK) {
if (right_db) {
tg_set_error(err, err_sz, sqlite3_errmsg(right_db));
sqlite3_close(right_db);
} else {
tg_set_error(err, err_sz, "graph diff: cannot open right DB");
}
sqlite3_close(left_db);
return CBM_NOT_FOUND;
}
rc = tg_compare_query(left_db, right_db, project, "canonical nodes", nodes_sql, err, err_sz);
if (rc == 0) {
rc = tg_compare_query(left_db, right_db, project, "canonical edges", edges_sql, err, err_sz);
}
if (rc == 0) {
rc = tg_compare_query(left_db, right_db, project, "file hashes", hashes_sql, err, err_sz);
}
sqlite3_close(right_db);
sqlite3_close(left_db);
return rc;
}
#endif /* TEST_GRAPH_DIFF_H */