mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-03 08:03:38 +08:00
fix(pipeline): require tuple proof for structural delta preflight
Add an internal store query that returns exact inbound edge tuples and owner paths for file-delta preflight. Use it in the exact-delta planner so unowned structural CONTAINS_FILE edges are accepted only when the candidate regenerates the same source, target, and type tuple. Existing realistic full-pipeline structure still falls back when the descriptor omits that edge; live exact-delta routing remains unwired. Validation: git diff --check; bash scripts/check-source-safety.sh; make -j8 -f Makefile.cbm build/c/test-runner; CBM_ONLY_SUITE=store_nodes ./build/c/test-runner; CBM_ONLY_SUITE=pipeline ./build/c/test-runner. Signed-off-by: Andrew Hundt <ATHundt@gmail.com>
This commit is contained in:
@@ -637,30 +637,60 @@ static bool delta_path_in_batch(const char *path, const cbm_pipeline_file_delta_
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool delta_batch_contains_edge(const cbm_pipeline_file_delta_t *const *deltas,
|
||||
int delta_count, const char *source_qn,
|
||||
const char *target_qn, const char *type) {
|
||||
if (!deltas || !source_qn || !target_qn || !type) {
|
||||
return false;
|
||||
}
|
||||
for (int i = 0; i < delta_count; i++) {
|
||||
const cbm_pipeline_file_delta_t *delta = deltas[i];
|
||||
if (!delta) {
|
||||
continue;
|
||||
}
|
||||
for (int j = 0; j < delta->delta.edge_count; j++) {
|
||||
const cbm_store_delta_edge_t *edge = &delta->delta.edges[j];
|
||||
if (edge->source_qn && edge->target_qn && edge->type &&
|
||||
strcmp(edge->source_qn, source_qn) == 0 &&
|
||||
strcmp(edge->target_qn, target_qn) == 0 && strcmp(edge->type, type) == 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool delta_unowned_inbound_edge_is_regenerated(
|
||||
const cbm_store_inbound_edge_t *edge, const cbm_pipeline_file_delta_t *const *deltas,
|
||||
int delta_count) {
|
||||
return edge && edge->source_rel_path && edge->source_rel_path[0] == '\0' && edge->type &&
|
||||
strcmp(edge->type, "CONTAINS_FILE") == 0 &&
|
||||
delta_batch_contains_edge(deltas, delta_count, edge->source_qn, edge->target_qn,
|
||||
edge->type);
|
||||
}
|
||||
|
||||
static bool delta_inbound_edges_supported(cbm_store_t *store,
|
||||
const cbm_pipeline_file_delta_t *delta,
|
||||
const cbm_pipeline_file_delta_t *const *deltas,
|
||||
int delta_count,
|
||||
cbm_pipeline_file_delta_plan_t *plan) {
|
||||
char **source_paths = NULL;
|
||||
int source_count = 0;
|
||||
int rc = cbm_store_list_file_delta_inbound_source_paths(
|
||||
store, delta->delta.project, delta->delta.rel_path, &source_paths, &source_count);
|
||||
cbm_store_inbound_edge_t *edges = NULL;
|
||||
int edge_count = 0;
|
||||
int rc = cbm_store_list_file_delta_inbound_edges(store, delta->delta.project,
|
||||
delta->delta.rel_path, &edges, &edge_count);
|
||||
if (rc != CBM_STORE_OK) {
|
||||
delta_plan_set_fallback(plan, cbm_delta_reason_preflight_error);
|
||||
return false;
|
||||
}
|
||||
bool ok = true;
|
||||
for (int i = 0; i < source_count; i++) {
|
||||
if (!delta_path_in_batch(source_paths[i], deltas, delta_count)) {
|
||||
for (int i = 0; i < edge_count; i++) {
|
||||
if (!delta_path_in_batch(edges[i].source_rel_path, deltas, delta_count) &&
|
||||
!delta_unowned_inbound_edge_is_regenerated(&edges[i], deltas, delta_count)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (int i = 0; i < source_count; i++) {
|
||||
free(source_paths[i]);
|
||||
}
|
||||
free(source_paths);
|
||||
cbm_store_free_inbound_edges(edges, edge_count);
|
||||
if (!ok) {
|
||||
delta_plan_set_fallback(plan, cbm_delta_reason_inbound_edges_require_full);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
#include <stdint.h>
|
||||
#include "foundation/constants.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <math.h>
|
||||
|
||||
enum {
|
||||
@@ -2343,6 +2344,117 @@ int cbm_store_list_file_delta_inbound_source_paths(cbm_store_t *s, const char *p
|
||||
return store_collect_text_column(s, stmt, "list_file_delta_inbound_source_paths", out, count);
|
||||
}
|
||||
|
||||
void cbm_store_free_inbound_edges(cbm_store_inbound_edge_t *edges, int count) {
|
||||
if (!edges) {
|
||||
return;
|
||||
}
|
||||
for (int i = 0; i < count; i++) {
|
||||
free(edges[i].source_qn);
|
||||
free(edges[i].target_qn);
|
||||
free(edges[i].type);
|
||||
free(edges[i].source_rel_path);
|
||||
free(edges[i].target_rel_path);
|
||||
}
|
||||
free(edges);
|
||||
}
|
||||
|
||||
static int store_inbound_edges_grow(cbm_store_inbound_edge_t **items, int *cap) {
|
||||
if (!items || !cap) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
if (*cap > INT_MAX / ST_GROWTH) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
int new_cap = (*cap > 0) ? *cap * ST_GROWTH : ST_INIT_CAP_8;
|
||||
cbm_store_inbound_edge_t *next = realloc(*items, (size_t)new_cap * sizeof(*next));
|
||||
if (!next) {
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
*items = next;
|
||||
*cap = new_cap;
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
int cbm_store_list_file_delta_inbound_edges(cbm_store_t *s, const char *project,
|
||||
const char *rel_path,
|
||||
cbm_store_inbound_edge_t **out, int *count) {
|
||||
if (out) {
|
||||
*out = NULL;
|
||||
}
|
||||
if (count) {
|
||||
*count = 0;
|
||||
}
|
||||
if (!s || !project || !rel_path || !out || !count) {
|
||||
if (s) {
|
||||
store_set_error(s, "list_file_delta_inbound_edges: invalid argument");
|
||||
}
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
|
||||
const char *sql =
|
||||
"SELECT src.qualified_name, tgt.qualified_name, e.type, "
|
||||
" COALESCE(src_owner.rel_path, ''), COALESCE(tgt_owner.rel_path, '') "
|
||||
"FROM edges e "
|
||||
"JOIN node_owners tgt_owner "
|
||||
" ON tgt_owner.project = ?1 AND tgt_owner.rel_path = ?2 "
|
||||
" AND tgt_owner.node_id = e.target_id "
|
||||
"JOIN nodes src ON src.id = e.source_id "
|
||||
"JOIN nodes tgt ON tgt.id = e.target_id "
|
||||
"LEFT JOIN node_owners src_owner "
|
||||
" ON src_owner.project = ?1 AND src_owner.node_id = e.source_id "
|
||||
"WHERE e.project = ?1 "
|
||||
" AND (src_owner.rel_path IS NULL OR src_owner.rel_path != ?2) "
|
||||
"ORDER BY src.qualified_name, tgt.qualified_name, e.type;";
|
||||
sqlite3_stmt *stmt = NULL;
|
||||
if (sqlite3_prepare_v2(s->db, sql, CBM_NOT_FOUND, &stmt, NULL) != SQLITE_OK) {
|
||||
store_set_error_sqlite(s, "list_file_delta_inbound_edges prepare");
|
||||
return CBM_STORE_ERR;
|
||||
}
|
||||
bind_text(stmt, ST_COL_1, project);
|
||||
bind_text(stmt, ST_COL_2, rel_path);
|
||||
|
||||
int cap = 0;
|
||||
int n = 0;
|
||||
int partial = 0;
|
||||
cbm_store_inbound_edge_t *items = NULL;
|
||||
int rc = CBM_STORE_OK;
|
||||
while ((rc = sqlite3_step(stmt)) == SQLITE_ROW) {
|
||||
if (n >= cap && store_inbound_edges_grow(&items, &cap) != CBM_STORE_OK) {
|
||||
rc = CBM_STORE_ERR;
|
||||
break;
|
||||
}
|
||||
cbm_store_inbound_edge_t *edge = &items[n];
|
||||
memset(edge, 0, sizeof(*edge));
|
||||
partial = 1;
|
||||
edge->source_qn = heap_strdup((const char *)sqlite3_column_text(stmt, 0));
|
||||
edge->target_qn = heap_strdup((const char *)sqlite3_column_text(stmt, 1));
|
||||
edge->type = heap_strdup((const char *)sqlite3_column_text(stmt, 2));
|
||||
edge->source_rel_path = heap_strdup((const char *)sqlite3_column_text(stmt, 3));
|
||||
edge->target_rel_path = heap_strdup((const char *)sqlite3_column_text(stmt, 4));
|
||||
if (!edge->source_qn || !edge->target_qn || !edge->type || !edge->source_rel_path ||
|
||||
!edge->target_rel_path) {
|
||||
rc = CBM_STORE_ERR;
|
||||
break;
|
||||
}
|
||||
n++;
|
||||
partial = 0;
|
||||
}
|
||||
if (rc != SQLITE_DONE && rc != CBM_STORE_ERR) {
|
||||
store_set_error_sqlite(s, "list_file_delta_inbound_edges");
|
||||
rc = CBM_STORE_ERR;
|
||||
} else if (rc == SQLITE_DONE) {
|
||||
rc = CBM_STORE_OK;
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
if (rc != CBM_STORE_OK) {
|
||||
cbm_store_free_inbound_edges(items, n + partial);
|
||||
return rc;
|
||||
}
|
||||
*out = items;
|
||||
*count = n;
|
||||
return CBM_STORE_OK;
|
||||
}
|
||||
|
||||
int cbm_store_upsert_symbol_export(cbm_store_t *s, const char *project,
|
||||
const char *qualified_name, const char *rel_path,
|
||||
int64_t node_id, int64_t generation) {
|
||||
|
||||
@@ -108,6 +108,14 @@ typedef struct {
|
||||
const char *derived_kind;
|
||||
} cbm_store_delta_edge_t;
|
||||
|
||||
typedef struct {
|
||||
char *source_qn;
|
||||
char *target_qn;
|
||||
char *type;
|
||||
char *source_rel_path; /* empty when source node has no owner metadata */
|
||||
char *target_rel_path;
|
||||
} cbm_store_inbound_edge_t;
|
||||
|
||||
typedef struct {
|
||||
const char *qualified_name;
|
||||
int64_t node_id; /* CBM_STORE_NO_NODE_ID resolves by qualified_name when present. */
|
||||
@@ -531,6 +539,13 @@ int cbm_store_list_file_delta_inbound_source_paths(cbm_store_t *s, const char *p
|
||||
const char *rel_path, char ***out,
|
||||
int *count);
|
||||
|
||||
/* Caller frees with cbm_store_free_inbound_edges(). */
|
||||
int cbm_store_list_file_delta_inbound_edges(cbm_store_t *s, const char *project,
|
||||
const char *rel_path,
|
||||
cbm_store_inbound_edge_t **out, int *count);
|
||||
|
||||
void cbm_store_free_inbound_edges(cbm_store_inbound_edge_t *edges, int count);
|
||||
|
||||
int cbm_store_upsert_symbol_export(cbm_store_t *s, const char *project,
|
||||
const char *qualified_name, const char *rel_path,
|
||||
int64_t node_id, int64_t generation);
|
||||
|
||||
@@ -4107,6 +4107,94 @@ TEST(pipeline_file_delta_plan_falls_back_on_full_pipeline_structure_edge) {
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pipeline_file_delta_plan_accepts_regenerated_structural_inbound_edge) {
|
||||
enum { PIPELINE_DELTA_TEST_BASE_GENERATION = 1 };
|
||||
const char *project = "test";
|
||||
const char *rel_path = "src/main.go";
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
ASSERT_EQ(cbm_store_upsert_project(s, project, "/tmp/test"), CBM_STORE_OK);
|
||||
|
||||
char *file_qn = cbm_pipeline_fqn_compute(project, rel_path, "__file__");
|
||||
char *folder_qn = cbm_pipeline_fqn_folder(project, "src");
|
||||
ASSERT_NOT_NULL(file_qn);
|
||||
ASSERT_NOT_NULL(folder_qn);
|
||||
|
||||
cbm_node_t old_file = {.project = (char *)project,
|
||||
.label = "File",
|
||||
.name = "main.go",
|
||||
.qualified_name = file_qn,
|
||||
.file_path = (char *)rel_path,
|
||||
.properties_json = "{}"};
|
||||
int64_t file_id = cbm_store_upsert_node(s, &old_file);
|
||||
ASSERT_GT(file_id, CBM_STORE_NO_NODE_ID);
|
||||
ASSERT_EQ(cbm_store_upsert_node_owner(s, project, file_id, rel_path,
|
||||
PIPELINE_DELTA_TEST_BASE_GENERATION),
|
||||
CBM_STORE_OK);
|
||||
cbm_file_state_t base_state = {.project = (char *)project,
|
||||
.rel_path = (char *)rel_path,
|
||||
.content_hash = "base-content",
|
||||
.git_oid = "",
|
||||
.mtime_ns = 1,
|
||||
.size = 10,
|
||||
.language = "go",
|
||||
.pass_fingerprint = "test-pass",
|
||||
.generation = PIPELINE_DELTA_TEST_BASE_GENERATION,
|
||||
.indexed_at = "2026-06-30T00:00:00Z"};
|
||||
ASSERT_EQ(cbm_store_upsert_file_state(s, &base_state), CBM_STORE_OK);
|
||||
|
||||
cbm_node_t folder = {.project = (char *)project,
|
||||
.label = "Folder",
|
||||
.name = "src",
|
||||
.qualified_name = folder_qn,
|
||||
.file_path = "src",
|
||||
.properties_json = "{}"};
|
||||
int64_t folder_id = cbm_store_upsert_node(s, &folder);
|
||||
ASSERT_GT(folder_id, CBM_STORE_NO_NODE_ID);
|
||||
cbm_edge_t contains = {.project = (char *)project,
|
||||
.source_id = folder_id,
|
||||
.target_id = file_id,
|
||||
.type = "CONTAINS_FILE",
|
||||
.properties_json = "{}"};
|
||||
int64_t edge_id = cbm_store_insert_edge(s, &contains);
|
||||
ASSERT_GT(edge_id, CBM_STORE_NO_NODE_ID);
|
||||
ASSERT_EQ(cbm_store_upsert_edge_owner(s, project, edge_id, rel_path, NULL,
|
||||
PIPELINE_DELTA_TEST_BASE_GENERATION),
|
||||
CBM_STORE_OK);
|
||||
|
||||
cbm_node_t new_file = {.project = (char *)project,
|
||||
.label = "File",
|
||||
.name = "main.go",
|
||||
.qualified_name = file_qn,
|
||||
.file_path = (char *)rel_path,
|
||||
.properties_json = "{}"};
|
||||
cbm_store_delta_edge_t regenerated_edge = {.source_qn = folder_qn,
|
||||
.target_qn = file_qn,
|
||||
.type = "CONTAINS_FILE",
|
||||
.properties_json = "{}"};
|
||||
cbm_pipeline_file_delta_t delta = {.delta = {.project = project,
|
||||
.rel_path = rel_path,
|
||||
.nodes = &new_file,
|
||||
.node_count = 1,
|
||||
.edges = ®enerated_edge,
|
||||
.edge_count = 1}};
|
||||
cbm_file_hash_t hash = {0};
|
||||
cbm_file_state_t state = {0};
|
||||
pipeline_delta_attach_test_metadata(&delta, &hash, &state);
|
||||
|
||||
cbm_pipeline_file_delta_plan_t plan = {0};
|
||||
ASSERT_EQ(cbm_pipeline_plan_file_delta(s, &delta, CBM_SZ_4, &plan), CBM_STORE_OK);
|
||||
ASSERT_EQ(plan.route, CBM_PIPELINE_DELTA_ROUTE_EXACT_CANDIDATE);
|
||||
ASSERT_STR_EQ(plan.reason, "candidate");
|
||||
ASSERT_EQ(pipeline_delta_plan_contains_path(&plan, rel_path), 1);
|
||||
|
||||
cbm_pipeline_file_delta_plan_free(&plan);
|
||||
free(folder_qn);
|
||||
free(file_qn);
|
||||
cbm_store_close(s);
|
||||
PASS();
|
||||
}
|
||||
|
||||
TEST(pipeline_file_delta_plan_falls_back_without_file_metadata) {
|
||||
cbm_store_t *s = cbm_store_open_memory();
|
||||
ASSERT_NOT_NULL(s);
|
||||
@@ -9109,6 +9197,7 @@ SUITE(pipeline) {
|
||||
RUN_TEST(pipeline_file_delta_plan_falls_back_on_external_inbound_edge);
|
||||
RUN_TEST(pipeline_file_delta_plan_falls_back_on_unowned_structural_inbound_edge);
|
||||
RUN_TEST(pipeline_file_delta_plan_falls_back_on_full_pipeline_structure_edge);
|
||||
RUN_TEST(pipeline_file_delta_plan_accepts_regenerated_structural_inbound_edge);
|
||||
RUN_TEST(pipeline_file_delta_plan_falls_back_without_file_metadata);
|
||||
RUN_TEST(pipeline_file_delta_plan_falls_back_on_unsupported_edges);
|
||||
RUN_TEST(pipeline_file_delta_plan_falls_back_on_delete);
|
||||
|
||||
@@ -1043,6 +1043,18 @@ TEST(store_rebuild_file_delta_owners_derives_from_graph) {
|
||||
CBM_STORE_OK);
|
||||
ASSERT_EQ(node_owners, 1);
|
||||
ASSERT_EQ(edge_owners, 1);
|
||||
cbm_store_inbound_edge_t *inbound = NULL;
|
||||
int inbound_count = 0;
|
||||
ASSERT_EQ(cbm_store_list_file_delta_inbound_edges(s, "test", "src/main.go", &inbound,
|
||||
&inbound_count),
|
||||
CBM_STORE_OK);
|
||||
ASSERT_EQ(inbound_count, 1);
|
||||
ASSERT_STR_EQ(inbound[0].source_qn, "test.src");
|
||||
ASSERT_STR_EQ(inbound[0].target_qn, "test.src.main.__file__");
|
||||
ASSERT_STR_EQ(inbound[0].type, "CONTAINS_FILE");
|
||||
ASSERT_STR_EQ(inbound[0].source_rel_path, "");
|
||||
ASSERT_STR_EQ(inbound[0].target_rel_path, "src/main.go");
|
||||
cbm_store_free_inbound_edges(inbound, inbound_count);
|
||||
ASSERT_EQ(store_count_metadata_owners(s, 0, "test", "stale.go"), 0);
|
||||
ASSERT_EQ(store_count_metadata_owners(s, 1, "test", "stale.go"), 0);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user