From d9cfd9988a46e80441bcf198148c648a1928483b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 13:50:44 +0900 Subject: [PATCH 1/5] Fix backfill fold retry and dry-run signals (#1613 #1703 #1919) --- DEVELOPER_GUIDE.md | 7 ++ changelog.d/unreleased/1613.fixed.md | 24 ++++++ .../Cli/IndexCommandRunner.Maintenance.cs | 84 +++++++++++++------ src/CodeIndex/Cli/IndexCommandRunner.cs | 1 + src/CodeIndex/Cli/JsonOutputContracts.cs | 4 + src/CodeIndex/Database/DbWriter.cs | 24 ++++++ src/CodeIndex/Mcp/McpServer.cs | 4 +- src/CodeIndex/Mcp/McpToolDefinitions.cs | 8 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 65 ++++++++++---- .../IndexCommandRunnerTests.cs | 72 ++++++++++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 61 ++++++++++++++ 11 files changed, 308 insertions(+), 46 deletions(-) create mode 100644 changelog.d/unreleased/1613.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index ff1318f99f..24fc5f2789 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -49,6 +49,13 @@ cdidx search AuthService --db /artifacts/codeindex.db --immutable Mutating commands such as `index`, `backfill-fold`, `optimize`, and `vacuum` require writable storage and reject read-only database opens. +`backfill-fold --dry-run` previews the folded-key rows that would be rewritten +without mutating the DB or stamping FoldReady. The MCP `backfill_fold` tool +accepts the same preview as `dry_run: true`, and also accepts `force: true` to +rewrite all folded keys when an operator needs to recover from suspicious fold +metadata or row state even though the stored version/fingerprint appears +current. + ## Filesystem Permissions On POSIX filesystems, cdidx creates `.cdidx/` with mode `0700` and applies mode diff --git a/changelog.d/unreleased/1613.fixed.md b/changelog.d/unreleased/1613.fixed.md new file mode 100644 index 0000000000..68796056e9 --- /dev/null +++ b/changelog.d/unreleased/1613.fixed.md @@ -0,0 +1,24 @@ +--- +category: fixed +issues: + - 1613 + - 1703 + - 1919 +affected: + - src/CodeIndex/Database/DbWriter.cs + - src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpServer.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **Backfill folded-key maintenance is now previewable and retry-aware (#1613, #1703, #1919)** — `backfill-fold --dry-run` and MCP `backfill_fold` `dry_run` preview affected rows without writing, MCP responses report FoldReady before/after plus already-complete state, and `force` can rewrite all keys when recovery requires bypassing metadata short-circuits. + +## 日本語 + +- **folded-key の保守処理をプレビュー可能かつ再試行判定しやすくしました (#1613, #1703, #1919)** — `backfill-fold --dry-run` と MCP `backfill_fold` の `dry_run` は書き込まず対象行数を返し、MCP 応答は FoldReady の前後状態と完了済み状態を示し、復旧時には `force` で metadata の短絡判定を避けて全 key を再生成できます。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs index be8289d306..bfeccf7df0 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs @@ -9,7 +9,7 @@ public static partial class IndexCommandRunner { private static readonly string[] AcceptedBackfillFoldFlags = [ - "--db", "--json", "--help", + "--db", "--json", "--dry-run", "--help", ]; public static int RunBackfillFold(string[] cmdArgs, JsonSerializerOptions jsonOptions) => @@ -167,6 +167,7 @@ internal static int RunBackfillFold( var writer = new DbWriter(db); var userVersionBefore = db.GetUserVersion(); + var foldReadyBefore = (userVersionBefore & DbContext.FoldReadyFlag) != 0; var currentFoldVersion = NameFold.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); var currentFoldFingerprint = NameFold.Fingerprint(); var storedFoldVersion = db.GetMetaString("fold_key_version"); @@ -177,26 +178,41 @@ internal static int RunBackfillFold( var rewriteAll = storedFoldVersion != currentFoldVersion || storedFoldFingerprint != currentFoldFingerprint; - var (symbols, symbolReferences) = writer.BackfillFoldedColumns( - rewriteAll, - backfillCancellation.Token); - // MarkFoldReady re-verifies inside a BEGIN IMMEDIATE so a concurrent writer cannot - // insert NULL-folded rows between the verify and the stamp. Issue #1535. - // MarkFoldReady は BEGIN IMMEDIATE 内で再検証するため、concurrent writer による - // NULL 行差し込みで fold_ready が嘘になるのを防ぐ。Issue #1535。 - var verified = writer.MarkFoldReady(); - if (!verified) + var symbols = 0; + var symbolReferences = 0; + var verified = false; + var userVersionAfter = userVersionBefore; + + if (options.DryRun) { - return WriteCommandError( - options.Json, - jsonOptions, - "folded-name backfill verification failed: some rows still have NULL folded values", - CommandExitCodes.DatabaseError, - "Retry `cdidx backfill-fold`. If the DB still does not verify, rebuild it with `cdidx index --rebuild`.", - CommandErrorCodes.DbError); + (symbols, symbolReferences) = writer.CountBackfillFoldedColumns(rewriteAll); } + else + { + using var transaction = writer.BeginTransaction(); + (symbols, symbolReferences) = writer.BackfillFoldedColumns( + rewriteAll, + backfillCancellation.Token); + // MarkFoldReady re-verifies in the same transaction so rows, metadata, and + // FoldReady stamp commit or roll back together. + // 同一 transaction 内で再検証し、行・metadata・FoldReady stamp を原子的に扱う。 + verified = writer.MarkFoldReady(); + if (!verified) + { + return WriteCommandError( + options.Json, + jsonOptions, + "folded-name backfill verification failed: some rows still have NULL folded values", + CommandExitCodes.DatabaseError, + "Retry `cdidx backfill-fold`. If the DB still does not verify, rebuild it with `cdidx index --rebuild`.", + CommandErrorCodes.DbError); + } - var userVersionAfter = db.GetUserVersion(); + transaction.Commit(); + userVersionAfter = db.GetUserVersion(); + } + var foldReadyAfter = (userVersionAfter & DbContext.FoldReadyFlag) != 0; + var wasAlreadyComplete = foldReadyBefore && !rewriteAll && symbols == 0 && symbolReferences == 0; if (options.Json) { @@ -204,20 +220,32 @@ internal static int RunBackfillFold( symbols, symbolReferences, rewriteAll, + options.DryRun, + wasAlreadyComplete, + foldReadyBefore, + foldReadyAfter, verified, userVersionBefore, userVersionAfter, - true), jsonContext.BackfillFoldJsonResult)); + foldReadyAfter), jsonContext.BackfillFoldJsonResult)); } else { - Console.WriteLine("Backfilling folded-name columns ..."); - Console.WriteLine($" symbols: {ConsoleUi.Counted(symbols, "row", format: "N0")} rewritten"); - Console.WriteLine($" symbol_references: {ConsoleUi.Counted(symbolReferences, "row", format: "N0")} rewritten"); + Console.WriteLine(options.DryRun + ? "Previewing folded-name column backfill ..." + : "Backfilling folded-name columns ..."); + var verb = options.DryRun ? "would be rewritten" : "rewritten"; + Console.WriteLine($" symbols: {ConsoleUi.Counted(symbols, "row", format: "N0")} {verb}"); + Console.WriteLine($" symbol_references: {ConsoleUi.Counted(symbolReferences, "row", format: "N0")} {verb}"); if (rewriteAll) Console.WriteLine(" mode: full folded-key refresh (fold metadata missing or mismatched)"); - Console.WriteLine($" verified: {(verified ? "yes" : "no")}"); - Console.WriteLine($" stamp: FoldReady bit set (user_version: {userVersionBefore} -> {userVersionAfter})"); + Console.WriteLine($" already complete: {(wasAlreadyComplete ? "yes" : "no")}"); + Console.WriteLine($" fold_ready: {foldReadyBefore} -> {foldReadyAfter}"); + if (!options.DryRun) + { + Console.WriteLine($" verified: {(verified ? "yes" : "no")}"); + Console.WriteLine($" stamp: FoldReady bit set (user_version: {userVersionBefore} -> {userVersionAfter})"); + } } return CommandExitCodes.Success; @@ -286,6 +314,7 @@ private static BackfillFoldCommandOptions ParseBackfillFoldArgs(string[] args) { var dbPath = Path.Combine(".cdidx", "codeindex.db"); var json = false; + var dryRun = false; for (int i = 0; i < args.Length; i++) { @@ -297,8 +326,11 @@ private static BackfillFoldCommandOptions ParseBackfillFoldArgs(string[] args) case "--json": json = true; break; + case "--dry-run": + dryRun = true; + break; case "--help" or "-h": - return new BackfillFoldCommandOptions { ShowHelp = true, DbPath = dbPath, Json = json }; + return new BackfillFoldCommandOptions { ShowHelp = true, DbPath = dbPath, Json = json, DryRun = dryRun }; default: if (args[i].StartsWith('-')) { @@ -310,6 +342,7 @@ private static BackfillFoldCommandOptions ParseBackfillFoldArgs(string[] args) { DbPath = dbPath, Json = json, + DryRun = dryRun, ParseError = $"backfill-fold does not accept positional arguments: '{args[i]}'" }; break; @@ -320,6 +353,7 @@ private static BackfillFoldCommandOptions ParseBackfillFoldArgs(string[] args) { DbPath = dbPath, Json = json, + DryRun = dryRun, }; } diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 395373bbd1..bec39d4849 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -1251,6 +1251,7 @@ public sealed class BackfillFoldCommandOptions public bool ShowHelp { get; init; } public string DbPath { get; init; } = Path.Combine(".cdidx", "codeindex.db"); public bool Json { get; init; } + public bool DryRun { get; init; } public string? ParseError { get; init; } } diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 4de679feff..6ef8c519fa 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -14,6 +14,10 @@ internal sealed record BackfillFoldJsonResult( [property: JsonPropertyName("symbols")] int Symbols, [property: JsonPropertyName("symbol_references")] int SymbolReferences, [property: JsonPropertyName("rewrite_all")] bool RewriteAll, + [property: JsonPropertyName("dry_run")] bool DryRun, + [property: JsonPropertyName("was_already_complete")] bool WasAlreadyComplete, + [property: JsonPropertyName("fold_ready_before")] bool FoldReadyBefore, + [property: JsonPropertyName("fold_ready_after")] bool FoldReadyAfter, [property: JsonPropertyName("verified")] bool Verified, [property: JsonPropertyName("user_version_before")] int UserVersionBefore, [property: JsonPropertyName("user_version_after")] int UserVersionAfter, diff --git a/src/CodeIndex/Database/DbWriter.cs b/src/CodeIndex/Database/DbWriter.cs index c3b2722ff6..1ac42ff703 100644 --- a/src/CodeIndex/Database/DbWriter.cs +++ b/src/CodeIndex/Database/DbWriter.cs @@ -3253,6 +3253,30 @@ private bool SymbolExtractorVersionMatchesCurrent(string? lang) return (symbols, symbolReferences); } + public (int Symbols, int SymbolReferences) CountBackfillFoldedColumns(bool rewriteAll = false) + { + using var symbols = _conn.CreateCommand(); + symbols.CommandText = rewriteAll + ? "SELECT COUNT(*) FROM symbols WHERE name IS NOT NULL" + : "SELECT COUNT(*) FROM symbols WHERE name IS NOT NULL AND name_folded IS NULL"; + + using var references = _conn.CreateCommand(); + references.CommandText = rewriteAll + ? "SELECT COUNT(*) FROM symbol_references WHERE symbol_name IS NOT NULL OR container_name IS NOT NULL" + : @"SELECT COUNT(*) + FROM symbol_references + WHERE (symbol_name IS NOT NULL AND symbol_name_folded IS NULL) + OR (container_name IS NOT NULL AND container_name_folded IS NULL)"; + + return (ToInt32Count(symbols.ExecuteScalar()), ToInt32Count(references.ExecuteScalar())); + } + + private static int ToInt32Count(object? value) + { + var count = value is long l ? l : (value is int i ? i : 0); + return count > int.MaxValue ? int.MaxValue : (int)count; + } + private int BackfillSymbolFoldedRows(bool rewriteAll, CancellationToken cancellationToken) { var rows = new List<(long Id, string Name)>(); diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index f1fedbf032..b0540aee8f 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -2049,7 +2049,7 @@ private async Task HandleToolsCallAsync(JsonNode? id, JsonNode? callPa "symbol_hotspots" => ExecuteSymbolHotspots(id, args), "ping" => ExecutePing(id), "index" => ExecuteIndex(id, args, progressToken), - "backfill_fold" => ExecuteBackfillFold(id, progressToken), + "backfill_fold" => ExecuteBackfillFold(id, args, progressToken), "suggest_improvement" => await ExecuteSuggestImprovementAsync(id, args).ConfigureAwait(false), _ => CreateErrorResponse(hasId: true, id: id, code: -32602, message: $"Unknown tool: {toolName}", category: McpErrorEnvelope.CategoryToolUnknown, @@ -2829,7 +2829,7 @@ private static JsonArray BuildToolExamples(string name) }, }, "index" => new JsonObject { ["path"] = ".", ["rebuild"] = false }, - "backfill_fold" => new JsonObject(), + "backfill_fold" => new JsonObject { ["dry_run"] = false, ["force"] = false }, "symbol_hotspots" => new JsonObject { ["lang"] = "csharp", ["limit"] = 10 }, "unused_symbols" => new JsonObject { ["lang"] = "csharp", ["limit"] = 10 }, "suggest_improvement" => new JsonObject diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 9a1bfb02f9..ffcdca42c3 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -410,11 +410,15 @@ private JsonNode HandleToolsList(JsonNode? id) IndexAnnotations()), CreateToolDefinition( "backfill_fold", - "Upgrade folded-name keys in an existing CodeIndex DB without reparsing source files. Rejects missing or blank targets instead of creating a fresh DB. Fills missing `name_folded` columns (or rewrites all keys after fold metadata drift such as version/fingerprint mismatch) and stamps FoldReady on success. On transports that can carry out-of-band server messages (stdio, and HTTP clients connected to `/events`), when the tools/call request includes `_meta.progressToken`, this tool emits `notifications/progress` with that token during backfill and verification. / ソース再解析なしで既存の CodeIndex DB の folded-name key を更新する。欠落したDBや空のDBを新規作成せず拒否し、欠損 `name_folded` 列を埋めるか、fold metadata の drift(version / fingerprint 不一致など)時は全 key を再生成し、成功時に FoldReady を stamp する。out-of-band のサーバーメッセージを送れる transport(stdio、および `/events` に接続した HTTP クライアント)では、tools/call リクエストに `_meta.progressToken` が含まれる場合、backfill と検証中に同じ token の `notifications/progress` を送信する。", + "Upgrade folded-name keys in an existing CodeIndex DB without reparsing source files. Rejects missing or blank targets instead of creating a fresh DB. Fills missing `name_folded` columns (or rewrites all keys after fold metadata drift such as version/fingerprint mismatch) and stamps FoldReady on success. Use `dry_run:true` to preview affected row counts without writing, or `force:true` to rewrite every folded key even when metadata appears current. On transports that can carry out-of-band server messages (stdio, and HTTP clients connected to `/events`), when the tools/call request includes `_meta.progressToken`, this tool emits `notifications/progress` with that token during backfill and verification. / ソース再解析なしで既存の CodeIndex DB の folded-name key を更新する。欠落したDBや空のDBを新規作成せず拒否し、欠損 `name_folded` 列を埋めるか、fold metadata の drift(version / fingerprint 不一致など)時は全 key を再生成し、成功時に FoldReady を stamp する。`dry_run:true` で書き込まず対象行数を確認でき、`force:true` で metadata が current に見える場合でも全 folded key を再生成する。out-of-band のサーバーメッセージを送れる transport(stdio、および `/events` に接続した HTTP クライアント)では、tools/call リクエストに `_meta.progressToken` が含まれる場合、backfill と検証中に同じ token の `notifications/progress` を送信する。", new JsonObject { ["type"] = "object", - ["properties"] = new JsonObject() + ["properties"] = new JsonObject + { + ["dry_run"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preview affected folded-key row counts without writing to the database.", ["default"] = false }, + ["force"] = new JsonObject { ["type"] = "boolean", ["description"] = "Rewrite all folded keys even when stored fold metadata matches the current runtime.", ["default"] = false } + } }, IndexAnnotations()), CreateToolDefinition( diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 55b69080f3..4e32011e92 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3110,7 +3110,7 @@ void WriteProjectRootOnce() structured); } - private JsonNode ExecuteBackfillFold(JsonNode? id, JsonNode? progressToken = null) + private JsonNode ExecuteBackfillFold(JsonNode? id, JsonNode? args, JsonNode? progressToken = null) { if (!DbContext.TryValidateExistingCodeIndexDb(_dbPath, out var validationMessage, out var isNotFound)) { @@ -3132,38 +3132,69 @@ private JsonNode ExecuteBackfillFold(JsonNode? id, JsonNode? progressToken = nul MarkSharedDbMigrated(); var writer = new DbWriter(db); var userVersionBefore = db.GetUserVersion(); + var foldReadyBefore = (userVersionBefore & DbContext.FoldReadyFlag) != 0; var currentFoldVersion = NameFold.Version.ToString(System.Globalization.CultureInfo.InvariantCulture); var currentFoldFingerprint = NameFold.Fingerprint(); var storedFoldVersion = db.GetMetaString("fold_key_version"); var storedFoldFingerprint = db.GetMetaString("fold_key_fingerprint"); - var rewriteAll = storedFoldVersion != currentFoldVersion + var dryRun = args?["dry_run"]?.GetValue() ?? args?["dryRun"]?.GetValue() ?? false; + var force = args?["force"]?.GetValue() ?? false; + var rewriteAll = force + || storedFoldVersion != currentFoldVersion || storedFoldFingerprint != currentFoldFingerprint; - EmitProgressNotification(progressToken, 0, null, "Backfilling folded-name keys."); - var (symbols, symbolReferences) = writer.BackfillFoldedColumns(rewriteAll); - EmitProgressNotification(progressToken, symbols + symbolReferences, null, "Verifying folded-name keys."); - // MarkFoldReady wraps its own re-verification in BEGIN IMMEDIATE, so a concurrent - // writer cannot insert NULL-folded rows between the verify and the stamp. Issue #1535. - // MarkFoldReady は BEGIN IMMEDIATE 内で再検証するため、concurrent writer による - // NULL 行差し込みで fold_ready が嘘になるのを防ぐ。Issue #1535。 - var verified = writer.MarkFoldReady(); - if (!verified) - return CreateToolErrorResponse(id, "Folded-name backfill verification failed: some rows still have NULL folded values. Re-run backfill_fold."); - - var userVersionAfter = db.GetUserVersion(); - EmitProgressNotification(progressToken, symbols + symbolReferences, symbols + symbolReferences, "Folded-name backfill complete."); + var symbols = 0; + var symbolReferences = 0; + var verified = false; + var userVersionAfter = userVersionBefore; + + if (dryRun) + { + (symbols, symbolReferences) = writer.CountBackfillFoldedColumns(rewriteAll); + } + else + { + EmitProgressNotification(progressToken, 0, null, "Backfilling folded-name keys."); + using var transaction = writer.BeginTransaction(); + (symbols, symbolReferences) = writer.BackfillFoldedColumns(rewriteAll); + EmitProgressNotification(progressToken, symbols + symbolReferences, null, "Verifying folded-name keys."); + // Verify and stamp in the same transaction as the row rewrite so crash recovery + // never leaves current fold metadata without a matching FoldReady stamp. + // 行の再生成と同じ transaction で検証・stamp し、metadata だけが先に残らないようにする。 + verified = writer.MarkFoldReady(); + if (!verified) + return CreateToolErrorResponse(id, "Folded-name backfill verification failed: some rows still have NULL folded values. Re-run backfill_fold."); + + transaction.Commit(); + userVersionAfter = db.GetUserVersion(); + EmitProgressNotification(progressToken, symbols + symbolReferences, symbols + symbolReferences, "Folded-name backfill complete."); + } + + var foldReadyAfter = (userVersionAfter & DbContext.FoldReadyFlag) != 0; + var wasAlreadyComplete = foldReadyBefore && !rewriteAll && symbols == 0 && symbolReferences == 0; var payload = new JsonObject { ["symbols"] = symbols, ["symbol_references"] = symbolReferences, ["rewrite_all"] = rewriteAll, + ["dry_run"] = dryRun, + ["force"] = force, + ["was_already_complete"] = wasAlreadyComplete, + ["fold_ready_before"] = foldReadyBefore, + ["fold_ready_after"] = foldReadyAfter, ["verified"] = verified, ["user_version_before"] = userVersionBefore, ["user_version_after"] = userVersionAfter, - ["fold_ready"] = true, + ["fold_ready"] = foldReadyAfter, + ["fold_key_version_before"] = storedFoldVersion, + ["fold_key_version_after"] = dryRun ? storedFoldVersion : currentFoldVersion, + ["fold_key_fingerprint_before"] = storedFoldFingerprint, + ["fold_key_fingerprint_after"] = dryRun ? storedFoldFingerprint : currentFoldFingerprint, }; - var summary = rewriteAll + var summary = dryRun + ? "Folded-name backfill preview complete." + : rewriteAll ? "Folded-name keys refreshed and FoldReady stamped." : "Missing folded-name keys backfilled and FoldReady stamped."; return CreateToolResult(id, summary, payload); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 0e027c9e0f..b08f024e66 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -2993,6 +2993,78 @@ public void RunBackfillFold_BackfillsLegacyRowsAndStampsFoldReady() } } + [Fact] + public void RunBackfillFold_DryRunReportsRowsWithoutWriting() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_backfill_fold_dry_{Guid.NewGuid():N}.db"); + try + { + using (var db = new DbContext(dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/app.py", + Lang = "python", + Size = 64, + Lines = 2, + Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord { FileId = fileId, Kind = "function", Name = "café_init", Line = 1, StartLine = 1, EndLine = 1 }, + ]); + writer.MarkGraphReady(); + writer.MarkIssuesReady(); + } + + using (var conn = new SqliteConnection($"Data Source={dbPath}")) + { + conn.Open(); + using var cmd = conn.CreateCommand(); + cmd.CommandText = "UPDATE symbols SET name_folded = NULL; PRAGMA user_version = 3"; + cmd.ExecuteNonQuery(); + } + + JsonElement json; + int exitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var writer = new StringWriter(); + try + { + Console.SetOut(writer); + exitCode = IndexCommandRunner.RunBackfillFold(["--db", dbPath, "--dry-run", "--json"], _jsonOptions); + using var document = JsonDocument.Parse(writer.ToString()); + json = document.RootElement.Clone(); + } + finally + { + Console.SetOut(originalOut); + } + } + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.True(json.GetProperty("dry_run").GetBoolean()); + Assert.Equal(1, json.GetProperty("symbols").GetInt32()); + Assert.False(json.GetProperty("verified").GetBoolean()); + Assert.False(json.GetProperty("fold_ready_after").GetBoolean()); + + using var verifyDb = new DbContext(dbPath); + using var count = verifyDb.Connection.CreateCommand(); + count.CommandText = "SELECT COUNT(*) FROM symbols WHERE name_folded IS NULL"; + Assert.Equal(1L, (long)count.ExecuteScalar()!); + Assert.Equal(3, verifyDb.GetUserVersion()); + } + finally + { + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void RunBackfillFold_RewritesAllWhenOnlyFingerprintDrifted() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 79286769e6..9fa6ba91ac 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6480,6 +6480,10 @@ public void ToolsCall_BackfillFold_StampsFoldReady() Assert.Equal(2, structured["symbols"]!.GetValue()); Assert.Equal(0, structured["symbol_references"]!.GetValue()); Assert.True(structured["rewrite_all"]!.GetValue()); + Assert.False(structured["dry_run"]!.GetValue()); + Assert.False(structured["was_already_complete"]!.GetValue()); + Assert.False(structured["fold_ready_before"]!.GetValue()); + Assert.True(structured["fold_ready_after"]!.GetValue()); Assert.True(structured["verified"]!.GetValue()); Assert.Equal(3, structured["user_version_before"]!.GetValue()); Assert.Equal(7, structured["user_version_after"]!.GetValue()); @@ -6491,6 +6495,63 @@ public void ToolsCall_BackfillFold_StampsFoldReady() Assert.True(reader._foldReady); } + [Fact] + public void ToolsCall_BackfillFold_DryRunDoesNotWrite() + { + var writer = new DbWriter(_db.Connection); + writer.SetMeta("fold_key_fingerprint", "DEADBEEFDEADBEEF"); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"backfill_fold","arguments":{"dry_run":true}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.False(response["result"]!["isError"]?.GetValue() ?? false); + var structured = response["result"]!["structuredContent"]!; + Assert.True(structured["dry_run"]!.GetValue()); + Assert.Equal(2, structured["symbols"]!.GetValue()); + Assert.False(structured["verified"]!.GetValue()); + Assert.False(structured["fold_ready_after"]!.GetValue()); + + Assert.Equal("DEADBEEFDEADBEEF", _db.GetMetaString("fold_key_fingerprint")); + Assert.Equal(3, _db.GetUserVersion()); + } + + [Fact] + public void ToolsCall_BackfillFold_SecondRunSignalsAlreadyComplete() + { + var first = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"backfill_fold","arguments":{}}}""")!; + Assert.False(_server.HandleMessage(first)!["result"]!["isError"]?.GetValue() ?? false); + + var second = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"backfill_fold","arguments":{}}}""")!; + var response = _server.HandleMessage(second)!; + + Assert.False(response["result"]!["isError"]?.GetValue() ?? false); + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(0, structured["symbols"]!.GetValue()); + Assert.Equal(0, structured["symbol_references"]!.GetValue()); + Assert.False(structured["rewrite_all"]!.GetValue()); + Assert.True(structured["was_already_complete"]!.GetValue()); + Assert.True(structured["fold_ready_before"]!.GetValue()); + Assert.True(structured["fold_ready_after"]!.GetValue()); + } + + [Fact] + public void ToolsCall_BackfillFold_ForceRewritesAlreadyCompleteRows() + { + var first = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"backfill_fold","arguments":{}}}""")!; + Assert.False(_server.HandleMessage(first)!["result"]!["isError"]?.GetValue() ?? false); + + var forced = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"backfill_fold","arguments":{"force":true}}}""")!; + var response = _server.HandleMessage(forced)!; + + Assert.False(response["result"]!["isError"]?.GetValue() ?? false); + var structured = response["result"]!["structuredContent"]!; + Assert.True(structured["force"]!.GetValue()); + Assert.True(structured["rewrite_all"]!.GetValue()); + Assert.Equal(2, structured["symbols"]!.GetValue()); + Assert.False(structured["was_already_complete"]!.GetValue()); + Assert.True(structured["fold_ready_after"]!.GetValue()); + } + [Fact] public void ToolsCall_BackfillFold_RewritesAllWhenOnlyFingerprintDrifted() { From e16de057e45888ca9b228ba9f746569ff3294740 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:03:33 +0900 Subject: [PATCH 2/5] Allow backfill fold MCP dry-run arguments (#1703) --- src/CodeIndex/Mcp/McpToolHandlers.cs | 1 + tests/CodeIndex.Tests/McpServerTests.cs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index fca8e51aca..fdcbfbb001 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -333,6 +333,7 @@ private static List ReadStringList(JsonNode? args, string propertyName) "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "project", "solution" }, "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, "index" => new HashSet(StringComparer.Ordinal) { "path", "db", "rebuild", "parallelism", "files", "commits", "changedBetween", "dryRun", "optimize" }, + "backfill_fold" => new HashSet(StringComparer.Ordinal) { "dry_run", "dryRun", "force" }, "suggest_improvement" => new HashSet(StringComparer.Ordinal) { "category", "language", "description", "context", "toolInvocationContext" }, _ => new HashSet(StringComparer.Ordinal), }; diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 6babcd1586..209af0ca55 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6502,7 +6502,7 @@ public void ToolsCall_BackfillFold_StampsFoldReady() var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"backfill_fold","arguments":{}}}""")!; var response = _server.HandleMessage(request)!; - Assert.False(response["result"]!["isError"]?.GetValue() ?? false); + Assert.False(response["result"]!["isError"]?.GetValue() ?? false, response.ToJsonString()); var structured = response["result"]!["structuredContent"]!; Assert.Equal(2, structured["symbols"]!.GetValue()); Assert.Equal(0, structured["symbol_references"]!.GetValue()); @@ -6531,7 +6531,7 @@ public void ToolsCall_BackfillFold_DryRunDoesNotWrite() var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"backfill_fold","arguments":{"dry_run":true}}}""")!; var response = _server.HandleMessage(request)!; - Assert.False(response["result"]!["isError"]?.GetValue() ?? false); + Assert.False(response["result"]!["isError"]?.GetValue() ?? false, response.ToJsonString()); var structured = response["result"]!["structuredContent"]!; Assert.True(structured["dry_run"]!.GetValue()); Assert.Equal(2, structured["symbols"]!.GetValue()); From 1d8519c0371f4b4702c9db20f8ff99cc518520be Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:09:58 +0900 Subject: [PATCH 3/5] Advertise backfill fold dry-run flag (#1703) --- src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 8501977961..b6e7f749bd 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -271,7 +271,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--integrity-check", Description = "Run PRAGMA integrity_check on the database", Commands = Set("db") }, new() { Name = "--rebuild", Description = "Delete existing DB and rebuild from scratch", Commands = Set("index") }, new() { Name = "--optimize", Description = "Optimize the existing FTS5 table without scanning files", Commands = Set("index") }, - new() { Name = "--dry-run", Description = "Scan files without writing", Commands = Set("index") }, + new() { Name = "--dry-run", Description = "Preview without writing", Commands = Set("index", "backfill-fold") }, new() { Name = "--force", Description = "Bypass the per-database index lock", Commands = Set("index") }, new() { Name = "--duration-format", ValuePlaceholder = "", Description = "Index elapsed time display format", Commands = Set("index") }, new() { Name = "--max-file-bytes", ValuePlaceholder = "", Description = "Override the per-file indexing size limit", Commands = Set("index") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index d9b935293e..cc78099cf1 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -62,7 +62,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = [ ("index", "cdidx index [--db ] [--rebuild] [--optimize] [--verbose] [--dry-run] [--force] [--quiet] [--json] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]] [--watch [--debounce ]]"), ("hooks", "cdidx hooks [--project ] [--force] [--json]"), - ("backfill-fold", "cdidx backfill-fold [--db ] [--json]"), + ("backfill-fold", "cdidx backfill-fold [--db ] [--dry-run] [--json]"), ("optimize", "cdidx optimize [--db ] [--json]"), ("vacuum", "cdidx vacuum [--db ] [--json]"), ("index-commits", "cdidx index --commits [id ...] [--db ] [--verbose] [--dry-run] [--json] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), From f79495ace92f54d8a6f954c74080a1a2885ff877 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:16:02 +0900 Subject: [PATCH 4/5] Report effective fold readiness in backfill dry-run (#1613 #1919) --- .../Cli/IndexCommandRunner.Maintenance.cs | 12 +++- src/CodeIndex/Mcp/McpToolHandlers.cs | 12 +++- .../IndexCommandRunnerTests.cs | 64 +++++++++++++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 6 +- 4 files changed, 87 insertions(+), 7 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs index bfeccf7df0..97d5744c6f 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs @@ -172,11 +172,13 @@ internal static int RunBackfillFold( var currentFoldFingerprint = NameFold.Fingerprint(); var storedFoldVersion = db.GetMetaString("fold_key_version"); var storedFoldFingerprint = db.GetMetaString("fold_key_fingerprint"); + var foldMetadataCurrentBefore = storedFoldVersion == currentFoldVersion + && storedFoldFingerprint == currentFoldFingerprint; + foldReadyBefore = foldReadyBefore && foldMetadataCurrentBefore; // Missing or mismatched fold metadata means persisted keys may have been generated // by a different fold algorithm/runtime, so refresh every row from source names. // fold metadata 未記録 / 不一致時は全行再計算して version/runtime skew を解消する。 - var rewriteAll = storedFoldVersion != currentFoldVersion - || storedFoldFingerprint != currentFoldFingerprint; + var rewriteAll = !foldMetadataCurrentBefore; var symbols = 0; var symbolReferences = 0; @@ -211,7 +213,11 @@ internal static int RunBackfillFold( transaction.Commit(); userVersionAfter = db.GetUserVersion(); } - var foldReadyAfter = (userVersionAfter & DbContext.FoldReadyFlag) != 0; + var foldMetadataCurrentAfter = options.DryRun + ? foldMetadataCurrentBefore + : true; + var foldReadyAfter = (userVersionAfter & DbContext.FoldReadyFlag) != 0 + && foldMetadataCurrentAfter; var wasAlreadyComplete = foldReadyBefore && !rewriteAll && symbols == 0 && symbolReferences == 0; if (options.Json) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index fdcbfbb001..ff50a9489c 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3216,11 +3216,13 @@ private JsonNode ExecuteBackfillFold(JsonNode? id, JsonNode? args, JsonNode? pro var currentFoldFingerprint = NameFold.Fingerprint(); var storedFoldVersion = db.GetMetaString("fold_key_version"); var storedFoldFingerprint = db.GetMetaString("fold_key_fingerprint"); + var foldMetadataCurrentBefore = storedFoldVersion == currentFoldVersion + && storedFoldFingerprint == currentFoldFingerprint; + foldReadyBefore = foldReadyBefore && foldMetadataCurrentBefore; var dryRun = args?["dry_run"]?.GetValue() ?? args?["dryRun"]?.GetValue() ?? false; var force = args?["force"]?.GetValue() ?? false; var rewriteAll = force - || storedFoldVersion != currentFoldVersion - || storedFoldFingerprint != currentFoldFingerprint; + || !foldMetadataCurrentBefore; var symbols = 0; var symbolReferences = 0; var verified = false; @@ -3248,7 +3250,11 @@ private JsonNode ExecuteBackfillFold(JsonNode? id, JsonNode? args, JsonNode? pro EmitProgressNotification(progressToken, symbols + symbolReferences, symbols + symbolReferences, "Folded-name backfill complete."); } - var foldReadyAfter = (userVersionAfter & DbContext.FoldReadyFlag) != 0; + var foldMetadataCurrentAfter = dryRun + ? foldMetadataCurrentBefore + : true; + var foldReadyAfter = (userVersionAfter & DbContext.FoldReadyFlag) != 0 + && foldMetadataCurrentAfter; var wasAlreadyComplete = foldReadyBefore && !rewriteAll && symbols == 0 && symbolReferences == 0; var payload = new JsonObject diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 9c035f2665..e151114895 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -3066,6 +3066,70 @@ public void RunBackfillFold_DryRunReportsRowsWithoutWriting() } } + [Fact] + public void RunBackfillFold_DryRunReportsEffectiveFoldReadyWhenMetadataStale() + { + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_backfill_fold_stale_dry_{Guid.NewGuid():N}.db"); + try + { + using (var db = new DbContext(dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + var fileId = writer.UpsertFile(new FileRecord + { + Path = "src/app.py", + Lang = "python", + Size = 64, + Lines = 1, + Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord { FileId = fileId, Kind = "function", Name = "café_init", Line = 1, StartLine = 1, EndLine = 1 }, + ]); + writer.BackfillFoldedColumns(rewriteAll: true); + writer.MarkFoldReady(); + writer.SetMeta("fold_key_fingerprint", "DEADBEEFDEADBEEF"); + } + + JsonElement json; + int exitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var writer = new StringWriter(); + try + { + Console.SetOut(writer); + exitCode = IndexCommandRunner.RunBackfillFold(["--db", dbPath, "--dry-run", "--json"], _jsonOptions); + using var document = JsonDocument.Parse(writer.ToString()); + json = document.RootElement.Clone(); + } + finally + { + Console.SetOut(originalOut); + } + } + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.True(json.GetProperty("dry_run").GetBoolean()); + Assert.True(json.GetProperty("rewrite_all").GetBoolean()); + Assert.False(json.GetProperty("fold_ready_before").GetBoolean()); + Assert.False(json.GetProperty("fold_ready_after").GetBoolean()); + Assert.False(json.GetProperty("fold_ready").GetBoolean()); + + using var verifyDb = new DbContext(dbPath); + Assert.Equal("DEADBEEFDEADBEEF", verifyDb.GetMetaString("fold_key_fingerprint")); + Assert.Equal(DbContext.FoldReadyFlag, verifyDb.GetUserVersion()); + } + finally + { + SqliteConnection.ClearAllPools(); + if (File.Exists(dbPath)) + File.Delete(dbPath); + } + } + [Fact] public void RunBackfillFold_RewritesAllWhenOnlyFingerprintDrifted() { diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 209af0ca55..43fa94a9a1 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6526,6 +6526,8 @@ public void ToolsCall_BackfillFold_StampsFoldReady() public void ToolsCall_BackfillFold_DryRunDoesNotWrite() { var writer = new DbWriter(_db.Connection); + writer.BackfillFoldedColumns(rewriteAll: true); + writer.MarkFoldReady(); writer.SetMeta("fold_key_fingerprint", "DEADBEEFDEADBEEF"); var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"backfill_fold","arguments":{"dry_run":true}}}""")!; @@ -6536,10 +6538,12 @@ public void ToolsCall_BackfillFold_DryRunDoesNotWrite() Assert.True(structured["dry_run"]!.GetValue()); Assert.Equal(2, structured["symbols"]!.GetValue()); Assert.False(structured["verified"]!.GetValue()); + Assert.False(structured["fold_ready_before"]!.GetValue()); Assert.False(structured["fold_ready_after"]!.GetValue()); + Assert.False(structured["fold_ready"]!.GetValue()); Assert.Equal("DEADBEEFDEADBEEF", _db.GetMetaString("fold_key_fingerprint")); - Assert.Equal(3, _db.GetUserVersion()); + Assert.Equal(7, _db.GetUserVersion()); } [Fact] From 86d97ed9097a87cb98f416f3405700c2a9340731 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:54:18 +0900 Subject: [PATCH 5/5] Update backfill fold usage test (#1703) --- tests/CodeIndex.Tests/ConsoleUiTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 047c04ba8c..cabc165dd4 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -61,7 +61,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx hooks [--project ] [--force] [--json]", output); Assert.Contains("cdidx index --commits [id ...] [--db ] [--verbose] [--dry-run] [--json] [--duration-format ]", output); Assert.Contains("cdidx index --files [path ...] [--db ] [--verbose] [--dry-run] [--json] [--duration-format ]", output); - Assert.Contains("cdidx backfill-fold [--db ] [--json]", output); + Assert.Contains("cdidx backfill-fold [--db ] [--dry-run] [--json]", output); Assert.Contains("cdidx optimize [--db ] [--json]", output); Assert.Contains("cdidx license", output); Assert.Contains("cdidx completions ", output);