From e8c7d855aba584ab7fb4a7e72eafbbf6aa68373d Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Wed, 1 Jul 2026 00:04:37 -0400 Subject: [PATCH] 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 --- src/pipeline/pipeline_delta.c | 50 ++++++++++++--- src/store/store.c | 112 ++++++++++++++++++++++++++++++++++ src/store/store.h | 15 +++++ tests/test_pipeline.c | 89 +++++++++++++++++++++++++++ tests/test_store_nodes.c | 12 ++++ 5 files changed, 268 insertions(+), 10 deletions(-) diff --git a/src/pipeline/pipeline_delta.c b/src/pipeline/pipeline_delta.c index a5b366c43..9ad36baae 100644 --- a/src/pipeline/pipeline_delta.c +++ b/src/pipeline/pipeline_delta.c @@ -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); } diff --git a/src/store/store.c b/src/store/store.c index 855ae6761..52434cbc9 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -11,6 +11,7 @@ #include #include "foundation/constants.h" +#include #include 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) { diff --git a/src/store/store.h b/src/store/store.h index 71f86e536..3cbcc6b4a 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -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); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 111c883e8..299189d0c 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -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); diff --git a/tests/test_store_nodes.c b/tests/test_store_nodes.c index 975c67932..f919242de 100644 --- a/tests/test_store_nodes.c +++ b/tests/test_store_nodes.c @@ -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);