From 079b1be8929873367e3121fd20ffeb0201255aba Mon Sep 17 00:00:00 2001 From: Andrew Hundt Date: Tue, 30 Jun 2026 12:36:26 -0400 Subject: [PATCH] feat(pipeline): require metadata for delta planning Harden the internal exact-delta planner so candidates require file_hash and file_state metadata before any future publish route can be considered. Add explicit delete and rename fallback reasons because exact cleanup semantics for old paths, owner rows, reverse importers, and freshness state are not implemented yet. Add focused planner tests for metadata-required candidates, missing metadata fallback, delete fallback, and rename fallback. This does not change incremental routing, defaults, or publish behavior. Signed-off-by: Andrew Hundt --- src/pipeline/pipeline_delta.c | 29 +++++++++++ src/pipeline/pipeline_internal.h | 6 +++ tests/test_pipeline.c | 85 ++++++++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) diff --git a/src/pipeline/pipeline_delta.c b/src/pipeline/pipeline_delta.c index f19ee16c3..e67f592c5 100644 --- a/src/pipeline/pipeline_delta.c +++ b/src/pipeline/pipeline_delta.c @@ -10,9 +10,12 @@ static const char cbm_delta_edge_imports[] = "IMPORTS"; static const char cbm_delta_prop_is_exported[] = "is_exported"; static const char cbm_delta_reason_candidate[] = "candidate"; +static const char cbm_delta_reason_delete_requires_full[] = "delete_requires_full"; static const char cbm_delta_reason_frontier_error[] = "frontier_error"; static const char cbm_delta_reason_frontier_too_large[] = "frontier_too_large"; static const char cbm_delta_reason_invalid_input[] = "invalid_input"; +static const char cbm_delta_reason_missing_file_metadata[] = "missing_file_metadata"; +static const char cbm_delta_reason_rename_requires_full[] = "rename_requires_full"; static const char cbm_delta_reason_unsupported_edges[] = "unsupported_edges"; enum { CBM_DELTA_GROWTH = 2 }; @@ -248,6 +251,20 @@ static void delta_plan_set_fallback(cbm_pipeline_file_delta_plan_t *plan, const plan->reason = reason; } +static bool delta_field_matches(const char *actual, const char *expected) { + return actual && expected && strcmp(actual, expected) == 0; +} + +static bool delta_file_metadata_complete(const cbm_store_file_delta_t *delta) { + return delta && delta->file_hash && delta->file_state && + delta_field_matches(delta->file_hash->project, delta->project) && + delta_field_matches(delta->file_hash->rel_path, delta->rel_path) && + delta->file_hash->sha256 && + delta_field_matches(delta->file_state->project, delta->project) && + delta_field_matches(delta->file_state->rel_path, delta->rel_path) && + delta->file_state->content_hash && delta->file_state->indexed_at; +} + int cbm_pipeline_plan_file_delta(cbm_store_t *store, const cbm_pipeline_file_delta_t *delta, int max_affected_paths, cbm_pipeline_file_delta_plan_t *out) { if (!out) { @@ -259,10 +276,22 @@ int cbm_pipeline_plan_file_delta(cbm_store_t *store, const cbm_pipeline_file_del max_affected_paths <= 0) { return CBM_STORE_OK; } + if (delta->change_kind == CBM_PIPELINE_DELTA_CHANGE_DELETE) { + delta_plan_set_fallback(out, cbm_delta_reason_delete_requires_full); + return CBM_STORE_OK; + } + if (delta->change_kind == CBM_PIPELINE_DELTA_CHANGE_RENAME) { + delta_plan_set_fallback(out, cbm_delta_reason_rename_requires_full); + return CBM_STORE_OK; + } if (delta->unsupported_edge_count > 0) { delta_plan_set_fallback(out, cbm_delta_reason_unsupported_edges); return CBM_STORE_OK; } + if (!delta_file_metadata_complete(&delta->delta)) { + delta_plan_set_fallback(out, cbm_delta_reason_missing_file_metadata); + return CBM_STORE_OK; + } const char **new_export_qns = NULL; if (delta->delta.export_count > 0) { diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index 9bc633120..59cebcfae 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -118,6 +118,12 @@ typedef struct { cbm_store_symbol_export_t *exports; cbm_store_import_ref_t *imports; int unsupported_edge_count; + enum { + CBM_PIPELINE_DELTA_CHANGE_UPSERT = 0, + CBM_PIPELINE_DELTA_CHANGE_DELETE = 1, + CBM_PIPELINE_DELTA_CHANGE_RENAME = 2, + } change_kind; + const char *old_rel_path; /* borrowed; set for rename preflight only */ } cbm_pipeline_file_delta_t; typedef enum { diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 55dd23a00..ddab4fdcf 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -3170,6 +3170,30 @@ static int pipeline_delta_plan_contains_path(const cbm_pipeline_file_delta_plan_ return 0; } +static void pipeline_delta_attach_test_metadata(cbm_pipeline_file_delta_t *delta, + cbm_file_hash_t *hash, + cbm_file_state_t *state) { + enum { PIPELINE_DELTA_TEST_GENERATION = 1 }; + *hash = (cbm_file_hash_t){.project = delta->delta.project, + .rel_path = delta->delta.rel_path, + .sha256 = "test-hash", + .mtime_ns = 1, + .size = 10}; + *state = (cbm_file_state_t){.project = delta->delta.project, + .rel_path = delta->delta.rel_path, + .content_hash = "test-content", + .git_oid = "", + .mtime_ns = 1, + .size = 10, + .language = "go", + .pass_fingerprint = "test-pass", + .generation = PIPELINE_DELTA_TEST_GENERATION, + .indexed_at = "2026-06-30T00:00:00Z"}; + delta->delta.generation = PIPELINE_DELTA_TEST_GENERATION; + delta->delta.file_hash = hash; + delta->delta.file_state = state; +} + TEST(pipeline_file_delta_descriptor_from_gbuf) { cbm_gbuf_t *gb = cbm_gbuf_new("proj", "/tmp/proj"); ASSERT_NOT_NULL(gb); @@ -3250,6 +3274,9 @@ TEST(pipeline_file_delta_plan_candidate_from_frontier) { {.qualified_name = "test.lib.Value", .node_id = CBM_STORE_NO_NODE_ID}}; cbm_pipeline_file_delta_t delta = { .delta = {.project = "test", .rel_path = "lib.go", .exports = exports, .export_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); @@ -3263,6 +3290,23 @@ TEST(pipeline_file_delta_plan_candidate_from_frontier) { PASS(); } +TEST(pipeline_file_delta_plan_falls_back_without_file_metadata) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + ASSERT_EQ(cbm_store_upsert_project(s, "test", "/tmp/test"), CBM_STORE_OK); + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", .rel_path = "main.go"}}; + + 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_FALLBACK); + ASSERT_STR_EQ(plan.reason, "missing_file_metadata"); + ASSERT_EQ(plan.affected_count, 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} + TEST(pipeline_file_delta_plan_falls_back_on_unsupported_edges) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -3280,6 +3324,41 @@ TEST(pipeline_file_delta_plan_falls_back_on_unsupported_edges) { PASS(); } +TEST(pipeline_file_delta_plan_falls_back_on_delete) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", .rel_path = "gone.go"}, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_DELETE}; + + 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_FALLBACK); + ASSERT_STR_EQ(plan.reason, "delete_requires_full"); + ASSERT_EQ(plan.affected_count, 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} + +TEST(pipeline_file_delta_plan_falls_back_on_rename) { + cbm_store_t *s = cbm_store_open_memory(); + ASSERT_NOT_NULL(s); + cbm_pipeline_file_delta_t delta = {.delta = {.project = "test", .rel_path = "new.go"}, + .change_kind = CBM_PIPELINE_DELTA_CHANGE_RENAME, + .old_rel_path = "old.go"}; + + 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_FALLBACK); + ASSERT_STR_EQ(plan.reason, "rename_requires_full"); + ASSERT_EQ(plan.affected_count, 0); + + cbm_pipeline_file_delta_plan_free(&plan); + cbm_store_close(s); + PASS(); +} + TEST(pipeline_file_delta_plan_falls_back_on_large_frontier) { cbm_store_t *s = cbm_store_open_memory(); ASSERT_NOT_NULL(s); @@ -3298,6 +3377,9 @@ TEST(pipeline_file_delta_plan_falls_back_on_large_frontier) { {.qualified_name = "test.lib.Hot", .node_id = CBM_STORE_NO_NODE_ID}}; cbm_pipeline_file_delta_t delta = { .delta = {.project = "test", .rel_path = "lib.go", .exports = exports, .export_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_2, &plan), CBM_STORE_OK); @@ -7647,7 +7729,10 @@ SUITE(pipeline) { RUN_TEST(pipeline_file_delta_descriptor_from_gbuf); RUN_TEST(pipeline_file_delta_descriptor_marks_unsupported_edges); RUN_TEST(pipeline_file_delta_plan_candidate_from_frontier); + 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); + RUN_TEST(pipeline_file_delta_plan_falls_back_on_rename); RUN_TEST(pipeline_file_delta_plan_falls_back_on_large_frontier); /* File persistence */ RUN_TEST(store_file_persistence);