@@ -26,6 +26,7 @@ enum { INCR_RING_BUF = 4, INCR_RING_MASK = 3, INCR_TS_BUF = 24, INCR_WAL_BUF = 1
2626#include "foundation/compat_fs.h"
2727#include "foundation/platform.h"
2828
29+ #include <errno.h>
2930#include <stdlib.h>
3031#include <string.h>
3132#include <sys/stat.h>
@@ -121,48 +122,247 @@ static bool *classify_files(cbm_file_info_t *files, int file_count, cbm_file_has
121122 return changed ;
122123}
123124
124- /* Find stored files that no longer exist on disk. Returns count. */
125- static int find_deleted_files (cbm_file_info_t * files , int file_count , cbm_file_hash_t * stored ,
126- int stored_count , char * * * out_deleted ) {
125+ /* Classify stored files that are absent from current discovery. Returns the
126+ * count of truly-deleted files (output via out_deleted) and ALSO collects
127+ * mode-skipped files into out_mode_skipped (caller frees both).
128+ *
129+ * A stored file is classified as:
130+ * - "deleted" — `stat()` returns ENOENT or ENOTDIR. Its nodes will
131+ * be purged and its hash row dropped.
132+ * - "mode-skipped" — `stat()` succeeds. The file exists on disk but the
133+ * current discovery pass didn't visit it (e.g. excluded
134+ * by FAST_SKIP_DIRS in fast/moderate mode). Its nodes
135+ * must be preserved AND its hash row must be carried
136+ * forward into the new DB so subsequent reindexes can
137+ * still see it as "known" rather than treating it as
138+ * new-or-deleted.
139+ *
140+ * Without this distinction, a fast-mode reindex after a full-mode index
141+ * would silently purge every file under `tools/`, `scripts/`, `bin/`,
142+ * `build/`, `docs/`, `__tests__/`, etc. — see task
143+ * claude-connectors/codebase-memory-index-repository-is-destructive-...
144+ * and the 2026-04-13 Skyline incident (packages/mcp/src/tools/ vanished
145+ * from a live graph mid-session).
146+ *
147+ * Mode-skipped hash preservation is the second half of the additive-merge
148+ * contract: dump_and_persist re-upserts these hash rows so the next reindex
149+ * can correctly detect a real on-disk deletion of a mode-skipped file (as
150+ * opposed to seeing it as "never existed" → noop → orphaned graph nodes).
151+ *
152+ * Fail-safe rules (preserve nodes on uncertainty):
153+ * - repo_path NULL → log error and preserve everything (return 0
154+ * deletions, empty mode_skipped). The caller contract is that
155+ * repo_path is required; a NULL means a misconfigured pipeline,
156+ * not a deletion signal.
157+ * - snprintf truncation (combined path ≥ CBM_SZ_4K) → preserve. We can't
158+ * reliably stat a truncated path. Treat as mode-skipped.
159+ * - stat() errno != ENOENT/ENOTDIR (EACCES, EIO, ELOOP, transient NFS,
160+ * etc.) → preserve. The file may exist; we just can't see it right now.
161+ * Treat as mode-skipped.
162+ *
163+ * Note: we use stat() (not lstat()) on purpose. A symlink whose target was
164+ * deleted should be classified as deleted from the indexer's perspective
165+ * because the indexer follows symlinks during discovery — a stale symlink
166+ * has no source to parse. */
167+ static int find_deleted_files (const char * repo_path , cbm_file_info_t * files , int file_count ,
168+ cbm_file_hash_t * stored , int stored_count , char * * * out_deleted ,
169+ cbm_file_hash_t * * out_mode_skipped , int * out_mode_skipped_count ) {
170+ * out_deleted = NULL ;
171+ * out_mode_skipped = NULL ;
172+ * out_mode_skipped_count = 0 ;
173+
174+ if (!repo_path ) {
175+ /* Misconfigured pipeline. Preserve everything rather than risk
176+ * silently re-introducing the destructive overwrite this function
177+ * was rewritten to prevent. */
178+ cbm_log_error ("incremental.err" , "msg" , "find_deleted_files_null_repo_path" );
179+ return 0 ;
180+ }
181+
127182 CBMHashTable * current = cbm_ht_create ((size_t )file_count * PAIR_LEN );
128183 for (int i = 0 ; i < file_count ; i ++ ) {
129184 cbm_ht_set (current , files [i ].rel_path , & files [i ]);
130185 }
131186
132- int count = 0 ;
133- int cap = CBM_SZ_64 ;
134- char * * deleted = malloc ((size_t )cap * sizeof (char * ));
187+ int del_count = 0 ;
188+ int del_cap = CBM_SZ_64 ;
189+ char * * deleted = malloc ((size_t )del_cap * sizeof (char * ));
190+ if (!deleted ) {
191+ cbm_log_error ("incremental.err" , "msg" , "find_deleted_files_oom" );
192+ cbm_ht_free (current );
193+ return 0 ;
194+ }
195+
196+ int ms_count = 0 ;
197+ int ms_cap = CBM_SZ_64 ;
198+ cbm_file_hash_t * mode_skipped = malloc ((size_t )ms_cap * sizeof (cbm_file_hash_t ));
199+ if (!mode_skipped ) {
200+ cbm_log_error ("incremental.err" , "msg" , "find_deleted_files_oom_ms" );
201+ free (deleted );
202+ cbm_ht_free (current );
203+ return 0 ;
204+ }
135205
136206 for (int i = 0 ; i < stored_count ; i ++ ) {
137- if (!cbm_ht_get (current , stored [i ].rel_path )) {
138- if (count >= cap ) {
139- cap *= PAIR_LEN ;
140- char * * tmp = realloc (deleted , (size_t )cap * sizeof (char * ));
207+ if (cbm_ht_get (current , stored [i ].rel_path )) {
208+ continue ; /* still visited by current pass */
209+ }
210+ /* Not in current discovery — check if it's truly deleted or just
211+ * mode-skipped (excluded by FAST_SKIP_DIRS etc.). */
212+ bool preserve = false;
213+ char abs_path [CBM_SZ_4K ];
214+ int n = snprintf (abs_path , sizeof (abs_path ), "%s/%s" , repo_path , stored [i ].rel_path );
215+ if (n < 0 || n >= (int )sizeof (abs_path )) {
216+ /* Truncation or encoding error — can't reliably stat. Preserve. */
217+ cbm_log_warn ("incremental.path_truncated" , "rel_path" , stored [i ].rel_path );
218+ preserve = true;
219+ } else {
220+ struct stat st ;
221+ if (stat (abs_path , & st ) == 0 ) {
222+ /* File exists on disk — mode-skipped, not deleted. */
223+ preserve = true;
224+ } else if (errno != ENOENT && errno != ENOTDIR ) {
225+ /* Transient or permission error — fail safe by preserving.
226+ * EACCES, EIO, ELOOP, ENAMETOOLONG, etc. */
227+ cbm_log_warn ("incremental.stat_uncertain" , "rel_path" , stored [i ].rel_path , "errno" ,
228+ itoa_buf (errno ));
229+ preserve = true;
230+ }
231+ }
232+
233+ if (preserve ) {
234+ /* Carry forward the existing hash row so subsequent reindexes
235+ * can correctly classify this file. */
236+ if (ms_count >= ms_cap ) {
237+ ms_cap *= PAIR_LEN ;
238+ cbm_file_hash_t * tmp = realloc (mode_skipped , (size_t )ms_cap * sizeof (* tmp ));
141239 if (!tmp ) {
240+ cbm_log_error ("incremental.err" , "msg" ,
241+ "find_deleted_files_realloc_oom_ms" );
142242 break ;
143243 }
144- deleted = tmp ;
244+ mode_skipped = tmp ;
145245 }
146- deleted [count ++ ] = strdup (stored [i ].rel_path );
246+ char * rp = strdup (stored [i ].rel_path );
247+ char * sh = stored [i ].sha256 ? strdup (stored [i ].sha256 ) : NULL ;
248+ if (!rp || (stored [i ].sha256 && !sh )) {
249+ /* OOM mid-record. Drop this entry rather than persist a
250+ * row with a NULL rel_path that would silently fail the
251+ * NOT NULL constraint in upsert and reintroduce the
252+ * orphaned-node bug. */
253+ cbm_log_error ("incremental.err" , "msg" , "find_deleted_files_strdup_oom" ,
254+ "rel_path" , stored [i ].rel_path );
255+ free (rp );
256+ free (sh );
257+ break ;
258+ }
259+ mode_skipped [ms_count ].project = NULL ; /* unused by upsert API */
260+ mode_skipped [ms_count ].rel_path = rp ;
261+ mode_skipped [ms_count ].sha256 = sh ;
262+ mode_skipped [ms_count ].mtime_ns = stored [i ].mtime_ns ;
263+ mode_skipped [ms_count ].size = stored [i ].size ;
264+ ms_count ++ ;
265+ continue ;
266+ }
267+
268+ /* File is truly gone — record for purge. */
269+ if (del_count >= del_cap ) {
270+ del_cap *= PAIR_LEN ;
271+ char * * tmp = realloc (deleted , (size_t )del_cap * sizeof (char * ));
272+ if (!tmp ) {
273+ cbm_log_error ("incremental.err" , "msg" , "find_deleted_files_realloc_oom" );
274+ break ;
275+ }
276+ deleted = tmp ;
147277 }
278+ deleted [del_count ++ ] = strdup (stored [i ].rel_path );
148279 }
149280
150281 cbm_ht_free (current );
151282 * out_deleted = deleted ;
152- return count ;
283+ * out_mode_skipped = mode_skipped ;
284+ * out_mode_skipped_count = ms_count ;
285+ return del_count ;
286+ }
287+
288+ /* Free a mode_skipped array allocated by find_deleted_files. */
289+ static void free_mode_skipped (cbm_file_hash_t * ms , int count ) {
290+ if (!ms ) {
291+ return ;
292+ }
293+ for (int i = 0 ; i < count ; i ++ ) {
294+ free ((void * )ms [i ].rel_path );
295+ free ((void * )ms [i ].sha256 );
296+ }
297+ free (ms );
153298}
154299
155300/* ── Persist file hashes ─────────────────────────────────────────── */
156301
302+ /* Persist file hash rows for the current discovery and any mode-skipped
303+ * files preserved from the previous DB.
304+ *
305+ * Partial-failure policy: an `upsert` failure on any single row is logged
306+ * as a warning and the loop continues. We deliberately do NOT abort the
307+ * whole reindex on a single bad row — partial preservation is better than
308+ * total loss, and a transient failure on one file should not invalidate
309+ * the entire incremental update. The trade-off is that a silently-failed
310+ * row produces the same downstream effect as if the file were never
311+ * indexed at all (forced re-parse on the next run for current-files,
312+ * potential orphaned-node revival for mode_skipped). The warning surface
313+ * is the only signal that something went wrong. */
157314static void persist_hashes (cbm_store_t * store , const char * project , cbm_file_info_t * files ,
158- int file_count ) {
315+ int file_count , const cbm_file_hash_t * mode_skipped ,
316+ int mode_skipped_count ) {
317+ int current_failed = 0 ;
318+ int ms_failed = 0 ;
319+
320+ /* Current discovery: re-stat to capture any mtime/size that changed
321+ * during the run, and write fresh hash rows for visited files. */
159322 for (int i = 0 ; i < file_count ; i ++ ) {
160323 struct stat st ;
161324 if (stat (files [i ].path , & st ) != 0 ) {
162325 continue ;
163326 }
164- cbm_store_upsert_file_hash (store , project , files [i ].rel_path , "" , stat_mtime_ns (& st ),
165- st .st_size );
327+ int rc = cbm_store_upsert_file_hash (store , project , files [i ].rel_path , "" ,
328+ stat_mtime_ns (& st ), st .st_size );
329+ if (rc != CBM_STORE_OK ) {
330+ cbm_log_warn ("incremental.persist_hash_failed" , "scope" , "current" , "rel_path" ,
331+ files [i ].rel_path , "rc" , itoa_buf (rc ));
332+ current_failed ++ ;
333+ }
334+ }
335+
336+ /* Mode-skipped (preserved): re-upsert hash rows from the previous DB
337+ * so the next reindex can still classify these files correctly. Without
338+ * this, an orphaned-node bug emerges where:
339+ * - full mode indexes everything
340+ * - fast mode runs and drops mode-skipped hash rows
341+ * - file is then deleted on disk
342+ * - next reindex's stored hashes don't include the file → noop or
343+ * can't detect the deletion → graph nodes for the deleted file
344+ * remain forever (or until a destructive rebuild).
345+ *
346+ * A failure here is more serious than a current-files failure because
347+ * it can revive the orphaned-node bug for that specific file. Logged
348+ * with scope=mode_skipped so the warning is searchable. */
349+ if (mode_skipped ) {
350+ for (int i = 0 ; i < mode_skipped_count ; i ++ ) {
351+ int rc = cbm_store_upsert_file_hash (store , project , mode_skipped [i ].rel_path ,
352+ mode_skipped [i ].sha256 ? mode_skipped [i ].sha256
353+ : "" ,
354+ mode_skipped [i ].mtime_ns , mode_skipped [i ].size );
355+ if (rc != CBM_STORE_OK ) {
356+ cbm_log_warn ("incremental.persist_hash_failed" , "scope" , "mode_skipped" ,
357+ "rel_path" , mode_skipped [i ].rel_path , "rc" , itoa_buf (rc ));
358+ ms_failed ++ ;
359+ }
360+ }
361+ }
362+
363+ if (current_failed > 0 || ms_failed > 0 ) {
364+ cbm_log_warn ("incremental.persist_summary" , "current_failed" , itoa_buf (current_failed ),
365+ "mode_skipped_failed" , itoa_buf (ms_failed ));
166366 }
167367}
168368
@@ -257,9 +457,14 @@ static void run_postpasses(cbm_pipeline_ctx_t *ctx, cbm_file_info_t *changed_fil
257457 itoa_buf ((int )elapsed_ms (t )));
258458 }
259459}
260- /* Delete old DB and dump merged graph + hashes to disk. */
460+ /* Delete old DB and dump merged graph + hashes to disk.
461+ * Mode-skipped hash rows are preserved across the rebuild so subsequent
462+ * reindexes can correctly distinguish "never indexed" from "indexed but
463+ * not visited this pass". */
261464static void dump_and_persist (cbm_gbuf_t * gbuf , const char * db_path , const char * project ,
262- cbm_file_info_t * files , int file_count , const char * repo_path ) {
465+ cbm_file_info_t * files , int file_count ,
466+ const cbm_file_hash_t * mode_skipped , int mode_skipped_count ,
467+ const char * repo_path ) {
263468 struct timespec t ;
264469 cbm_clock_gettime (CLOCK_MONOTONIC , & t );
265470
@@ -277,7 +482,7 @@ static void dump_and_persist(cbm_gbuf_t *gbuf, const char *db_path, const char *
277482
278483 cbm_store_t * hash_store = cbm_store_open_path (db_path );
279484 if (hash_store ) {
280- persist_hashes (hash_store , project , files , file_count );
485+ persist_hashes (hash_store , project , files , file_count , mode_skipped , mode_skipped_count );
281486
282487 /* FTS5 rebuild after incremental dump. The btree dump path bypasses
283488 * any triggers that could have kept nodes_fts synchronized, so we
@@ -329,18 +534,27 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
329534 bool * is_changed =
330535 classify_files (files , file_count , stored , stored_count , & n_changed , & n_unchanged );
331536
332- /* Find deleted files */
537+ /* Classify stored files absent from current discovery: truly-deleted
538+ * (purge) vs mode-skipped (preserve nodes AND hash rows). */
333539 char * * deleted = NULL ;
334- int deleted_count = find_deleted_files (files , file_count , stored , stored_count , & deleted );
540+ cbm_file_hash_t * mode_skipped = NULL ;
541+ int mode_skipped_count = 0 ;
542+ int deleted_count =
543+ find_deleted_files (cbm_pipeline_repo_path (p ), files , file_count , stored , stored_count ,
544+ & deleted , & mode_skipped , & mode_skipped_count );
335545
336546 cbm_log_info ("incremental.classify" , "changed" , itoa_buf (n_changed ), "unchanged" ,
337- itoa_buf (n_unchanged ), "deleted" , itoa_buf (deleted_count ));
547+ itoa_buf (n_unchanged ), "deleted" , itoa_buf (deleted_count ), "mode_skipped" ,
548+ itoa_buf (mode_skipped_count ));
338549
339- /* Fast path: nothing changed → skip */
550+ /* Fast path: nothing changed → skip. The on-disk DB is left untouched,
551+ * which means existing hash rows (including for any mode-skipped files
552+ * that were already preserved by an earlier run) remain intact. */
340553 if (n_changed == 0 && deleted_count == 0 ) {
341554 cbm_log_info ("incremental.noop" , "reason" , "no_changes" );
342555 free (is_changed );
343556 free (deleted );
557+ free_mode_skipped (mode_skipped , mode_skipped_count );
344558 cbm_store_free_file_hashes (stored , stored_count );
345559 cbm_store_close (store );
346560 return 0 ;
@@ -380,6 +594,7 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
380594 free (deleted [i ]);
381595 }
382596 free (deleted );
597+ free_mode_skipped (mode_skipped , mode_skipped_count );
383598 cbm_store_close (store );
384599 return CBM_NOT_FOUND ;
385600 }
@@ -435,8 +650,13 @@ int cbm_pipeline_run_incremental(cbm_pipeline_t *p, const char *db_path, cbm_fil
435650 cbm_registry_free (registry );
436651 cbm_path_alias_collection_free (path_aliases );
437652
438- /* Step 7: Dump to disk */
439- dump_and_persist (existing , db_path , project , files , file_count , cbm_pipeline_repo_path (p ));
653+ /* Step 7: Dump to disk (preserves mode-skipped hash rows so the next
654+ * reindex can correctly classify those files instead of seeing them
655+ * as never-existed; also exports a fast-mode artifact when one is
656+ * already present alongside the repo). */
657+ dump_and_persist (existing , db_path , project , files , file_count , mode_skipped ,
658+ mode_skipped_count , cbm_pipeline_repo_path (p ));
659+ free_mode_skipped (mode_skipped , mode_skipped_count );
440660 cbm_gbuf_free (existing );
441661
442662 cbm_log_info ("incremental.done" , "elapsed_ms" , itoa_buf ((int )elapsed_ms (t0 )));
0 commit comments