test(769): end-to-end sibling regression + index-format rebuild boundary Refs #769

Signed-off-by: LA-10 <leenshuhail1@gmail.com>
This commit is contained in:
LA-10 committed 2026-08-25 11:57:49 +03:00
1 parent 010569fa6c
commit 4d9da40b36
9 files changed
+405 -1

No files matched your search

+1 -1
View File
@@ -559,7 +559,7 @@ TEST_DISCOVER_SRCS = \
TEST_GRAPH_BUFFER_SRCS = tests/test_graph_buffer.c
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c
TEST_PIPELINE_SRCS = tests/test_registry.c tests/test_pipeline.c tests/test_cross_repo.c tests/test_fqn.c tests/test_route_canon.c tests/test_path_alias.c tests/test_configlink.c tests/test_infrascan.c tests/test_worker_pool.c tests/test_parallel.c tests/test_index_resilience.c tests/test_index_format.c tests/test_call_reference_contract.c tests/repro/repro_call_scope_usages.c tests/repro/repro_call_argument_usages.c tests/repro/repro_reference_precision.c tests/repro/repro_lexical_binding_precision.c tests/repro/repro_call_argument_matrix_a.c tests/repro/repro_call_argument_matrix_b.c tests/repro/repro_call_node_behaviors.c tests/repro/repro_language_registry.c tests/repro/repro_call_node_manifest.c tests/repro/repro_lsp_ordered_signatures.c tests/repro/repro_lsp_ordered_local.c tests/repro/repro_ts_overload_return_chains.c tests/repro/repro_harness_cleanup.c tests/repro/repro_runner_filter.c
TEST_WATCHER_SRCS = tests/test_watcher.c
+3
View File
@@ -8343,6 +8343,9 @@ static char *handle_index_repository(cbm_mcp_server_t *srv, const char *args) {
srv, doc, root, project_name, repo_path, persistence, p, excluded_dirs, excluded_count,
file_errors, file_error_count, has_logfile ? logfile_path : NULL);
yyjson_mut_obj_add_str(doc, root, "status", degraded ? "degraded" : "indexed");
if (cbm_pipeline_had_format_migration(p)) {
yyjson_mut_obj_add_bool(doc, root, "format_migration", true);
}
} else {
yyjson_mut_obj_add_str(doc, root, "status", "error");
yyjson_mut_obj_add_str(doc, root, "hint",
+31
View File
@@ -190,6 +190,10 @@ struct cbm_pipeline {
int committed_nodes;
int committed_edges;
/* #769: set when a stale-format index was routed through the one-time
* full rebuild, so the MCP response can surface the migration. */
bool format_migration;
/* ADR (project_summaries) captured before a full-reindex DB delete, so it
* can be restored after the rebuild. NULL when no ADR existed. Issue #516. */
char *saved_adr;
@@ -224,6 +228,10 @@ void cbm_pipeline_set_pkgmap(CBMHashTable *map) {
g_pkgmap = map;
}
bool cbm_pipeline_had_format_migration(const cbm_pipeline_t *p) {
return p && p->format_migration;
}
/* ── Timing helper ──────────────────────────────────────────────── */
static double elapsed_ms(struct timespec start) {
@@ -1393,6 +1401,24 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file
free(db_path);
return CBM_PIPELINE_FORCE_FULL_REINDEX;
}
cbm_store_t *fmt_store = cbm_store_open_path_query(db_path);
int fmt = 0;
if (fmt_store) {
cbm_store_get_format_version(fmt_store, &fmt);
cbm_store_close(fmt_store);
}
if (fmt != CBM_INDEX_FORMAT_VERSION) {
cbm_log_info("pipeline.route", "path", "format_change_reindex", "stored_format",
itoa_buf(fmt));
p->format_migration = true;
int adr_rc = capture_existing_adr(p, db_path);
(void)cbm_unlink(db_path);
(void)cbm_remove_db_sidecars(db_path);
free(db_path);
return adr_rc != 0 ? adr_rc : CBM_PIPELINE_FORCE_FULL_REINDEX;
}
cbm_log_info("pipeline.route", "path", "incremental_manifest");
int rc = cbm_pipeline_run_incremental(p, db_path, files, file_count, baseline_manifest,
baseline_count, force_full_on_mismatch);
@@ -1747,6 +1773,11 @@ int cbm_pipeline_publish_staged(char *stage_path, const cbm_pipeline_generation_
ok = cbm_store_adr_store(store, generation->project, generation->adr_content) ==
CBM_STORE_OK;
}
if (ok) {
ok = cbm_store_set_format_version(store, CBM_INDEX_FORMAT_VERSION) == CBM_STORE_OK;
}
cbm_log_info("publish.timing", "block", "writes", "elapsed_ms",
itoa_buf((int)elapsed_ms(t_pub)));
cbm_clock_gettime(CLOCK_MONOTONIC, &t_pub);
+2
View File
@@ -93,6 +93,8 @@ int cbm_pipeline_get_mode(const cbm_pipeline_t *p);
* to NULL/0 when p is NULL or nothing was excluded. Do not free. */
void cbm_pipeline_get_excluded(const cbm_pipeline_t *p, char ***out, int *count);
bool cbm_pipeline_had_format_migration(const cbm_pipeline_t *p);
/* Committed node/edge counts captured at dump time (-1 when dump did not run).
* Nodes are the #334 plausibility-gate axis; edges are informational only. */
void cbm_pipeline_get_committed_counts(const cbm_pipeline_t *p, int *nodes, int *edges);
+30
View File
@@ -3512,6 +3512,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,
+8
View File
@@ -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 ────────────────────────────────────────────── */
@@ -92,6 +93,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. */
+79
View File
@@ -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);
+249
View File
@@ -0,0 +1,249 @@
/*
* test_index_format.c — Guard for the persisted index-format boundary (#769).
*
* #1108 changed File-node QNs to keep the file extension, so an index written
* before it holds collided File identities: badge.component.ts/.html/.scss all
* stripped to the same stem and only one File node survived per component.
* Refreshing such an index incrementally would mint new-format QNs only for the
* files that happened to change, leaving the old collided node behind — a mixed
* graph with duplicate nodes and stale edges.
*
* A stale-format index must therefore be routed through the existing
* full-reindex path exactly once (which preserves ADR/project metadata per
* #516), and the rebuilt index must not force a second rebuild on the next
* unchanged run.
*/
#include "test_framework.h"
#include "repro_harness.h" /* RProj, rh_index_files, rh_count_label, rh_cleanup */
#include "foundation/log.h"
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
/* ── Fixture: one component's siblings + an uncolliding control ─────── */
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";
/* help.html shares no stem with anything, so it is searchable both before and
* after the fix — a control that isolates the collision as the cause. */
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]));
/* ── Log capture: the routing decision is only visible in the log ───── */
enum { IF_LOG_BUF = 8192 };
static char g_log_buf[IF_LOG_BUF];
static size_t g_log_len;
static void capture_sink(const char *line) {
size_t n = strlen(line);
if (g_log_len + n + 2 < 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';
}
/* Run index_repository through the production MCP flow, capturing the log. */
static char *index_capture(RProj *lp) {
char args[700];
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", lp->tmpdir);
char *prior = getenv("CBM_CACHE_DIR");
char *saved = prior ? strdup(prior) : NULL;
cbm_setenv("CBM_CACHE_DIR", lp->cachedir, 1);
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);
if (saved) {
cbm_setenv("CBM_CACHE_DIR", saved, 1);
free(saved);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
return resp;
}
/* ── Test 1: one File node per file, and every file reaches search ──── */
TEST(index_format_siblings_distinct_and_searchable) {
RProj lp;
char args[700];
cbm_store_t *store = rh_index_files(&lp, k_files, k_nfiles);
ASSERT_NOT_NULL(store);
/* One File node per file on disk — not one per component stem. */
ASSERT_EQ(rh_count_label(store, lp.project, "File"), k_nfiles);
/* cbm_store_list_files is the set search_code scopes grep to: a file with
* no node carrying its exact path is never opened, and the miss is silent. */
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;
}
}
if (!found) {
FAIL("indexed file missing from cbm_store_list_files");
}
}
for (int j = 0; j < nlisted; j++) {
free(listed[j]);
}
free(listed);
/* Every sibling's marker is reachable. .scss/.html have no def nodes, so a
* hit lands in raw_matches rather than a containing node — mode="files"
* merges both, so assert against the file list. */
snprintf(args, sizeof(args),
"{\"project\":\"%s\",\"pattern\":\"repro-marker\",\"mode\":\"files\"}", lp.project);
char *saved_dup = getenv("CBM_CACHE_DIR") ? strdup(getenv("CBM_CACHE_DIR")) : NULL;
cbm_setenv("CBM_CACHE_DIR", lp.cachedir, 1);
char *resp = cbm_mcp_handle_tool(lp.srv, "search_code", args);
if (saved_dup) {
cbm_setenv("CBM_CACHE_DIR", saved_dup, 1);
free(saved_dup);
} else {
cbm_unsetenv("CBM_CACHE_DIR");
}
ASSERT_NOT_NULL(resp);
for (int i = 0; i < k_nfiles; i++) {
if (!strstr(resp, k_files[i].name)) {
free(resp);
FAIL("search_code did not reach an indexed sibling");
}
}
free(resp);
rh_cleanup(&lp, store);
PASS();
}
/* ── Test 2: a legacy index rebuilds once, repairs, and settles ─────── */
/* Rewrite the graph into the pre-#1108 shape: the three siblings collapsed onto
* a single File node keyed by the extension-stripped QN, which is what such an
* index actually holds on disk. */
static int make_legacy_file_graph(cbm_store_t *store, const char *project, const char *legacy_qn) {
if (cbm_store_delete_nodes_by_label(store, project, "File") != CBM_STORE_OK) {
return -1;
}
cbm_node_t legacy = {
.project = project,
.label = "File",
.name = "badge.component.html",
.qualified_name = legacy_qn,
.file_path = "badge/badge.component.html",
.properties_json = "{\"extension\":\".html\"}",
};
return cbm_store_upsert_node(store, &legacy) > 0 ? 0 : -1;
}
TEST(index_format_legacy_index_rebuilds_and_repairs) {
char legacy_qn[512];
RProj lp;
/* Phase 1: index, then discard the query handle. */
cbm_store_t *q0 = rh_index_files(&lp, k_files, k_nfiles);
ASSERT_NOT_NULL(q0);
cbm_store_close(q0);
/* Phase 2: fabricate a legacy graph through a writable handle. */
cbm_store_t *w = cbm_store_open_path(lp.dbpath);
ASSERT_NOT_NULL(w);
ASSERT_EQ(cbm_store_adr_store(w, lp.project, "index-format-adr"), CBM_STORE_OK);
snprintf(legacy_qn, sizeof(legacy_qn), "%s.badge.badge.component.__file__", lp.project);
ASSERT_EQ(make_legacy_file_graph(w, lp.project, legacy_qn), 0);
ASSERT_EQ(cbm_store_set_format_version(w, 0), CBM_STORE_OK);
cbm_store_close(w);
cbm_store_t *probe = cbm_store_open_path_query(lp.dbpath);
if (probe)
cbm_store_close(probe);
/* Run 1: stale format forces the full-reindex path. */
char *resp = index_capture(&lp);
ASSERT_NOT_NULL(resp);
if (!strstr(g_log_buf, "format_change_reindex")) {
free(resp);
FAIL("a stale-format index was not routed through the full-reindex path");
}
if (!strstr(resp, "\"format_migration\":true")) {
free(resp);
FAIL("index_repository response did not surface format_migration");
}
free(resp);
/* Phase 3: verify the rebuild through a fresh read handle. */
cbm_store_t *r1 = cbm_store_open_path(lp.dbpath);
ASSERT_NOT_NULL(r1);
cbm_node_t stale = {0};
ASSERT_EQ(cbm_store_find_node_by_qn(r1, lp.project, legacy_qn, &stale), CBM_STORE_NOT_FOUND);
cbm_node_free_fields(&stale);
static const char *k_exts[] = {"ts", "html", "scss"};
for (int i = 0; i < 3; i++) {
char qn[512];
snprintf(qn, sizeof(qn), "%s.badge.badge.component.%s.__file__", lp.project, k_exts[i]);
cbm_node_t n = {0};
ASSERT_EQ(cbm_store_find_node_by_qn(r1, lp.project, qn, &n), CBM_STORE_OK);
cbm_node_free_fields(&n);
}
ASSERT_EQ(rh_count_label(r1, lp.project, "File"), k_nfiles);
cbm_adr_t adr = {0};
ASSERT_EQ(cbm_store_adr_get(r1, lp.project, &adr), CBM_STORE_OK);
ASSERT_NOT_NULL(adr.content);
ASSERT(strstr(adr.content, "index-format-adr") != NULL);
cbm_store_adr_free(&adr);
int fmt = -1;
ASSERT_EQ(cbm_store_get_format_version(r1, &fmt), CBM_STORE_OK);
ASSERT_EQ(fmt, CBM_INDEX_FORMAT_VERSION);
cbm_store_close(r1);
/* Run 2: unchanged, current format — no second rebuild. */
resp = index_capture(&lp);
ASSERT_NOT_NULL(resp);
if (strstr(g_log_buf, "format_change_reindex")) {
free(resp);
FAIL("a current-format index was rebuilt again");
}
if (strstr(resp, "format_migration")) {
free(resp);
FAIL("format_migration surfaced on an unchanged run");
}
free(resp);
/* Phase 4: final check, then clean up (rh_cleanup closes r2). */
cbm_store_t *r2 = cbm_store_open_path(lp.dbpath);
ASSERT_NOT_NULL(r2);
ASSERT_EQ(rh_count_label(r2, lp.project, "File"), k_nfiles);
rh_cleanup(&lp, r2);
PASS();
}
SUITE(index_format) {
RUN_TEST(index_format_siblings_distinct_and_searchable);
RUN_TEST(index_format_legacy_index_rebuilds_and_repairs);
}
+2
View File
@@ -719,6 +719,7 @@ extern void suite_pipeline(void);
extern void suite_pipeline_semantic_manifest_repro(void);
extern void suite_cross_repo(void);
extern void suite_index_resilience(void);
extern void suite_index_format(void);
extern void suite_fqn(void);
extern void suite_route_canon(void);
extern void suite_path_alias(void);
@@ -984,6 +985,7 @@ int main(int argc, char **argv) {
/* Pipeline (M8) */
RUN_SELECTED_SUITE(registry);
RUN_SELECTED_SUITE(pipeline);
RUN_SELECTED_SUITE(index_format);
RUN_SELECTED_SUITE(pipeline_semantic_manifest_repro);
RUN_SELECTED_SUITE(call_reference_contract);
RUN_SELECTED_SUITE(call_reference_language_complex_contract);