Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile.cbm
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
22 changes: 19 additions & 3 deletions src/pipeline/pipeline.c
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/pipeline/pipeline_incremental.c
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down
30 changes: 30 additions & 0 deletions src/store/store.c
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/store/store.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────── */

Expand Down Expand Up @@ -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. */
Expand Down
189 changes: 189 additions & 0 deletions tests/repro/repro_issue769.c
Original file line number Diff line number Diff line change
@@ -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 <foundation/compat.h>
#include "foundation/log.h"
#include "test_framework.h"
#include "repro_harness.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

static const char k_badge_ts[] =
"export class BadgeComponent {\n"
" isHighlighted() { return true; } /* repro-marker */\n"
"}\n";

static const char k_badge_html[] = "<div class=\"badge\">repro-marker</div>\n";

static const char k_badge_scss[] = ".badge { color: red; /* repro-marker */ }\n";

static const char k_help_html[] = "<p>repro-marker</p>\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);
}
2 changes: 2 additions & 0 deletions tests/repro/repro_main.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading