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+ }
0 commit comments