diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 04316627b..1812f38fc 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -4204,17 +4204,6 @@ static coverage_path_result_t coverage_normalize_rel(const char *input, bool all return written > 0U || allow_root ? COVERAGE_PATH_OK : COVERAGE_PATH_INVALID; } -static int64_t coverage_stat_mtime_ns(const struct stat *st) { -#ifdef __APPLE__ - return ((int64_t)st->st_mtimespec.tv_sec * (int64_t)CBM_NSEC_PER_SEC) + - (int64_t)st->st_mtimespec.tv_nsec; -#elif defined(_WIN32) - return (int64_t)st->st_mtime * (int64_t)CBM_NSEC_PER_SEC; -#else - return ((int64_t)st->st_mtim.tv_sec * (int64_t)CBM_NSEC_PER_SEC) + (int64_t)st->st_mtim.tv_nsec; -#endif -} - static const char *coverage_path_freshness(cbm_store_t *store, const char *project, const char *root_path, const char *rel_path, bool *outside) { @@ -4228,8 +4217,13 @@ static const char *coverage_path_freshness(cbm_store_t *store, const char *proje if (n < 0 || (size_t)n >= sizeof(abs_path)) { return "unavailable"; } - struct stat st; - if (stat(abs_path, &st) != 0) { + /* #1714: mtime_ns must come from the SAME source the indexer recorded it + * with — cbm_path_info_utf8. Recomputing from struct stat truncates to + * seconds on Windows (st_mtime), while the file_hashes record carries + * FILETIME-resolution nanoseconds, so a byte-identical file never matched + * and every path was reported metadata_changed. */ + cbm_path_info_t info; + if (cbm_path_info_utf8(abs_path, &info) != 0) { return "missing"; } if (!cbm_path_within_root(root_path, abs_path)) { @@ -4245,7 +4239,7 @@ static const char *coverage_path_freshness(cbm_store_t *store, const char *proje if (rc != CBM_STORE_OK) { return "unavailable"; } - bool matches = hash.mtime_ns == coverage_stat_mtime_ns(&st) && hash.size == st.st_size; + bool matches = hash.mtime_ns == info.mtime_ns && hash.size == info.size; cbm_store_clear_file_hash(&hash); return matches ? "metadata_match" : "metadata_changed"; } diff --git a/src/pipeline/pipeline_incremental.c b/src/pipeline/pipeline_incremental.c index fca1c8f06..0bf67670e 100644 --- a/src/pipeline/pipeline_incremental.c +++ b/src/pipeline/pipeline_incremental.c @@ -102,18 +102,6 @@ static const char *itoa_buf(int v) { return buf[idx]; } -/* ── Platform-portable mtime_ns ──────────────────────────────────── */ - -static int64_t stat_mtime_ns(const struct stat *st) { -#ifdef __APPLE__ - return ((int64_t)st->st_mtimespec.tv_sec * CBM_NS_PER_SEC) + (int64_t)st->st_mtimespec.tv_nsec; -#elif defined(_WIN32) - return (int64_t)st->st_mtime * CBM_NS_PER_SEC; -#else - return ((int64_t)st->st_mtim.tv_sec * CBM_NS_PER_SEC) + (int64_t)st->st_mtim.tv_nsec; -#endif -} - static const char *incr_mode_name(int mode) { switch (mode) { case CBM_MODE_FULL: @@ -683,14 +671,20 @@ static bool *classify_files(cbm_file_info_t *files, int file_count, cbm_file_has continue; } - struct stat st; - if (stat(files[i].path, &st) != 0) { + /* #1714: compare against the SAME source the hash writer used. The + * manifest hash is written from cbm_path_info_utf8 (see + * semantic_manifest_hash_file), so a stat()-based comparison is + * internally inconsistent: on Windows stat() truncates mtime to + * seconds while the recorded value carries FILETIME nanoseconds, which + * made every file look changed on each incremental pass. */ + cbm_path_info_t info; + if (cbm_path_info_utf8(files[i].path, &info) != 0) { changed[i] = true; n_changed++; continue; } - if (stat_mtime_ns(&st) != h->mtime_ns || st.st_size != h->size) { + if (info.mtime_ns != h->mtime_ns || info.size != h->size) { changed[i] = true; n_changed++; } else { diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 278d33e68..30735b8d4 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -2727,20 +2727,15 @@ TEST(tool_check_index_coverage_accepts_truncated_ignored_catalog_for_fresh_path_ ASSERT_NOT_NULL(store); char source_path[512]; snprintf(source_path, sizeof(source_path), "%s/project/main.go", tmp); - struct stat source_stat; - ASSERT_EQ(stat(source_path, &source_stat), 0); -#ifdef __APPLE__ - int64_t source_mtime_ns = - ((int64_t)source_stat.st_mtimespec.tv_sec * (int64_t)CBM_NSEC_PER_SEC) + - (int64_t)source_stat.st_mtimespec.tv_nsec; -#elif defined(_WIN32) - int64_t source_mtime_ns = (int64_t)source_stat.st_mtime * (int64_t)CBM_NSEC_PER_SEC; -#else - int64_t source_mtime_ns = ((int64_t)source_stat.st_mtim.tv_sec * (int64_t)CBM_NSEC_PER_SEC) + - (int64_t)source_stat.st_mtim.tv_nsec; -#endif - ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", source_mtime_ns, - source_stat.st_size), + /* The fixture must record the hash with the SAME mtime source the indexer + * writes with — cbm_path_info_utf8 — not struct stat. On Windows stat + * truncates to seconds while the stored record carries FILETIME-derived + * nanoseconds, so a stat-written fixture would never compare equal and the + * metadata_match contract below would fail. */ + cbm_path_info_t path_info; + ASSERT_EQ(cbm_path_info_utf8(source_path, &path_info), 0); + ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", path_info.mtime_ns, + path_info.size), CBM_STORE_OK); cbm_project_t project = {0}; ASSERT_EQ(cbm_store_get_project(store, "test-project", &project), CBM_STORE_OK); @@ -2782,6 +2777,56 @@ TEST(tool_check_index_coverage_accepts_truncated_ignored_catalog_for_fresh_path_ PASS(); } +/* #1714: coverage freshness must compare mtime_ns at the SAME precision the + * indexer records it. The pipeline records cbm_path_info_utf8's value (which + * on Windows derives from FILETIME — nanosecond), while the freshness reader + * used to recompute from struct stat, which on Windows truncates to seconds + * (st_mtime). A byte-identical file therefore never matched and every path was + * reported metadata_changed. The reader now uses the indexer's own source. */ +TEST(tool_check_index_coverage_freshness_uses_indexer_mtime_source_issue1714) { + char tmp[256]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + cbm_store_t *store = cbm_mcp_server_store(srv); + ASSERT_NOT_NULL(store); + + char source_path[512]; + snprintf(source_path, sizeof(source_path), "%s/project/main.go", tmp); + cbm_path_info_t info; + ASSERT_EQ(cbm_path_info_utf8(source_path, &info), 0); + + /* The hash exactly as the indexer writes it: same source, ns precision. */ + ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", info.mtime_ns, + info.size), + CBM_STORE_OK); + + char *response = cbm_mcp_handle_tool( + srv, "check_index_coverage", "{\"project\":\"test-project\",\"paths\":[\"main.go\"]}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "\"freshness\":\"metadata_match\"")); + free(response); + + /* A hash stored at seconds precision — what a stat-based reader previously + * compared against — must NOT match an unchanged file: the comparison must + * stay nanosecond-exact, or part of mtime resolution is silently dropped. */ + int64_t seconds_mtime_ns = (info.mtime_ns / (int64_t)CBM_NSEC_PER_SEC) * + (int64_t)CBM_NSEC_PER_SEC; + if (seconds_mtime_ns != info.mtime_ns) { + ASSERT_EQ(cbm_store_upsert_file_hash(store, "test-project", "main.go", "", + seconds_mtime_ns, info.size), + CBM_STORE_OK); + response = cbm_mcp_handle_tool( + srv, "check_index_coverage", "{\"project\":\"test-project\",\"paths\":[\"main.go\"]}"); + ASSERT_NOT_NULL(response); + ASSERT_NOT_NULL(strstr(response, "\"freshness\":\"metadata_changed\"")); + free(response); + } + + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + TEST(tool_check_index_coverage_rejects_stale_generation) { char tmp[256]; cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); @@ -11332,6 +11377,7 @@ SUITE(mcp) { RUN_TEST(tool_check_index_coverage_preserves_multiple_scope_labels); RUN_TEST(tool_check_index_coverage_accepts_truncated_ignored_catalog_for_fresh_path_issue1613); RUN_TEST(tool_check_index_coverage_rejects_stale_generation); + RUN_TEST(tool_check_index_coverage_freshness_uses_indexer_mtime_source_issue1714); RUN_TEST(tool_check_index_coverage_requires_source_when_file_metadata_changed); RUN_TEST(tool_check_index_coverage_surfaces_lookup_errors); RUN_TEST(tool_index_status_includes_git_metadata);