diff --git a/Makefile.cbm b/Makefile.cbm index b78018c1..9620c126 100644 --- a/Makefile.cbm +++ b/Makefile.cbm @@ -461,6 +461,7 @@ TEST_REPRO_SRCS = \ tests/repro/repro_issue787.c \ tests/repro/repro_issue842.c \ tests/repro/repro_issue964.c \ + tests/repro/repro_issue769.c \ tests/repro/repro_invariant_calls.c \ tests/repro/repro_invariant_graph.c \ tests/repro/repro_invariant_breadth.c \ diff --git a/src/pipeline/pipeline.c b/src/pipeline/pipeline.c index 9f363922..2f481e89 100644 --- a/src/pipeline/pipeline.c +++ b/src/pipeline/pipeline.c @@ -1116,17 +1116,30 @@ 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; + int fmt = 0; + cbm_store_get_file_hashes(check_store, p->project_name, &hashes, &hash_count); cbm_store_free_file_hashes(hashes, hash_count); + cbm_store_get_format_version(check_store, &fmt); + + bool format_stale = (fmt != CBM_INDEX_FORMAT_VERSION); cbm_store_close(check_store); - if (hash_count > 0 && file_count <= hash_count + (hash_count / PAIR_LEN)) { + + if (format_stale) { + if (hash_count > 0) { + char stored_buf[CBM_SZ_32]; + snprintf(stored_buf, sizeof(stored_buf), "%d", fmt); + cbm_log_info("pipeline.route", "path", "format_change_reindex", "stored_format", + stored_buf); + } + /* fall through to delete + full rebuild */ + } else if (hash_count > 0 && 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); free(db_path); return rc; - } - if (hash_count > 0) { + } 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)); } @@ -1226,6 +1239,9 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil CBM_PROF_END("persist", "1_reopen", t_reopen); if (hash_store) { bool hash_records_complete = true; + if (cbm_store_set_format_version(hash_store, CBM_INDEX_FORMAT_VERSION) != CBM_STORE_OK) { + cbm_log_error("pipeline.err", "phase", "persist_format_version"); + } CBM_PROF_START(t_delhash); if (cbm_store_delete_file_hashes(hash_store, p->project_name) != CBM_STORE_OK) { hash_records_complete = false; diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index 8b576697..3f8125f3 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -668,6 +668,13 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char * bool hash_records_complete = persist_hashes(hash_store, project, files, file_count, mode_skipped, mode_skipped_count); + /* #769: cbm_writer_open truncates and rebuilds the DB file from + * scratch on every dump, so the format version written by the last + * full index is gone by the time we get here. */ + if (cbm_store_set_format_version(hash_store, CBM_INDEX_FORMAT_VERSION) != CBM_STORE_OK) { + cbm_log_error("incremental.err", "msg", "persist_format_version", "project", project); + } + /* Coverage rows (#963): re-write the merged set into the rebuilt DB * (AFTER hashes, so the deleted-file prune sees the live file set). */ cbm_project_t project_info = {0}; diff --git a/src/store/store.c b/src/store/store.c index 112d5ea7..40dd0bc7 100644 --- a/src/store/store.c +++ b/src/store/store.c @@ -2749,6 +2749,36 @@ int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int * return CBM_STORE_OK; } +/* ── Index format version (PRAGMA user_version) ─────────────────── */ + +int cbm_store_get_format_version(cbm_store_t *s, int *out) { + if (!s || !s->db || !out) { + return CBM_STORE_ERR; + } + *out = 0; + sqlite3_stmt *stmt = NULL; + if (sqlite3_prepare_v2(s->db, "PRAGMA user_version;", CBM_NOT_FOUND, &stmt, NULL) != + SQLITE_OK) { + return CBM_STORE_ERR; + } + if (sqlite3_step(stmt) == SQLITE_ROW) { + *out = sqlite3_column_int(stmt, 0); + } + sqlite3_finalize(stmt); + return CBM_STORE_OK; +} + +/* PRAGMA values cannot be bound; format the integer constant in. Writes, so + * only call on a read-write connection (never from configure_pragmas). */ +int cbm_store_set_format_version(cbm_store_t *s, int version) { + if (!s || !s->db) { + return CBM_STORE_ERR; + } + char sql[ST_BUF_64]; + snprintf(sql, sizeof(sql), "PRAGMA user_version = %d;", version); + return exec_sql(s, sql); +} + /* ── Node neighbor names ──────────────────────────────────────── */ static int query_neighbor_names(sqlite3 *db, const char *sql, int64_t node_id, int limit, diff --git a/src/store/store.h b/src/store/store.h index 963837f8..e08b51e7 100644 --- a/src/store/store.h +++ b/src/store/store.h @@ -23,6 +23,7 @@ typedef struct cbm_store cbm_store_t; #define CBM_STORE_OK 0 #define CBM_STORE_ERR (-1) #define CBM_STORE_NOT_FOUND (-2) +#define CBM_INDEX_FORMAT_VERSION 1 /* ── Data structures ────────────────────────────────────────────── */ @@ -77,6 +78,13 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou * Returns CBM_STORE_OK or CBM_STORE_ERR. */ int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *count); +/* Persisted index-format identity. Bump when a change alters the QN scheme + * or node identity of an already-written graph, so an old DB is routed + * through the full-reindex path instead of producing a mixed graph. + * 1 = File QNs keep the file extension (#769). */ +int cbm_store_get_format_version(cbm_store_t *s, int *out); +int cbm_store_set_format_version(cbm_store_t *s, int version); + /* Get caller/callee names for a node (CALLS/HTTP_CALLS/ASYNC_CALLS edges). * Returns 0 on success. Caller must free each out_callers[i]/out_callees[i] * and the arrays themselves. */ diff --git a/tests/repro/repro_issue769.c b/tests/repro/repro_issue769.c new file mode 100644 index 00000000..ce888497 --- /dev/null +++ b/tests/repro/repro_issue769.c @@ -0,0 +1,189 @@ +/* + * repro_issue769.c -- Reproduce-first / regression guard for bug #769. + * + * Issue #769: "Angular component sibling files (ts/html/scss) merged into one + * File node; search_code silently incomplete" + * + * ROOT CAUSE: + * cbm_pipeline_fqn_compute (pipeline/fqn.c) called strip_file_extension() + * unconditionally, removing only the FINAL extension from the last path + * component. For a component's sibling files that is the only distinguishing + * part, so badge.component.ts / .html / .scss all produced one QN: + * proj.badge.badge.component.__file__ + * cbm_gbuf_upsert_node keys on QN, so the three files collapse into a single + * File node; only one path survives (the canonical content rule from #787 + * picks the lexicographically smallest file_path, i.e. .html). + * + * The search impact follows from cbm_store_list_files: + * SELECT DISTINCT file_path FROM nodes WHERE ... + * (no label filter — any node carrying the path makes the file searchable). + * write_scoped_filelist feeds that list to `xargs -0 grep`, so a file with no + * node carrying its exact path is NEVER OPENED. Templates and stylesheets + * produce no def nodes, so once they lose the QN collision they have no + * fallback node and vanish from search with no truncation warning. + * + * FIX: + * fqn.c: skip strip_file_extension when the caller passes the "__file__" + * sentinel. File identity becomes one node per actual source/template/style + * file. Symbol and module QNs are unchanged. + * + * FIXTURE DESIGN: + * One component, three siblings, each containing the marker: + * badge/badge.component.ts (parsed — yields a Class + Method) + * badge/badge.component.html (not parsed — no def nodes) + * badge/badge.component.scss (not parsed — no def nodes) + * Plus an unrelated help.html whose stem collides with nothing, as a control: + * it must be searchable both before and after the fix. + * + * WHY RED on buggy HEAD: + * - File-node count is 2 (one merged component node + help.html), not 4. + * - list_files omits badge.component.scss and badge.component.ts, so grep + * never opens them and the marker set comes back short. + * - The surviving node's extension does not match its file_path. + */ +#include +#include "foundation/log.h" +#include "test_framework.h" +#include "repro_harness.h" +#include +#include +#include + +static const char k_badge_ts[] = + "export class BadgeComponent {\n" + " isHighlighted() { return true; } /* repro-marker */\n" + "}\n"; + +static const char k_badge_html[] = "
repro-marker
\n"; + +static const char k_badge_scss[] = ".badge { color: red; /* repro-marker */ }\n"; + +static const char k_help_html[] = "

repro-marker

\n"; + +static const RFile k_files[] = { + {"badge/badge.component.ts", k_badge_ts}, + {"badge/badge.component.html", k_badge_html}, + {"badge/badge.component.scss", k_badge_scss}, + {"standalone/help.html", k_help_html}, +}; +static const int k_nfiles = (int)(sizeof(k_files) / sizeof(k_files[0])); + +static char g_log_buf[8192]; +static size_t g_log_len; + +static void capture_sink(const char *line) { + size_t n = strlen(line); + if (g_log_len + n + 1 < sizeof(g_log_buf)) { + memcpy(g_log_buf + g_log_len, line, n); + g_log_len += n; + g_log_buf[g_log_len++] = '\n'; + g_log_buf[g_log_len] = '\0'; + } +} + +static void capture_reset(void) { + g_log_len = 0; + g_log_buf[0] = '\0'; +} + +TEST(repro_issue769_component_siblings_distinct_and_searchable) { + RProj lp; + cbm_store_t *store = rh_index_files(&lp, k_files, k_nfiles); + ASSERT_NOT_NULL(store); + + /* 1. One File node per file on disk — not one per component stem. */ + ASSERT_EQ(rh_count_label(store, lp.project, "File"), k_nfiles); + + /* 2. Every fixture file reaches the scoped grep list. */ + char **listed = NULL; + int nlisted = 0; + ASSERT_EQ(cbm_store_list_files(store, lp.project, &listed, &nlisted), CBM_STORE_OK); + for (int i = 0; i < k_nfiles; i++) { + bool found = false; + for (int j = 0; j < nlisted; j++) { + if (listed[j] && strcmp(listed[j], k_files[i].name) == 0) { + found = true; + break; + } + } + ASSERT(found); + } + for (int j = 0; j < nlisted; j++) { + free(listed[j]); + } + free(listed); + + /* 3. search_code reaches every sibling. */ + char args[700]; + snprintf(args, sizeof(args), + "{\"project\":\"%s\",\"pattern\":\"repro-marker\",\"mode\":\"files\"}", lp.project); + char *resp = cbm_mcp_handle_tool(lp.srv, "search_code", args); + ASSERT_NOT_NULL(resp); + for (int i = 0; i < k_nfiles; i++) { + ASSERT(strstr(resp, k_files[i].name) != NULL); + } + free(resp); + + rh_cleanup(&lp, store); + PASS(); +} + +/* + * #769 upgrade path: an index written before the File-QN format change holds + * collided File identities. Refreshing it incrementally would give touched + * files new-format QNs while untouched files keep the old ones, a mixed + * graph. try_incremental_or_delete_db must route a stale-format index through + * the full-reindex path exactly once, and the rebuilt index must not force a + * second rebuild on the next unchanged run. + */ +TEST(repro_issue769_stale_format_forces_one_rebuild) { + RProj lp; + cbm_store_t *store = rh_index_files(&lp, k_files, k_nfiles); + ASSERT_NOT_NULL(store); + + /* Simulate a pre-change index. */ + ASSERT_EQ(cbm_store_set_format_version(store, 0), CBM_STORE_OK); + cbm_store_close(store); + + char args[700]; + snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", lp.tmpdir); + + /* Run 1: stale format → forced full rebuild. */ + capture_reset(); + cbm_log_set_sink_ex(capture_sink, CBM_LOG_SINK_TEE); + char *resp = cbm_mcp_handle_tool(lp.srv, "index_repository", args); + cbm_log_set_sink(NULL); + ASSERT_NOT_NULL(resp); + free(resp); + ASSERT(strstr(g_log_buf, "format_change_reindex") != NULL); + + /* Rebuild produced distinct File nodes and stamped the current format. */ + store = cbm_store_open_path(lp.dbpath); + ASSERT_NOT_NULL(store); + ASSERT_EQ(rh_count_label(store, lp.project, "File"), k_nfiles); + int fmt = -1; + ASSERT_EQ(cbm_store_get_format_version(store, &fmt), CBM_STORE_OK); + ASSERT_EQ(fmt, CBM_INDEX_FORMAT_VERSION); + cbm_store_close(store); + + /* Run 2: unchanged, current format → no second rebuild. */ + capture_reset(); + cbm_log_set_sink_ex(capture_sink, CBM_LOG_SINK_TEE); + resp = cbm_mcp_handle_tool(lp.srv, "index_repository", args); + cbm_log_set_sink(NULL); + ASSERT_NOT_NULL(resp); + free(resp); + ASSERT(strstr(g_log_buf, "format_change_reindex") == NULL); + ASSERT(strstr(g_log_buf, "path=incremental") != NULL); + + store = cbm_store_open_path(lp.dbpath); + ASSERT_NOT_NULL(store); + ASSERT_EQ(rh_count_label(store, lp.project, "File"), k_nfiles); + rh_cleanup(&lp, store); + PASS(); +} + +SUITE(repro_issue769) { + RUN_TEST(repro_issue769_component_siblings_distinct_and_searchable); + RUN_TEST(repro_issue769_stale_format_forces_one_rebuild); +} \ No newline at end of file diff --git a/tests/repro/repro_main.c b/tests/repro/repro_main.c index fe0ac29f..003c357f 100644 --- a/tests/repro/repro_main.c +++ b/tests/repro/repro_main.c @@ -83,6 +83,7 @@ extern void suite_repro_issue581(void); extern void suite_repro_issue787(void); extern void suite_repro_issue842(void); extern void suite_repro_issue964(void); +extern void suite_repro_issue769(void); /* NEW bugs found by the discovery sweep */ extern void suite_repro_new_ts_class_field_arrow(void); extern void suite_repro_new_py_tuple_unpack(void); @@ -167,6 +168,7 @@ int main(void) { RUN_SUITE(repro_new_cypher_limit_zero); RUN_SUITE(repro_issue363); RUN_SUITE(repro_issue581); + RUN_SUITE(repro_issue769); RUN_SUITE(repro_issue787); RUN_SUITE(repro_issue842); RUN_SUITE(repro_issue964); diff --git a/tests/test_fqn.c b/tests/test_fqn.c index d2163424..5d565f8d 100644 --- a/tests/test_fqn.c +++ b/tests/test_fqn.c @@ -89,6 +89,77 @@ TEST(fqn_compute_basic_rs) { PASS(); } +TEST(fqn_compute_file_sibling_distinct) { + char *ts = cbm_pipeline_fqn_compute("proj", "app/badge/badge.component.ts", "__file__"); + char *html = cbm_pipeline_fqn_compute("proj", "app/badge/badge.component.html", "__file__"); + char *scss = cbm_pipeline_fqn_compute("proj", "app/badge/badge.component.scss", "__file__"); + ASSERT_NOT_NULL(ts); + ASSERT_NOT_NULL(html); + ASSERT_NOT_NULL(scss); + ASSERT_STR_NEQ(ts, html); + ASSERT_STR_NEQ(ts, scss); + ASSERT_STR_NEQ(html, scss); + free(ts); + free(html); + free(scss); + PASS(); +} + +TEST(fqn_compute_symbol_still_strips) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/badge/badge.component.ts", "BadgeComponent"), + "proj.app.badge.badge.component.BadgeComponent"); + PASS(); +} + +TEST(fqn_module_siblings_still_share) { + ASSERT_FQN(cbm_pipeline_fqn_module("proj", "app/badge/badge.component.ts"), + "proj.app.badge.badge.component"); + ASSERT_FQN(cbm_pipeline_fqn_module("proj", "app/badge/badge.component.html"), + "proj.app.badge.badge.component"); + PASS(); +} + +/* #769: exact __file__ QNs for component siblings. */ +TEST(fqn_compute_file_sibling_exact) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/badge/badge.component.ts", "__file__"), + "proj.app.badge.badge.component.ts.__file__"); + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/badge/badge.component.html", "__file__"), + "proj.app.badge.badge.component.html.__file__"); + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/badge/badge.component.scss", "__file__"), + "proj.app.badge.badge.component.scss.__file__"); + PASS(); +} + +TEST(fqn_compute_file_multi_extension) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/badge/badge.spec.ts", "__file__"), + "proj.app.badge.badge.spec.ts.__file__"); + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "archive.tar.gz", "__file__"), + "proj.archive.tar.gz.__file__"); + PASS(); +} + +TEST(fqn_compute_file_no_extension) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "Makefile", "__file__"), + "proj.Makefile.__file__"); + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "LICENSE", "__file__"), + "proj.LICENSE.__file__"); + PASS(); +} + +TEST(fqn_compute_file_dotfile) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", ".gitignore", "__file__"), + "proj..gitignore.__file__"); + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "config/.env", "__file__"), + "proj.config..env.__file__"); + PASS(); +} + +TEST(fqn_compute_file_index_ts) { + ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/index.ts", "__file__"), + "proj.app.index.ts.__file__"); + PASS(); +} + /* ── Nested paths ─────────────────────────────────────────────── */ TEST(fqn_compute_nested_two_levels) { @@ -595,6 +666,14 @@ SUITE(fqn) { RUN_TEST(fqn_file_qn_distinguishes_same_stem_header_source_issue964); RUN_TEST(fqn_module_qn_still_strips_extension); RUN_TEST(fqn_compute_basic_rs); + RUN_TEST(fqn_compute_file_sibling_distinct); + RUN_TEST(fqn_compute_symbol_still_strips); + RUN_TEST(fqn_module_siblings_still_share); + RUN_TEST(fqn_compute_file_sibling_exact); + RUN_TEST(fqn_compute_file_multi_extension); + RUN_TEST(fqn_compute_file_no_extension); + RUN_TEST(fqn_compute_file_dotfile); + RUN_TEST(fqn_compute_file_index_ts); /* fqn_compute: nested paths */ RUN_TEST(fqn_compute_nested_two_levels);