Skip to content

Commit 4e18bf2

Browse files
committed
test(769): end-to-end sibling regression + index-format rebuild boundary Refs #769
Signed-off-by: LA-10 <leenshuhail1@gmail.com>
1 parent b79f397 commit 4e18bf2

9 files changed

Lines changed: 302 additions & 4 deletions

File tree

Makefile.cbm

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,7 @@ TEST_REPRO_SRCS = \
446446
tests/repro/repro_issue363.c \
447447
tests/repro/repro_issue581.c \
448448
tests/repro/repro_issue787.c \
449+
tests/repro/repro_issue769.c \
449450
tests/repro/repro_invariant_calls.c \
450451
tests/repro/repro_invariant_graph.c \
451452
tests/repro/repro_invariant_breadth.c \

src/pipeline/fqn.c

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,6 @@ char *cbm_pipeline_fqn_compute(const char *project, const char *rel_path, const
120120
if (!(name && strcmp(name, "__file__") == 0)) {
121121
strip_file_extension(path);
122122
}
123-
124123
const char *segments[CBM_SZ_256];
125124
int seg_count = 0;
126125
segments[seg_count++] = project;

src/pipeline/pipeline.c

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -990,17 +990,30 @@ static int try_incremental_or_delete_db(cbm_pipeline_t *p, cbm_file_info_t *file
990990
if (check_store && cbm_store_check_integrity(check_store)) {
991991
cbm_file_hash_t *hashes = NULL;
992992
int hash_count = 0;
993+
int fmt = 0;
994+
993995
cbm_store_get_file_hashes(check_store, p->project_name, &hashes, &hash_count);
994996
cbm_store_free_file_hashes(hashes, hash_count);
997+
cbm_store_get_format_version(check_store, &fmt);
998+
999+
bool format_stale = (fmt != CBM_INDEX_FORMAT_VERSION);
9951000
cbm_store_close(check_store);
996-
if (hash_count > 0 && file_count <= hash_count + (hash_count / PAIR_LEN)) {
1001+
1002+
if (format_stale) {
1003+
if (hash_count > 0) {
1004+
char stored_buf[CBM_SZ_32];
1005+
snprintf(stored_buf, sizeof(stored_buf), "%d", fmt);
1006+
cbm_log_info("pipeline.route", "path", "format_change_reindex", "stored_format",
1007+
stored_buf);
1008+
}
1009+
/* fall through to delete + full rebuild */
1010+
} else if (hash_count > 0 && file_count <= hash_count + (hash_count / PAIR_LEN)) {
9971011
cbm_log_info("pipeline.route", "path", "incremental", "stored_hashes",
9981012
itoa_buf(hash_count));
9991013
int rc = cbm_pipeline_run_incremental(p, db_path, files, file_count);
10001014
free(db_path);
10011015
return rc;
1002-
}
1003-
if (hash_count > 0) {
1016+
} else if (hash_count > 0) {
10041017
cbm_log_info("pipeline.route", "path", "mode_change_reindex", "stored_hashes",
10051018
itoa_buf(hash_count), "discovered", itoa_buf(file_count));
10061019
}
@@ -1086,6 +1099,9 @@ static int dump_and_persist_hashes(cbm_pipeline_t *p, const cbm_file_info_t *fil
10861099
cbm_store_t *hash_store = cbm_store_open_path(db_path);
10871100
CBM_PROF_END("persist", "1_reopen", t_reopen);
10881101
if (hash_store) {
1102+
if (cbm_store_set_format_version(hash_store, CBM_INDEX_FORMAT_VERSION) != CBM_STORE_OK) {
1103+
cbm_log_error("pipeline.err", "phase", "persist_format_version");
1104+
}
10891105
CBM_PROF_START(t_delhash);
10901106
cbm_store_delete_file_hashes(hash_store, p->project_name);
10911107
CBM_PROF_END("persist", "2_delete_file_hashes", t_delhash);

src/pipeline/pipeline_incremental.c

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -651,6 +651,13 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *
651651
if (hash_store) {
652652
persist_hashes(hash_store, project, files, file_count, mode_skipped, mode_skipped_count);
653653

654+
/* #769: cbm_writer_open truncates and rebuilds the DB file from
655+
* scratch on every dump, so the format version written by the last
656+
* full index is gone by the time we get here. */
657+
if (cbm_store_set_format_version(hash_store, CBM_INDEX_FORMAT_VERSION) != CBM_STORE_OK) {
658+
cbm_log_error("incremental.err", "msg", "persist_format_version", "project", project);
659+
}
660+
654661
/* Coverage rows (#963): re-write the merged set into the rebuilt DB
655662
* (AFTER hashes, so the deleted-file prune sees the live file set). */
656663
if (cov_count > 0 &&

src/store/store.c

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2281,6 +2281,36 @@ int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *
22812281
return CBM_STORE_OK;
22822282
}
22832283

2284+
/* ── Index format version (PRAGMA user_version) ─────────────────── */
2285+
2286+
int cbm_store_get_format_version(cbm_store_t *s, int *out) {
2287+
if (!s || !s->db || !out) {
2288+
return CBM_STORE_ERR;
2289+
}
2290+
*out = 0;
2291+
sqlite3_stmt *stmt = NULL;
2292+
if (sqlite3_prepare_v2(s->db, "PRAGMA user_version;", CBM_NOT_FOUND, &stmt, NULL) !=
2293+
SQLITE_OK) {
2294+
return CBM_STORE_ERR;
2295+
}
2296+
if (sqlite3_step(stmt) == SQLITE_ROW) {
2297+
*out = sqlite3_column_int(stmt, 0);
2298+
}
2299+
sqlite3_finalize(stmt);
2300+
return CBM_STORE_OK;
2301+
}
2302+
2303+
/* PRAGMA values cannot be bound; format the integer constant in. Writes, so
2304+
* only call on a read-write connection (never from configure_pragmas). */
2305+
int cbm_store_set_format_version(cbm_store_t *s, int version) {
2306+
if (!s || !s->db) {
2307+
return CBM_STORE_ERR;
2308+
}
2309+
char sql[ST_BUF_64];
2310+
snprintf(sql, sizeof(sql), "PRAGMA user_version = %d;", version);
2311+
return exec_sql(s, sql);
2312+
}
2313+
22842314
/* ── Node neighbor names ──────────────────────────────────────── */
22852315

22862316
static int query_neighbor_names(sqlite3 *db, const char *sql, int64_t node_id, int limit,

src/store/store.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ typedef struct cbm_store cbm_store_t;
2323
#define CBM_STORE_OK 0
2424
#define CBM_STORE_ERR (-1)
2525
#define CBM_STORE_NOT_FOUND (-2)
26+
#define CBM_INDEX_FORMAT_VERSION 1
2627

2728
/* ── Data structures ────────────────────────────────────────────── */
2829

@@ -77,6 +78,13 @@ void cbm_store_node_degree(cbm_store_t *s, int64_t node_id, int *in_deg, int *ou
7778
* Returns CBM_STORE_OK or CBM_STORE_ERR. */
7879
int cbm_store_list_files(cbm_store_t *s, const char *project, char ***out, int *count);
7980

81+
/* Persisted index-format identity. Bump when a change alters the QN scheme
82+
* or node identity of an already-written graph, so an old DB is routed
83+
* through the full-reindex path instead of producing a mixed graph.
84+
* 1 = File QNs keep the file extension (#769). */
85+
int cbm_store_get_format_version(cbm_store_t *s, int *out);
86+
int cbm_store_set_format_version(cbm_store_t *s, int version);
87+
8088
/* Get caller/callee names for a node (CALLS/HTTP_CALLS/ASYNC_CALLS edges).
8189
* Returns 0 on success. Caller must free each out_callers[i]/out_callees[i]
8290
* and the arrays themselves. */

tests/repro/repro_issue769.c

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/*
2+
* repro_issue769.c -- Reproduce-first / regression guard for bug #769.
3+
*
4+
* Issue #769: "Angular component sibling files (ts/html/scss) merged into one
5+
* File node; search_code silently incomplete"
6+
*
7+
* ROOT CAUSE:
8+
* cbm_pipeline_fqn_compute (pipeline/fqn.c) called strip_file_extension()
9+
* unconditionally, removing only the FINAL extension from the last path
10+
* component. For a component's sibling files that is the only distinguishing
11+
* part, so badge.component.ts / .html / .scss all produced one QN:
12+
* proj.badge.badge.component.__file__
13+
* cbm_gbuf_upsert_node keys on QN, so the three files collapse into a single
14+
* File node; only one path survives (the canonical content rule from #787
15+
* picks the lexicographically smallest file_path, i.e. .html).
16+
*
17+
* The search impact follows from cbm_store_list_files:
18+
* SELECT DISTINCT file_path FROM nodes WHERE ...
19+
* (no label filter — any node carrying the path makes the file searchable).
20+
* write_scoped_filelist feeds that list to `xargs -0 grep`, so a file with no
21+
* node carrying its exact path is NEVER OPENED. Templates and stylesheets
22+
* produce no def nodes, so once they lose the QN collision they have no
23+
* fallback node and vanish from search with no truncation warning.
24+
*
25+
* FIX:
26+
* fqn.c: skip strip_file_extension when the caller passes the "__file__"
27+
* sentinel. File identity becomes one node per actual source/template/style
28+
* file. Symbol and module QNs are unchanged.
29+
*
30+
* FIXTURE DESIGN:
31+
* One component, three siblings, each containing the marker:
32+
* badge/badge.component.ts (parsed — yields a Class + Method)
33+
* badge/badge.component.html (not parsed — no def nodes)
34+
* badge/badge.component.scss (not parsed — no def nodes)
35+
* Plus an unrelated help.html whose stem collides with nothing, as a control:
36+
* it must be searchable both before and after the fix.
37+
*
38+
* WHY RED on buggy HEAD:
39+
* - File-node count is 2 (one merged component node + help.html), not 4.
40+
* - list_files omits badge.component.scss and badge.component.ts, so grep
41+
* never opens them and the marker set comes back short.
42+
* - The surviving node's extension does not match its file_path.
43+
*/
44+
#include <foundation/compat.h>
45+
#include "foundation/log.h"
46+
#include "test_framework.h"
47+
#include "repro_harness.h"
48+
#include <string.h>
49+
#include <stdlib.h>
50+
#include <stdio.h>
51+
52+
static const char k_badge_ts[] =
53+
"export class BadgeComponent {\n"
54+
" isHighlighted() { return true; } /* repro-marker */\n"
55+
"}\n";
56+
57+
static const char k_badge_html[] = "<div class=\"badge\">repro-marker</div>\n";
58+
59+
static const char k_badge_scss[] = ".badge { color: red; /* repro-marker */ }\n";
60+
61+
static const char k_help_html[] = "<p>repro-marker</p>\n";
62+
63+
static const RFile k_files[] = {
64+
{"badge/badge.component.ts", k_badge_ts},
65+
{"badge/badge.component.html", k_badge_html},
66+
{"badge/badge.component.scss", k_badge_scss},
67+
{"standalone/help.html", k_help_html},
68+
};
69+
static const int k_nfiles = (int)(sizeof(k_files) / sizeof(k_files[0]));
70+
71+
static char g_log_buf[8192];
72+
static size_t g_log_len;
73+
74+
static void capture_sink(const char *line) {
75+
size_t n = strlen(line);
76+
if (g_log_len + n + 1 < sizeof(g_log_buf)) {
77+
memcpy(g_log_buf + g_log_len, line, n);
78+
g_log_len += n;
79+
g_log_buf[g_log_len++] = '\n';
80+
g_log_buf[g_log_len] = '\0';
81+
}
82+
}
83+
84+
static void capture_reset(void) {
85+
g_log_len = 0;
86+
g_log_buf[0] = '\0';
87+
}
88+
89+
TEST(repro_issue769_component_siblings_distinct_and_searchable) {
90+
RProj lp;
91+
cbm_store_t *store = rh_index_files(&lp, k_files, k_nfiles);
92+
ASSERT_NOT_NULL(store);
93+
94+
/* 1. One File node per file on disk — not one per component stem. */
95+
ASSERT_EQ(rh_count_label(store, lp.project, "File"), k_nfiles);
96+
97+
/* 2. Every fixture file reaches the scoped grep list. */
98+
char **listed = NULL;
99+
int nlisted = 0;
100+
ASSERT_EQ(cbm_store_list_files(store, lp.project, &listed, &nlisted), CBM_STORE_OK);
101+
for (int i = 0; i < k_nfiles; i++) {
102+
bool found = false;
103+
for (int j = 0; j < nlisted; j++) {
104+
if (listed[j] && strcmp(listed[j], k_files[i].name) == 0) {
105+
found = true;
106+
break;
107+
}
108+
}
109+
ASSERT(found);
110+
}
111+
for (int j = 0; j < nlisted; j++) {
112+
free(listed[j]);
113+
}
114+
free(listed);
115+
116+
/* 3. search_code reaches every sibling. */
117+
char args[700];
118+
snprintf(args, sizeof(args),
119+
"{\"project\":\"%s\",\"pattern\":\"repro-marker\",\"mode\":\"files\"}", lp.project);
120+
char *resp = cbm_mcp_handle_tool(lp.srv, "search_code", args);
121+
ASSERT_NOT_NULL(resp);
122+
for (int i = 0; i < k_nfiles; i++) {
123+
ASSERT(strstr(resp, k_files[i].name) != NULL);
124+
}
125+
free(resp);
126+
127+
rh_cleanup(&lp, store);
128+
PASS();
129+
}
130+
131+
/*
132+
* #769 upgrade path: an index written before the File-QN format change holds
133+
* collided File identities. Refreshing it incrementally would give touched
134+
* files new-format QNs while untouched files keep the old ones, a mixed
135+
* graph. try_incremental_or_delete_db must route a stale-format index through
136+
* the full-reindex path exactly once, and the rebuilt index must not force a
137+
* second rebuild on the next unchanged run.
138+
*/
139+
TEST(repro_issue769_stale_format_forces_one_rebuild) {
140+
RProj lp;
141+
cbm_store_t *store = rh_index_files(&lp, k_files, k_nfiles);
142+
ASSERT_NOT_NULL(store);
143+
144+
/* Simulate a pre-change index. */
145+
ASSERT_EQ(cbm_store_set_format_version(store, 0), CBM_STORE_OK);
146+
cbm_store_close(store);
147+
148+
char args[700];
149+
snprintf(args, sizeof(args), "{\"repo_path\":\"%s\"}", lp.tmpdir);
150+
151+
/* Run 1: stale format → forced full rebuild. */
152+
capture_reset();
153+
cbm_log_set_sink_ex(capture_sink, CBM_LOG_SINK_TEE);
154+
char *resp = cbm_mcp_handle_tool(lp.srv, "index_repository", args);
155+
cbm_log_set_sink(NULL);
156+
ASSERT_NOT_NULL(resp);
157+
free(resp);
158+
ASSERT(strstr(g_log_buf, "format_change_reindex") != NULL);
159+
160+
/* Rebuild produced distinct File nodes and stamped the current format. */
161+
store = cbm_store_open_path(lp.dbpath);
162+
ASSERT_NOT_NULL(store);
163+
ASSERT_EQ(rh_count_label(store, lp.project, "File"), k_nfiles);
164+
int fmt = -1;
165+
ASSERT_EQ(cbm_store_get_format_version(store, &fmt), CBM_STORE_OK);
166+
ASSERT_EQ(fmt, CBM_INDEX_FORMAT_VERSION);
167+
cbm_store_close(store);
168+
169+
/* Run 2: unchanged, current format → no second rebuild. */
170+
capture_reset();
171+
cbm_log_set_sink_ex(capture_sink, CBM_LOG_SINK_TEE);
172+
resp = cbm_mcp_handle_tool(lp.srv, "index_repository", args);
173+
cbm_log_set_sink(NULL);
174+
ASSERT_NOT_NULL(resp);
175+
free(resp);
176+
ASSERT(strstr(g_log_buf, "format_change_reindex") == NULL);
177+
ASSERT(strstr(g_log_buf, "path=incremental") != NULL);
178+
179+
store = cbm_store_open_path(lp.dbpath);
180+
ASSERT_NOT_NULL(store);
181+
ASSERT_EQ(rh_count_label(store, lp.project, "File"), k_nfiles);
182+
rh_cleanup(&lp, store);
183+
PASS();
184+
}
185+
186+
SUITE(repro_issue769) {
187+
RUN_TEST(repro_issue769_component_siblings_distinct_and_searchable);
188+
RUN_TEST(repro_issue769_stale_format_forces_one_rebuild);
189+
}

tests/repro/repro_main.c

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ extern void suite_repro_issue548(void);
8181
extern void suite_repro_issue363(void);
8282
extern void suite_repro_issue581(void);
8383
extern void suite_repro_issue787(void);
84+
extern void suite_repro_issue769(void);
8485
/* NEW bugs found by the discovery sweep */
8586
extern void suite_repro_new_ts_class_field_arrow(void);
8687
extern void suite_repro_new_py_tuple_unpack(void);
@@ -165,6 +166,7 @@ int main(void) {
165166
RUN_SUITE(repro_new_cypher_limit_zero);
166167
RUN_SUITE(repro_issue363);
167168
RUN_SUITE(repro_issue581);
169+
RUN_SUITE(repro_issue769);
168170
RUN_SUITE(repro_issue787);
169171
RUN_SUITE(repro_invariant_calls);
170172
RUN_SUITE(repro_invariant_graph);

tests/test_fqn.c

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,47 @@ TEST(fqn_module_siblings_still_share) {
8787
PASS();
8888
}
8989

90+
/* #769: exact __file__ QNs for component siblings. */
91+
TEST(fqn_compute_file_sibling_exact) {
92+
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/badge/badge.component.ts", "__file__"),
93+
"proj.app.badge.badge.component.ts.__file__");
94+
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/badge/badge.component.html", "__file__"),
95+
"proj.app.badge.badge.component.html.__file__");
96+
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/badge/badge.component.scss", "__file__"),
97+
"proj.app.badge.badge.component.scss.__file__");
98+
PASS();
99+
}
100+
101+
TEST(fqn_compute_file_multi_extension) {
102+
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/badge/badge.spec.ts", "__file__"),
103+
"proj.app.badge.badge.spec.ts.__file__");
104+
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "archive.tar.gz", "__file__"),
105+
"proj.archive.tar.gz.__file__");
106+
PASS();
107+
}
108+
109+
TEST(fqn_compute_file_no_extension) {
110+
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "Makefile", "__file__"),
111+
"proj.Makefile.__file__");
112+
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "LICENSE", "__file__"),
113+
"proj.LICENSE.__file__");
114+
PASS();
115+
}
116+
117+
TEST(fqn_compute_file_dotfile) {
118+
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", ".gitignore", "__file__"),
119+
"proj..gitignore.__file__");
120+
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "config/.env", "__file__"),
121+
"proj.config..env.__file__");
122+
PASS();
123+
}
124+
125+
TEST(fqn_compute_file_index_ts) {
126+
ASSERT_FQN(cbm_pipeline_fqn_compute("proj", "app/index.ts", "__file__"),
127+
"proj.app.index.ts.__file__");
128+
PASS();
129+
}
130+
90131
/* ── Nested paths ─────────────────────────────────────────────── */
91132

92133
TEST(fqn_compute_nested_two_levels) {
@@ -593,6 +634,11 @@ SUITE(fqn) {
593634
RUN_TEST(fqn_compute_file_sibling_distinct);
594635
RUN_TEST(fqn_compute_symbol_still_strips);
595636
RUN_TEST(fqn_module_siblings_still_share);
637+
RUN_TEST(fqn_compute_file_sibling_exact);
638+
RUN_TEST(fqn_compute_file_multi_extension);
639+
RUN_TEST(fqn_compute_file_no_extension);
640+
RUN_TEST(fqn_compute_file_dotfile);
641+
RUN_TEST(fqn_compute_file_index_ts);
596642

597643
/* fqn_compute: nested paths */
598644
RUN_TEST(fqn_compute_nested_two_levels);

0 commit comments

Comments
 (0)