diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index fd80382dd..b72096c66 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -33,6 +33,7 @@ enum { CBM_DIR_PERMS = 0755, PL_RING = 4, PL_RING_MASK = 3, PL_SEQ_PASSES = 6, P #include "foundation/compat_thread.h" #include "foundation/profile.h" #include "foundation/mem.h" +#include "yyjson/yyjson.h" #include #include @@ -151,6 +152,64 @@ static const char *itoa_buf(int val) { return bufs[i]; } +static const char *index_mode_name(cbm_index_mode_t mode) { + switch (mode) { + case CBM_MODE_FULL: + return "full"; + case CBM_MODE_MODERATE: + return "moderate"; + case CBM_MODE_FAST: + return "fast"; + default: + return "unknown"; + } +} + +static bool parse_index_mode(const char *properties_json, cbm_index_mode_t *out_mode) { + if (!properties_json || !out_mode) { + return false; + } + + yyjson_doc *doc = yyjson_read(properties_json, strlen(properties_json), 0); + if (!doc) { + return false; + } + + yyjson_val *root = yyjson_doc_get_root(doc); + yyjson_val *mode_value = yyjson_is_obj(root) ? yyjson_obj_get(root, "index_mode") : NULL; + bool parsed = true; + if (yyjson_equals_str(mode_value, "full")) { + *out_mode = CBM_MODE_FULL; + } else if (yyjson_equals_str(mode_value, "moderate")) { + *out_mode = CBM_MODE_MODERATE; + } else if (yyjson_equals_str(mode_value, "fast")) { + *out_mode = CBM_MODE_FAST; + } else { + parsed = false; + } + + yyjson_doc_free(doc); + return parsed; +} + +static bool index_mode_covers(cbm_index_mode_t stored_mode, cbm_index_mode_t requested_mode) { + return stored_mode >= CBM_MODE_FULL && stored_mode <= CBM_MODE_FAST && + requested_mode >= CBM_MODE_FULL && requested_mode <= CBM_MODE_FAST && + stored_mode <= requested_mode; +} + +static bool read_stored_index_mode(cbm_store_t *store, const char *project, + cbm_index_mode_t *out_mode) { + cbm_node_t project_node = {0}; + if (cbm_store_find_node_by_qn(store, project, project, &project_node) != CBM_STORE_OK) { + return false; + } + + bool parsed = parse_index_mode(project_node.properties_json, out_mode); + cbm_node_free_fields(&project_node); + return parsed; +} + /* Log current + peak RSS at a pipeline phase boundary (memory profiling). */ static void log_phase_mem(const char *phase) { enum { PL_BYTES_PER_MB = 1024 * 1024 }; @@ -446,7 +505,11 @@ static int pass_structure(cbm_pipeline_t *p, const cbm_file_info_t *files, int f cbm_log_info("pass.start", "pass", "structure", "files", itoa_buf(file_count)); /* Project node */ - cbm_gbuf_upsert_node(p->gbuf, "Project", p->project_name, p->project_name, NULL, 0, 0, "{}"); + char project_props[CBM_SZ_64]; + snprintf(project_props, sizeof(project_props), "{\"index_mode\":\"%s\"}", + index_mode_name(p->mode)); + cbm_gbuf_upsert_node(p->gbuf, "Project", p->project_name, p->project_name, NULL, 0, 0, + project_props); const char *branch_qn = p->branch_qn ? p->branch_qn : p->project_name; const char *branch_name = p->git_ctx.branch ? p->git_ctx.branch : "working-tree"; char branch_props[CBM_SZ_2K]; @@ -998,17 +1061,26 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file if (check_store && cbm_store_check_integrity(check_store)) { cbm_file_hash_t *hashes = NULL; int hash_count = 0; + cbm_index_mode_t stored_mode = CBM_MODE_FAST; + bool stored_mode_known = read_stored_index_mode(check_store, p->project_name, &stored_mode); cbm_store_get_file_hashes(check_store, p->project_name, &hashes, &hash_count); cbm_store_free_file_hashes(hashes, hash_count); cbm_store_close(check_store); - if (hash_count > 0 && file_count <= hash_count + (hash_count / PAIR_LEN)) { + bool mode_covered = stored_mode_known && index_mode_covers(stored_mode, p->mode); + if (hash_count > 0 && mode_covered && file_count <= hash_count + (hash_count / PAIR_LEN)) { cbm_log_info("pipeline.route", "path", "incremental", "stored_hashes", - itoa_buf(hash_count)); - int rc = cbm_pipeline_run_incremental(p, db_path, files, file_count); + itoa_buf(hash_count), "stored_mode", index_mode_name(stored_mode), + "requested_mode", index_mode_name(p->mode), "effective_mode", + index_mode_name(stored_mode)); + int rc = cbm_pipeline_run_incremental(p, db_path, files, file_count, stored_mode); free(db_path); return rc; } - if (hash_count > 0) { + if (hash_count > 0 && !mode_covered) { + cbm_log_info("pipeline.route", "path", "mode_upgrade_reindex", "stored_mode", + stored_mode_known ? index_mode_name(stored_mode) : "unknown", + "requested_mode", index_mode_name(p->mode)); + } else if (hash_count > 0) { cbm_log_info("pipeline.route", "path", "mode_change_reindex", "stored_hashes", itoa_buf(hash_count), "discovered", itoa_buf(file_count)); } diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index ee69fc9c2..b3f4dc226 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -684,11 +684,12 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char * /* ── Incremental pipeline entry point ────────────────────────────── */ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_file_info_t *files, - int file_count) { + int file_count, cbm_index_mode_t effective_mode) { struct timespec t0; cbm_clock_gettime(CLOCK_MONOTONIC, &t0); const char *project = cbm_pipeline_project_name(p); + cbm_index_mode_t requested_mode = (cbm_index_mode_t)cbm_pipeline_get_mode(p); /* Open existing disk DB */ cbm_store_t *store = cbm_store_open_path(db_path); @@ -839,7 +840,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil .registry = registry, .cancelled = cbm_pipeline_cancelled_ptr(p), .pipeline = p, /* so passes can record per-file skips (Track B) */ - .mode = cbm_pipeline_get_mode(p), + .mode = effective_mode, .path_aliases = path_aliases, .excluded_dirs = excluded_dirs, .excluded_count = excluded_count, @@ -854,7 +855,12 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil } } + /* Discovery already used requested_mode. Re-extraction must preserve the + * stronger capabilities recorded in Project.index_mode, including full-only + * C/C++ Macro nodes. Restore the process-wide gate immediately afterwards. */ + cbm_set_macro_extraction(effective_mode == CBM_MODE_FULL); run_extract_resolve(&ctx, changed_files, ci); + cbm_set_macro_extraction(requested_mode == CBM_MODE_FULL); cbm_pipeline_pass_k8s(&ctx, changed_files, ci); run_postpasses(&ctx, changed_files, ci, project); diff --git a/src/pipeline/pipeline_internal.h b/src/pipeline/pipeline_internal.h index a3d806558..b5735ee1b 100644 --- a/src/pipeline/pipeline_internal.h +++ b/src/pipeline/pipeline_internal.h @@ -607,9 +607,11 @@ int cbm_scan_project_env_urls_excluded(const char *root_path, cbm_env_binding_t /* Run incremental re-index on an existing disk DB. * Classifies files by mtime+size, deletes changed nodes, re-parses changed - * files, merges into disk DB. Returns 0 on success. */ + * files with effective_mode capabilities, then merges into disk DB. The + * pipeline's requested mode still owns discovery/exclusions. Returns 0 on + * success. */ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_file_info_t *files, - int file_count); + int file_count, cbm_index_mode_t effective_mode); /* Pipeline accessors for incremental use */ const char *cbm_pipeline_repo_path(const cbm_pipeline_t *p); diff --git a/tests/test_pipeline.c b/tests/test_pipeline.c index 1f2a7c48b..b1017b282 100644 --- a/tests/test_pipeline.c +++ b/tests/test_pipeline.c @@ -5761,6 +5761,364 @@ TEST(incremental_new_file_added) { PASS(); } +static bool project_has_index_mode(cbm_store_t *store, const char *project, + const char *expected_mode) { + cbm_node_t project_node = {0}; + if (cbm_store_find_node_by_qn(store, project, project, &project_node) != CBM_STORE_OK) { + return false; + } + + bool matches = false; + if (project_node.properties_json) { + yyjson_doc *doc = + yyjson_read(project_node.properties_json, strlen(project_node.properties_json), 0); + yyjson_val *root = doc ? yyjson_doc_get_root(doc) : NULL; + yyjson_val *mode = root ? yyjson_obj_get(root, "index_mode") : NULL; + matches = yyjson_equals_str(mode, expected_mode); + if (doc) { + yyjson_doc_free(doc); + } + } + + cbm_node_free_fields(&project_node); + return matches; +} + +static int project_count_nodes_by_label(cbm_store_t *store, const char *project, + const char *label) { + cbm_node_t *nodes = NULL; + int count = 0; + if (cbm_store_find_nodes_by_label(store, project, label, &nodes, &count) != CBM_STORE_OK) { + return -1; + } + cbm_store_free_nodes(nodes, count); + return count; +} + +static int setup_mode_upgrade_repo(char *tmpdir, size_t tmpdir_size, char *dbpath, + size_t dbpath_size) { + snprintf(tmpdir, tmpdir_size, "/tmp/cbm_mode_upgrade_XXXXXX"); + if (!cbm_mkdtemp(tmpdir)) { + return -1; + } + snprintf(dbpath, dbpath_size, "%s/test.db", tmpdir); + + if (th_write_file( + TH_PATH(tmpdir, "user_validator.go"), + "package validation\n" + "import \"errors\"\n" + "import \"strings\"\n" + "func ValidateUser(u User) error {\n" + " if u.Name == \"\" { return errors.New(\"name required\") }\n" + " if len(u.Name) > 100 { return errors.New(\"name too long\") }\n" + " if u.Age < 0 { return errors.New(\"invalid age\") }\n" + " if u.Age > 200 { return errors.New(\"age too high\") }\n" + " if u.Email == \"\" { return errors.New(\"email required\") }\n" + " if !strings.Contains(u.Email, \"@\") { return errors.New(\"invalid email\") }\n" + " if u.Phone == \"\" { return errors.New(\"phone required\") }\n" + " if len(u.Phone) < 7 { return errors.New(\"phone too short\") }\n" + " if u.Country == \"\" { return errors.New(\"country required\") }\n" + " for _, tag := range u.Tags {\n" + " if tag == \"\" { return errors.New(\"empty tag\") }\n" + " }\n" + " return nil\n" + "}\n") != 0) { + return -1; + } + + return th_write_file( + TH_PATH(tmpdir, "order_validator.go"), + "package validation\n" + "import \"errors\"\n" + "import \"strings\"\n" + "func ValidateOrder(o Order) error {\n" + " if o.Title == \"\" { return errors.New(\"title required\") }\n" + " if len(o.Title) > 100 { return errors.New(\"title too long\") }\n" + " if o.Amount < 0 { return errors.New(\"invalid amount\") }\n" + " if o.Amount > 200 { return errors.New(\"amount too high\") }\n" + " if o.Status == \"\" { return errors.New(\"status required\") }\n" + " if !strings.Contains(o.Status, \"@\") { return errors.New(\"invalid status\") }\n" + " if o.Region == \"\" { return errors.New(\"region required\") }\n" + " if len(o.Region) < 7 { return errors.New(\"region too short\") }\n" + " if o.Vendor == \"\" { return errors.New(\"vendor required\") }\n" + " for _, item := range o.Items {\n" + " if item == \"\" { return errors.New(\"empty item\") }\n" + " }\n" + " return nil\n" + "}\n"); +} + +TEST(incremental_mode_upgrade_reindexes_capabilities) { + char tmpdir[256]; + char dbpath[512]; + ASSERT_EQ(setup_mode_upgrade_repo(tmpdir, sizeof(tmpdir), dbpath, sizeof(dbpath)), 0); + + cbm_pipeline_t *pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + char *project = strdup(cbm_pipeline_project_name(pipeline)); + cbm_pipeline_free(pipeline); + + cbm_store_t *store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_count_edges_by_type(store, project, "SIMILAR_TO"), 0); + ASSERT_TRUE(project_has_index_mode(store, project, "fast")); + cbm_store_close(store); + + pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_MODERATE); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_pipeline_free(pipeline); + + store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + int similarity_edges = cbm_store_count_edges_by_type(store, project, "SIMILAR_TO"); + ASSERT_GT(similarity_edges, 0); + ASSERT_TRUE(project_has_index_mode(store, project, "moderate")); + cbm_store_close(store); + + pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_pipeline_free(pipeline); + + store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_count_edges_by_type(store, project, "SIMILAR_TO"), similarity_edges); + ASSERT_TRUE(project_has_index_mode(store, project, "moderate")); + cbm_store_close(store); + + free(project); + th_rmtree(tmpdir); + PASS(); +} + +TEST(incremental_mode_downgrade_preserves_similarity_for_changed_file) { + char tmpdir[256]; + char dbpath[512]; + ASSERT_EQ(setup_mode_upgrade_repo(tmpdir, sizeof(tmpdir), dbpath, sizeof(dbpath)), 0); + + cbm_pipeline_t *pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_MODERATE); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + char *project = strdup(cbm_pipeline_project_name(pipeline)); + cbm_pipeline_free(pipeline); + + cbm_store_t *store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + int similarity_edges = cbm_store_count_edges_by_type(store, project, "SIMILAR_TO"); + ASSERT_GT(similarity_edges, 0); + ASSERT_TRUE(project_has_index_mode(store, project, "moderate")); + cbm_store_close(store); + + FILE *changed = fopen(TH_PATH(tmpdir, "user_validator.go"), "a"); + ASSERT_NOT_NULL(changed); + ASSERT_GT(fprintf(changed, "\n// changed before fast reindex\n"), 0); + fclose(changed); + + pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_pipeline_free(pipeline); + + store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_count_edges_by_type(store, project, "SIMILAR_TO"), similarity_edges); + ASSERT_TRUE(project_has_index_mode(store, project, "moderate")); + cbm_store_close(store); + + pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_MODERATE); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_pipeline_free(pipeline); + + store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + ASSERT_EQ(cbm_store_count_edges_by_type(store, project, "SIMILAR_TO"), similarity_edges); + ASSERT_TRUE(project_has_index_mode(store, project, "moderate")); + cbm_store_close(store); + + free(project); + th_rmtree(tmpdir); + PASS(); +} + +TEST(incremental_mode_downgrade_preserves_full_extraction_for_changed_file) { + char tmpdir[256]; + char dbpath[512]; + ASSERT_EQ(setup_mode_upgrade_repo(tmpdir, sizeof(tmpdir), dbpath, sizeof(dbpath)), 0); + ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "macros.c"), + "#define REVIEW_LIMIT 42\n" + "int review_value(void) { return REVIEW_LIMIT; }\n"), + 0); + + cbm_pipeline_t *pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_MODERATE); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + char *project = strdup(cbm_pipeline_project_name(pipeline)); + cbm_pipeline_free(pipeline); + + cbm_store_t *store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + ASSERT_EQ(project_count_nodes_by_label(store, project, "Macro"), 0); + ASSERT_TRUE(project_has_index_mode(store, project, "moderate")); + cbm_store_close(store); + + pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_pipeline_free(pipeline); + + store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + int macro_nodes = project_count_nodes_by_label(store, project, "Macro"); + ASSERT_GT(macro_nodes, 0); + ASSERT_TRUE(project_has_index_mode(store, project, "full")); + cbm_store_close(store); + + FILE *changed = fopen(TH_PATH(tmpdir, "macros.c"), "a"); + ASSERT_NOT_NULL(changed); + ASSERT_GT(fprintf(changed, "\n/* changed before moderate reindex */\n"), 0); + fclose(changed); + + pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_MODERATE); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_pipeline_free(pipeline); + + store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + ASSERT_EQ(project_count_nodes_by_label(store, project, "Macro"), macro_nodes); + ASSERT_TRUE(project_has_index_mode(store, project, "full")); + cbm_store_close(store); + + pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FULL); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_pipeline_free(pipeline); + + store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + ASSERT_EQ(project_count_nodes_by_label(store, project, "Macro"), macro_nodes); + ASSERT_TRUE(project_has_index_mode(store, project, "full")); + cbm_store_close(store); + + free(project); + th_rmtree(tmpdir); + PASS(); +} + +TEST(incremental_missing_mode_metadata_forces_reindex) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_mode_legacy_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmpdir)); + + char dbpath[512]; + snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir); + ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "main.go"), + "package main\n\nfunc main() { println(\"hello\") }\n"), + 0); + + cbm_pipeline_t *pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + char *project = strdup(cbm_pipeline_project_name(pipeline)); + cbm_pipeline_free(pipeline); + + cbm_store_t *store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + cbm_node_t project_node = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(store, project, project, &project_node), CBM_STORE_OK); + cbm_node_t legacy_project_node = project_node; + legacy_project_node.properties_json = "{}"; + ASSERT_GT(cbm_store_upsert_node(store, &legacy_project_node), 0); + cbm_node_free_fields(&project_node); + + char sentinel_qn[512]; + snprintf(sentinel_qn, sizeof(sentinel_qn), "%s.legacy_sentinel", project); + cbm_node_t sentinel = {.project = project, + .label = "Function", + .name = "legacy_sentinel", + .qualified_name = sentinel_qn, + .file_path = "legacy.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(store, &sentinel), 0); + cbm_store_close(store); + + pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_pipeline_free(pipeline); + + store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + cbm_node_t sentinel_after = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(store, project, sentinel_qn, &sentinel_after), + CBM_STORE_NOT_FOUND); + ASSERT_TRUE(project_has_index_mode(store, project, "fast")); + cbm_store_close(store); + + free(project); + th_rmtree(tmpdir); + PASS(); +} + +TEST(incremental_nul_suffixed_mode_metadata_forces_reindex) { + char tmpdir[256]; + snprintf(tmpdir, sizeof(tmpdir), "/tmp/cbm_mode_nul_XXXXXX"); + ASSERT_NOT_NULL(cbm_mkdtemp(tmpdir)); + + char dbpath[512]; + snprintf(dbpath, sizeof(dbpath), "%s/test.db", tmpdir); + ASSERT_EQ(th_write_file(TH_PATH(tmpdir, "main.go"), + "package main\n\nfunc main() { println(\"hello\") }\n"), + 0); + + cbm_pipeline_t *pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + char *project = strdup(cbm_pipeline_project_name(pipeline)); + cbm_pipeline_free(pipeline); + + cbm_store_t *store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + cbm_node_t project_node = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(store, project, project, &project_node), CBM_STORE_OK); + cbm_node_t corrupt_project_node = project_node; + corrupt_project_node.properties_json = "{\"index_mode\":\"full\\u0000garbage\"}"; + ASSERT_GT(cbm_store_upsert_node(store, &corrupt_project_node), 0); + cbm_node_free_fields(&project_node); + ASSERT_FALSE(project_has_index_mode(store, project, "full")); + + char sentinel_qn[512]; + snprintf(sentinel_qn, sizeof(sentinel_qn), "%s.nul_mode_sentinel", project); + cbm_node_t sentinel = {.project = project, + .label = "Function", + .name = "nul_mode_sentinel", + .qualified_name = sentinel_qn, + .file_path = "nul-mode.go", + .properties_json = "{}"}; + ASSERT_GT(cbm_store_upsert_node(store, &sentinel), 0); + cbm_store_close(store); + + pipeline = cbm_pipeline_new(tmpdir, dbpath, CBM_MODE_FAST); + ASSERT_NOT_NULL(pipeline); + ASSERT_EQ(cbm_pipeline_run(pipeline), 0); + cbm_pipeline_free(pipeline); + + store = cbm_store_open_path(dbpath); + ASSERT_NOT_NULL(store); + cbm_node_t sentinel_after = {0}; + ASSERT_EQ(cbm_store_find_node_by_qn(store, project, sentinel_qn, &sentinel_after), + CBM_STORE_NOT_FOUND); + ASSERT_TRUE(project_has_index_mode(store, project, "fast")); + cbm_store_close(store); + + free(project); + th_rmtree(tmpdir); + PASS(); +} + TEST(incremental_fast_preserves_mode_skipped_tools_dir) { /* Regression: 2026-04-13. A fast-mode reindex after a full-mode index * was silently destroying every file under FAST_SKIP_DIRS directories @@ -7046,6 +7404,11 @@ SUITE(pipeline) { RUN_TEST(incremental_detects_changed_file); RUN_TEST(incremental_detects_deleted_file); RUN_TEST(incremental_new_file_added); + RUN_TEST(incremental_mode_upgrade_reindexes_capabilities); + RUN_TEST(incremental_mode_downgrade_preserves_similarity_for_changed_file); + RUN_TEST(incremental_mode_downgrade_preserves_full_extraction_for_changed_file); + RUN_TEST(incremental_missing_mode_metadata_forces_reindex); + RUN_TEST(incremental_nul_suffixed_mode_metadata_forces_reindex); RUN_TEST(incremental_fast_preserves_mode_skipped_tools_dir); RUN_TEST(incremental_k8s_manifest_indexed); RUN_TEST(incremental_kustomize_module_indexed);