From 169ed69e4000a24de89f3a895e3fce2ab66631a8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 2 Aug 2026 01:32:58 +0900 Subject: [PATCH 1/3] Repair mixed folded backfill state (#4946) --- TESTING_GUIDE.md | 2 + changelog.d/unreleased/4946.fixed.md | 20 ++++ .../Cli/IndexCommandRunner.Maintenance.cs | 10 ++ .../Database/DbWriter.FoldBackfill.cs | 18 +++- src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs | 8 ++ .../IndexCommandRunnerTests.cs | 99 +++++++++++++++++++ .../McpServerToolsCallTests.cs | 31 ++++++ 7 files changed, 184 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/4946.fixed.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index b0cb99672..2140767a3 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -379,6 +379,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding share seeded databases to verify that optimize dry-run reports sizes, readiness, lock state, recommendations, and planned work while preserving the source bytes and creating no lock artifacts; the same fixtures retain the writable optimize and lock/read-only URI mutation guards. `ConsoleUiTests.PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags` keeps the optimize dry-run flag visible in full CLI usage. - Issue #4889 backfill-checkpoint coverage keeps the mutation preflight, folded-value validation, index lock, and artifact decision in one contract. Reuse the completed fixture for default no-op, forced `--checkpoint`, `--no-checkpoint` mutation, human output, a non-NULL drifted fold that requires a protected full repair, and a zero-row pending graph refresh; keep WAL/SHM open during the forced snapshot so Windows sharing semantics and the complete DB/WAL/SHM/manifest file set are exercised. Separate dry-run, invalid-DB, and held-lock assertions must prove that no checkpoint directory is created, while a real pending rewrite or pending graph refresh must retain the automatic checkpoint. +- Issue #4946 fold-backfill coverage keeps CLI and MCP preflight aligned for a database that simultaneously contains NULL folded values and non-NULL drifted values. Both surfaces must promote that mixed state to one full rewrite and verify successfully in the first invocation. - Issue #4856 maintenance-error coverage spans `CommandErrorWriterTests`, `QueryCommandRunnerTests`, `IndexCommandRunnerTests`, and `DbCommandRunnerTests`. Keep the SQLite primary-code matrix (`5`, `6`, `8`, `11`, `26`), caller-spelled relative paths, whole-value Unix/Windows/file-URI redaction, missing and invalid-header files, directory/inaccessible preflight classification, integrity corruption rows, lock-holder metadata, the `index --optimize` human preamble, valid JSON, and category-specific recovery hints aligned with classifier version `1`. - `IndexCommandRunnerTests.Run_DryRunWithRebuildAndMemoryTrace_SkipsConfirmationAndPreservesWorkspace_Issue4580` @@ -1338,6 +1339,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" seed 済み database を共有し、optimize dry-run が size、readiness、lock state、推奨、planned work を報告しつつ source byte を保持し、lock artifact を作成しないことを検証する。同じ fixture で、書き込み版 optimize と lock/read-only URI の mutation guard も維持する。 `ConsoleUiTests.PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags` は、full CLI usage に optimize dry-run flag が表示され続けることを固定します。 - Issue #4889 の backfill-checkpoint coverage は、mutation preflight、folded値の検証、index lock、artifact 作成判断を1つの契約として固定します。完了済み fixture を既定no-op、明示 `--checkpoint`、`--no-checkpoint` mutation、human output、保護された全行修復を必要とする非NULLのfold drift、zero-rowのpending graph refreshで再利用し、明示snapshot中は WAL/SHM をopenしたままにしてWindowsの共有 semanticsとDB/WAL/SHM/manifest一式を検証してください。dry-run、無効DB、lock保持中のassertionではcheckpoint directoryが作られないことを個別に証明し、実際にpending rewriteまたはpending graph refreshがある場合はautomatic checkpointを維持してください。 +- Issue #4946 の fold-backfill coverage は、NULL の folded 値と非 NULL だが drift した値が同時に存在する database で CLI と MCP の preflight を同期させます。両 surface はこの混在状態を1回の全行 rewrite に昇格し、最初の invocation で検証成功しなければなりません。 - Issue #4856 の maintenance-error coverage は `CommandErrorWriterTests`、`QueryCommandRunnerTests`、`IndexCommandRunnerTests`、`DbCommandRunnerTests` にまたがります。 SQLite primary-code matrix (`5`、`6`、`8`、`11`、`26`)、呼び出し側表記を維持する relative path、Unix / Windows / file URI の値全体 redaction、missing / invalid-header file、directory / inaccessible preflight classification、integrity corruption row、lock-holder metadata、`index --optimize` の human preamble、有効な JSON、category 別 recovery hint を classifier version `1` と同期させてください。 - `IndexCommandRunnerTests.Run_DryRunWithRebuildAndMemoryTrace_SkipsConfirmationAndPreservesWorkspace_Issue4580` diff --git a/changelog.d/unreleased/4946.fixed.md b/changelog.d/unreleased/4946.fixed.md new file mode 100644 index 000000000..9f261524f --- /dev/null +++ b/changelog.d/unreleased/4946.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4946 +affected: + - src/CodeIndex/Database/DbWriter.FoldBackfill.cs + - src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs + - src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - tests/CodeIndex.Tests/McpServerToolsCallTests.cs + - TESTING_GUIDE.md +--- + +## English + +- **Fold backfill now repairs mixed missing and stale folded keys in one pass (#4946)** — `backfill-fold` and MCP `backfill_fold` distinguish targetable NULL values from non-NULL drift during preflight and promote mixed state to a full rewrite, so verification succeeds without requiring a rebuild or retry. + +## 日本語 + +- **fold backfill が missing / stale folded key の混在を1回で修復するようになりました (#4946)** — `backfill-fold` と MCP `backfill_fold` は preflight で対象修復可能な NULL 値と非 NULL の drift を区別し、混在時は全行 rewrite に昇格するため、rebuild や再試行なしで検証が成功します。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs index 5855ce4aa..e8c2f0851 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs @@ -640,6 +640,16 @@ internal static int RunBackfillFold( // fold metadata 未記録 / 不一致時は全行再計算して version/runtime skew を解消する。 var rewriteAll = writer.ResolveFoldBackfillRewriteAll(!foldMetadataCurrentBefore); + if (!rewriteAll && !writer.AllPresentFoldedColumnValuesMatchCurrentFold()) + { + // Missing folded values can use the targeted backfill, but any non-NULL + // drift must promote the same invocation to a full refresh. This avoids a + // partial pass failing verification when both states coexist. + // NULL の folded 値は対象行だけ修復できるが、非 NULL の drift も混在する場合は + // 同じ invocation を全行 refresh に昇格し、partial pass 後の検証失敗を防ぐ。 + rewriteAll = true; + } + var symbols = 0; var symbolReferences = 0; var verified = false; diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index 79a2afa1b..f88a509f6 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -127,6 +127,12 @@ private bool AllFoldedColumnsBackfilledCore( } public bool AllFoldedColumnValuesMatchCurrentFold() + => AllFoldedColumnValuesMatchCurrentFoldCore(allowMissingValues: false); + + internal bool AllPresentFoldedColumnValuesMatchCurrentFold() + => AllFoldedColumnValuesMatchCurrentFoldCore(allowMissingValues: true); + + private bool AllFoldedColumnValuesMatchCurrentFoldCore(bool allowMissingValues) { var markdownSymbolIdentityFolds = BuildMarkdownSymbolIdentityFoldMap(); var hasDisplayNameFolded = @@ -156,7 +162,7 @@ WHERE s.name IS NOT NULL reader.IsDBNull(6) ? null : reader.GetString(6), markdownSymbolIdentityFolds); var actual = reader.IsDBNull(2) ? null : reader.GetString(2); - if (!string.Equals(actual, expected, StringComparison.Ordinal)) + if (!FoldedValueMatches(actual, expected, allowMissingValues)) return false; var foldedDisplay = DbReader.FoldNameForLanguage( reader.GetString(1), @@ -170,7 +176,7 @@ WHERE s.name IS NOT NULL ? foldedDisplay : null; var actualDisplay = reader.IsDBNull(3) ? null : reader.GetString(3); - if (!string.Equals(actualDisplay, expectedDisplay, StringComparison.Ordinal)) + if (!FoldedValueMatches(actualDisplay, expectedDisplay, allowMissingValues)) return false; } } @@ -200,7 +206,7 @@ FROM symbol_references r reader.IsDBNull(4) ? null : reader.GetString(4), reader.GetString(5)); var actual = reader.IsDBNull(1) ? null : reader.GetString(1); - if (!string.Equals(actual, expected, StringComparison.Ordinal)) + if (!FoldedValueMatches(actual, expected, allowMissingValues)) return false; } @@ -210,7 +216,7 @@ FROM symbol_references r reader.GetString(2), reader.IsDBNull(4) ? null : reader.GetString(4)); var actual = reader.IsDBNull(3) ? null : reader.GetString(3); - if (!string.Equals(actual, expected, StringComparison.Ordinal)) + if (!FoldedValueMatches(actual, expected, allowMissingValues)) return false; } } @@ -223,6 +229,10 @@ FROM symbol_references r return true; } + private static bool FoldedValueMatches(string? actual, string? expected, bool allowMissingValues) + => (allowMissingValues && actual == null) + || string.Equals(actual, expected, StringComparison.Ordinal); + public bool AllFoldedColumnsBackfilled(IReadOnlyCollection requireCurrentSymbolExtractorLanguages) { if (IsInTransaction()) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs b/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs index beb18c568..f47e49962 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs @@ -69,6 +69,14 @@ private async Task ExecuteBackfillFoldAsync(JsonNode? id, JsonNode? ar var force = args?["force"]?.GetValue() ?? false; var rewriteAll = writer.ResolveFoldBackfillRewriteAll( force || !foldMetadataCurrentBefore); + if (!rewriteAll && !writer.AllPresentFoldedColumnValuesMatchCurrentFold()) + { + // Keep MCP aligned with the CLI: mixed missing and non-current folded + // values require one full repair rather than a partial pass that cannot verify. + // CLI と同様に、missing と non-current の folded 値が混在する場合は + // 検証不能な partial pass ではなく1回の全行修復へ昇格する。 + rewriteAll = true; + } var symbols = 0; var symbolReferences = 0; var totalSymbols = 0; diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index e2fe8ed9e..5f998c440 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6484,6 +6484,105 @@ public void RunBackfillFold_BackfillsLegacyRowsAndStampsFoldReady() } } + [Fact] + public void RunBackfillFold_MixedMissingAndNonCurrentRows_RewritesAll_Issue4946() + { + var dbPath = CreateTempDbPath("cdidx_backfill_fold_mixed_4946"); + try + { + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + db.InitializeSchema(); + var writer = new DbWriter(db.Connection); + var csharpFileId = writer.UpsertFile(new FileRecord + { + Path = "src/App.cs", + Lang = "csharp", + Size = 64, + Lines = 2, + Modified = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), + }); + var markdownFileId = writer.UpsertFile(new FileRecord + { + Path = "changelog.d/unreleased/4946.fixed.md", + Lang = "markdown", + Size = 64, + Lines = 2, + Modified = new DateTime(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord { FileId = csharpFileId, Kind = "function", Name = "MissingMember", Line = 1, StartLine = 1, EndLine = 1 }, + new SymbolRecord { FileId = markdownFileId, Kind = "heading", Name = "Current heading", Line = 1, StartLine = 1, EndLine = 2 }, + ]); + writer.InsertReferences([ + new ReferenceRecord + { + FileId = csharpFileId, + SymbolName = "MissingMember", + ReferenceKind = "call", + Line = 2, + Column = 5, + Context = "MissingMember()", + ContainerKind = "function", + ContainerName = "CurrentContainer", + }, + ]); + writer.BackfillFoldedColumns(rewriteAll: true); + Assert.True(writer.MarkFoldReady()); + writer.MarkCSharpSymbolNameContractReady(); + + using var corrupt = db.Connection.CreateCommand(); + corrupt.CommandText = """ + UPDATE symbols + SET name_folded = CASE + WHEN name = 'MissingMember' THEN NULL + ELSE 'stale-non-current-fold' + END; + UPDATE symbol_references + SET symbol_name_folded = NULL, + container_name_folded = 'stale-non-current-fold'; + """; + corrupt.ExecuteNonQuery(); + } + + JsonElement json; + int exitCode; + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + exitCode = IndexCommandRunner.RunBackfillFold(["--db", dbPath, "--json"], _jsonOptions); + using var document = JsonDocument.Parse(output.ToString()); + json = document.RootElement.Clone(); + } + finally + { + Console.SetOut(originalOut); + } + } + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(2, json.GetProperty("symbols").GetInt32()); + Assert.Equal(1, json.GetProperty("symbol_references").GetInt32()); + Assert.True(json.GetProperty("rewrite_all").GetBoolean()); + Assert.True(json.GetProperty("verified").GetBoolean()); + Assert.True(json.GetProperty("fold_ready").GetBoolean()); + + using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); + var verifyWriter = new DbWriter(verifyDb.Connection); + Assert.True(verifyWriter.AllFoldedColumnsBackfilled(requireCurrentFoldKeys: true)); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + TestProjectHelper.DeleteDirectory(dbPath + ".checkpoints"); + } + } + [Fact] public void RunBackfillFold_DryRunReportsRowsWithoutWriting() { diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index fceb98ba0..3becacd80 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -12020,6 +12020,37 @@ public void ToolsCall_BackfillFold_StampsFoldReady() Assert.True(reader._foldReady); } + [Fact] + public void ToolsCall_BackfillFold_MixedMissingAndNonCurrentRows_RewritesAll_Issue4946() + { + var writer = new DbWriter(_db.Connection); + writer.BackfillFoldedColumns(rewriteAll: true); + Assert.True(writer.MarkFoldReady()); + + using (var corrupt = _db.Connection.CreateCommand()) + { + corrupt.CommandText = """ + UPDATE symbols + SET name_folded = CASE + WHEN id = (SELECT MIN(id) FROM symbols) THEN NULL + ELSE 'stale-non-current-fold' + END + """; + corrupt.ExecuteNonQuery(); + } + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":4946,"method":"tools/call","params":{"name":"backfill_fold","arguments":{}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.False(response["result"]!["isError"]?.GetValue() ?? false, response.ToJsonString()); + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(2, structured["symbols"]!.GetValue()); + Assert.True(structured["rewrite_all"]!.GetValue()); + Assert.True(structured["verified"]!.GetValue()); + Assert.True(structured["fold_ready"]!.GetValue()); + Assert.True(writer.AllFoldedColumnsBackfilled(requireCurrentFoldKeys: true)); + } + [Fact] public void ToolsCall_BackfillFold_RejectsNewerCSharpIdentityContract_Issue4866Review() { From 45cd0850b00d4089758c29020c4ee37a7f785fd8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 2 Aug 2026 02:50:47 +0900 Subject: [PATCH 2/3] Preserve promoted backfill checkpoints (#4946) --- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4946.fixed.md | 4 +- .../Cli/IndexCommandRunner.Maintenance.cs | 19 ++-- .../Database/DbWriter.FoldBackfill.cs | 5 + src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs | 16 ++- .../IndexCommandRunnerTests.cs | 103 ++++++++++++++++++ .../McpServerToolsCallTests.cs | 45 ++++++++ 7 files changed, 178 insertions(+), 18 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 2140767a3..ba1af2698 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -379,7 +379,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding share seeded databases to verify that optimize dry-run reports sizes, readiness, lock state, recommendations, and planned work while preserving the source bytes and creating no lock artifacts; the same fixtures retain the writable optimize and lock/read-only URI mutation guards. `ConsoleUiTests.PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags` keeps the optimize dry-run flag visible in full CLI usage. - Issue #4889 backfill-checkpoint coverage keeps the mutation preflight, folded-value validation, index lock, and artifact decision in one contract. Reuse the completed fixture for default no-op, forced `--checkpoint`, `--no-checkpoint` mutation, human output, a non-NULL drifted fold that requires a protected full repair, and a zero-row pending graph refresh; keep WAL/SHM open during the forced snapshot so Windows sharing semantics and the complete DB/WAL/SHM/manifest file set are exercised. Separate dry-run, invalid-DB, and held-lock assertions must prove that no checkpoint directory is created, while a real pending rewrite or pending graph refresh must retain the automatic checkpoint. -- Issue #4946 fold-backfill coverage keeps CLI and MCP preflight aligned for a database that simultaneously contains NULL folded values and non-NULL drifted values. Both surfaces must promote that mixed state to one full rewrite and verify successfully in the first invocation. +- Issue #4946 fold-backfill coverage keeps CLI and MCP preflight aligned for a database that simultaneously contains NULL folded values and non-NULL drifted values. Both surfaces must promote that mixed state to one full rewrite and verify successfully in the first invocation. If that promoted rewrite is interrupted, its checkpoint must keep the retry in full-rewrite mode until completion and then be cleared; complete databases must not pay for an additional present-value scan before their existing exact verification. - Issue #4856 maintenance-error coverage spans `CommandErrorWriterTests`, `QueryCommandRunnerTests`, `IndexCommandRunnerTests`, and `DbCommandRunnerTests`. Keep the SQLite primary-code matrix (`5`, `6`, `8`, `11`, `26`), caller-spelled relative paths, whole-value Unix/Windows/file-URI redaction, missing and invalid-header files, directory/inaccessible preflight classification, integrity corruption rows, lock-holder metadata, the `index --optimize` human preamble, valid JSON, and category-specific recovery hints aligned with classifier version `1`. - `IndexCommandRunnerTests.Run_DryRunWithRebuildAndMemoryTrace_SkipsConfirmationAndPreservesWorkspace_Issue4580` @@ -1339,7 +1339,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" seed 済み database を共有し、optimize dry-run が size、readiness、lock state、推奨、planned work を報告しつつ source byte を保持し、lock artifact を作成しないことを検証する。同じ fixture で、書き込み版 optimize と lock/read-only URI の mutation guard も維持する。 `ConsoleUiTests.PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags` は、full CLI usage に optimize dry-run flag が表示され続けることを固定します。 - Issue #4889 の backfill-checkpoint coverage は、mutation preflight、folded値の検証、index lock、artifact 作成判断を1つの契約として固定します。完了済み fixture を既定no-op、明示 `--checkpoint`、`--no-checkpoint` mutation、human output、保護された全行修復を必要とする非NULLのfold drift、zero-rowのpending graph refreshで再利用し、明示snapshot中は WAL/SHM をopenしたままにしてWindowsの共有 semanticsとDB/WAL/SHM/manifest一式を検証してください。dry-run、無効DB、lock保持中のassertionではcheckpoint directoryが作られないことを個別に証明し、実際にpending rewriteまたはpending graph refreshがある場合はautomatic checkpointを維持してください。 -- Issue #4946 の fold-backfill coverage は、NULL の folded 値と非 NULL だが drift した値が同時に存在する database で CLI と MCP の preflight を同期させます。両 surface はこの混在状態を1回の全行 rewrite に昇格し、最初の invocation で検証成功しなければなりません。 +- Issue #4946 の fold-backfill coverage は、NULL の folded 値と非 NULL だが drift した値が同時に存在する database で CLI と MCP の preflight を同期させます。両 surface はこの混在状態を1回の全行 rewrite に昇格し、最初の invocation で検証成功しなければなりません。昇格した rewrite が中断された場合は、完了まで checkpoint に従って全行 rewrite mode を維持してから checkpoint を消去し、complete database では既存の厳密検証より前に追加の present-value scan を実行しないでください。 - Issue #4856 の maintenance-error coverage は `CommandErrorWriterTests`、`QueryCommandRunnerTests`、`IndexCommandRunnerTests`、`DbCommandRunnerTests` にまたがります。 SQLite primary-code matrix (`5`、`6`、`8`、`11`、`26`)、呼び出し側表記を維持する relative path、Unix / Windows / file URI の値全体 redaction、missing / invalid-header file、directory / inaccessible preflight classification、integrity corruption row、lock-holder metadata、`index --optimize` の human preamble、有効な JSON、category 別 recovery hint を classifier version `1` と同期させてください。 - `IndexCommandRunnerTests.Run_DryRunWithRebuildAndMemoryTrace_SkipsConfirmationAndPreservesWorkspace_Issue4580` diff --git a/changelog.d/unreleased/4946.fixed.md b/changelog.d/unreleased/4946.fixed.md index 9f261524f..cdbf66bbc 100644 --- a/changelog.d/unreleased/4946.fixed.md +++ b/changelog.d/unreleased/4946.fixed.md @@ -13,8 +13,8 @@ affected: ## English -- **Fold backfill now repairs mixed missing and stale folded keys in one pass (#4946)** — `backfill-fold` and MCP `backfill_fold` distinguish targetable NULL values from non-NULL drift during preflight and promote mixed state to a full rewrite, so verification succeeds without requiring a rebuild or retry. +- **Fold backfill now repairs mixed missing and stale folded keys in one pass (#4946)** — `backfill-fold` and MCP `backfill_fold` distinguish targetable NULL values from non-NULL drift during preflight and promote mixed state to a full rewrite, so verification succeeds without requiring a rebuild or retry. An interrupted promoted rewrite remains in full-rewrite mode until its checkpointed work completes. ## 日本語 -- **fold backfill が missing / stale folded key の混在を1回で修復するようになりました (#4946)** — `backfill-fold` と MCP `backfill_fold` は preflight で対象修復可能な NULL 値と非 NULL の drift を区別し、混在時は全行 rewrite に昇格するため、rebuild や再試行なしで検証が成功します。 +- **fold backfill が missing / stale folded key の混在を1回で修復するようになりました (#4946)** — `backfill-fold` と MCP `backfill_fold` は preflight で対象修復可能な NULL 値と非 NULL の drift を区別し、混在時は全行 rewrite に昇格するため、rebuild や再試行なしで検証が成功します。昇格後に中断された rewrite は、checkpoint の処理が完了するまで全行 rewrite mode を維持します。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs index e8c2f0851..1794483ea 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs @@ -638,9 +638,17 @@ internal static int RunBackfillFold( // 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 = writer.ResolveFoldBackfillRewriteAll(!foldMetadataCurrentBefore); + var rewriteAll = writer.ResolveFoldBackfillRewriteAll( + !foldMetadataCurrentBefore || writer.HasFoldBackfillRewriteCheckpoint()); - if (!rewriteAll && !writer.AllPresentFoldedColumnValuesMatchCurrentFold()) + var symbols = 0; + var symbolReferences = 0; + var verified = false; + var userVersionAfter = userVersionBefore; + var pendingRows = writer.CountBackfillFoldedColumns(rewriteAll); + if (!rewriteAll + && (pendingRows.Symbols > 0 || pendingRows.SymbolReferences > 0) + && !writer.AllPresentFoldedColumnValuesMatchCurrentFold()) { // Missing folded values can use the targeted backfill, but any non-NULL // drift must promote the same invocation to a full refresh. This avoids a @@ -648,13 +656,8 @@ internal static int RunBackfillFold( // NULL の folded 値は対象行だけ修復できるが、非 NULL の drift も混在する場合は // 同じ invocation を全行 refresh に昇格し、partial pass 後の検証失敗を防ぐ。 rewriteAll = true; + pendingRows = writer.CountBackfillFoldedColumns(rewriteAll); } - - var symbols = 0; - var symbolReferences = 0; - var verified = false; - var userVersionAfter = userVersionBefore; - var pendingRows = writer.CountBackfillFoldedColumns(rewriteAll); if (!rewriteAll && pendingRows.Symbols == 0 && pendingRows.SymbolReferences == 0 diff --git a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs index f88a509f6..86ee1aada 100644 --- a/src/CodeIndex/Database/DbWriter.FoldBackfill.cs +++ b/src/CodeIndex/Database/DbWriter.FoldBackfill.cs @@ -451,6 +451,11 @@ internal bool IsFoldBackfillGraphRefreshPending() "1", StringComparison.Ordinal); + internal bool HasFoldBackfillRewriteCheckpoint() + => GetMetaString(FoldBackfillPhaseMetaKey) != null + || GetMetaString(FoldBackfillLastSymbolIdMetaKey) != null + || GetMetaString(FoldBackfillLastReferenceIdMetaKey) != null; + private static int ToInt32Count(object? value) { var count = value is long l ? l : (value is int i ? i : 0); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs b/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs index f47e49962..d4376a8ee 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Backfill.cs @@ -68,23 +68,27 @@ private async Task ExecuteBackfillFoldAsync(JsonNode? id, JsonNode? ar foldReadyBefore = foldReadyBefore && foldMetadataCurrentBefore; var force = args?["force"]?.GetValue() ?? false; var rewriteAll = writer.ResolveFoldBackfillRewriteAll( - force || !foldMetadataCurrentBefore); - if (!rewriteAll && !writer.AllPresentFoldedColumnValuesMatchCurrentFold()) + force + || !foldMetadataCurrentBefore + || writer.HasFoldBackfillRewriteCheckpoint()); + (var totalSymbols, var totalSymbolReferences) = + writer.CountBackfillFoldedColumns(rewriteAll); + if (!rewriteAll + && (totalSymbols > 0 || totalSymbolReferences > 0) + && !writer.AllPresentFoldedColumnValuesMatchCurrentFold()) { // Keep MCP aligned with the CLI: mixed missing and non-current folded // values require one full repair rather than a partial pass that cannot verify. // CLI と同様に、missing と non-current の folded 値が混在する場合は // 検証不能な partial pass ではなく1回の全行修復へ昇格する。 rewriteAll = true; + (totalSymbols, totalSymbolReferences) = + writer.CountBackfillFoldedColumns(rewriteAll); } var symbols = 0; var symbolReferences = 0; - var totalSymbols = 0; - var totalSymbolReferences = 0; var verified = false; var userVersionAfter = userVersionBefore; - - (totalSymbols, totalSymbolReferences) = writer.CountBackfillFoldedColumns(rewriteAll); if (dryRun) { symbols = totalSymbols; diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 5f998c440..40a846091 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6583,6 +6583,109 @@ UPDATE symbol_references } } + [Fact] + public void RunBackfillFold_InterruptedPromotedRewriteResumesBeforeTargetedMode_Issue4946Review() + { + var dbPath = CreateTempDbPath("cdidx_backfill_fold_promoted_resume_4946"); + using var cts = new CancellationTokenSource(); + try + { + using (var db = new DbContext(DbOpenIntent.WriteIndex, 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(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord { FileId = fileId, Kind = "function", Name = "first", Line = 1, StartLine = 1, EndLine = 1 }, + new SymbolRecord { FileId = fileId, Kind = "function", Name = "second", Line = 2, StartLine = 2, EndLine = 2 }, + ]); + writer.BackfillFoldedColumns(rewriteAll: true); + Assert.True(writer.MarkFoldReady()); + + using (var corrupt = db.Connection.CreateCommand()) + { + corrupt.CommandText = """ + UPDATE symbols + SET name_folded = CASE + WHEN name = 'first' THEN 'stale-non-current-fold' + ELSE NULL + END + """; + corrupt.ExecuteNonQuery(); + } + + DbWriter.FoldBackfillRowUpdatedForTesting = cts.Cancel; + Assert.Throws( + () => writer.BackfillFoldedColumns(rewriteAll: true, cts.Token)); + DbWriter.FoldBackfillRowUpdatedForTesting = null; + Assert.True(writer.HasFoldBackfillRewriteCheckpoint()); + } + + var resumed = RunBackfill(); + Assert.Equal(CommandExitCodes.Success, resumed.ExitCode); + Assert.Equal(1, resumed.Json.GetProperty("symbols").GetInt32()); + Assert.True(resumed.Json.GetProperty("rewrite_all").GetBoolean()); + Assert.True(resumed.Json.GetProperty("verified").GetBoolean()); + + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(db.Connection); + Assert.False(writer.HasFoldBackfillRewriteCheckpoint()); + Assert.True(writer.AllFoldedColumnsBackfilled(requireCurrentFoldKeys: true)); + + using var corrupt = db.Connection.CreateCommand(); + corrupt.CommandText = "UPDATE symbols SET name_folded = 'stale-again' WHERE name = 'first'"; + corrupt.ExecuteNonQuery(); + } + + var laterRewrite = RunBackfill(); + Assert.Equal(CommandExitCodes.Success, laterRewrite.ExitCode); + Assert.Equal(2, laterRewrite.Json.GetProperty("symbols").GetInt32()); + Assert.True(laterRewrite.Json.GetProperty("rewrite_all").GetBoolean()); + Assert.True(laterRewrite.Json.GetProperty("verified").GetBoolean()); + + using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); + var verifyWriter = new DbWriter(verifyDb.Connection); + Assert.True(verifyWriter.AllFoldedColumnsBackfilled(requireCurrentFoldKeys: true)); + + (int ExitCode, JsonElement Json) RunBackfill() + { + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + var exitCode = IndexCommandRunner.RunBackfillFold( + ["--db", dbPath, "--json", "--no-checkpoint"], + _jsonOptions); + using var document = JsonDocument.Parse(output.ToString()); + return (exitCode, document.RootElement.Clone()); + } + finally + { + Console.SetOut(originalOut); + } + } + } + } + finally + { + DbWriter.FoldBackfillRowUpdatedForTesting = null; + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + TestProjectHelper.DeleteDirectory(dbPath + ".checkpoints"); + } + } + [Fact] public void RunBackfillFold_DryRunReportsRowsWithoutWriting() { diff --git a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs index 3becacd80..5aa622f94 100644 --- a/tests/CodeIndex.Tests/McpServerToolsCallTests.cs +++ b/tests/CodeIndex.Tests/McpServerToolsCallTests.cs @@ -12051,6 +12051,51 @@ UPDATE symbols Assert.True(writer.AllFoldedColumnsBackfilled(requireCurrentFoldKeys: true)); } + [Fact] + public void ToolsCall_BackfillFold_InterruptedPromotedRewriteResumesBeforeTargetedMode_Issue4946Review() + { + var writer = new DbWriter(_db.Connection); + writer.BackfillFoldedColumns(rewriteAll: true); + Assert.True(writer.MarkFoldReady()); + + using (var corrupt = _db.Connection.CreateCommand()) + { + corrupt.CommandText = """ + UPDATE symbols + SET name_folded = CASE + WHEN id = (SELECT MIN(id) FROM symbols) THEN 'stale-non-current-fold' + ELSE NULL + END + """; + corrupt.ExecuteNonQuery(); + } + + using var cts = new CancellationTokenSource(); + try + { + DbWriter.FoldBackfillRowUpdatedForTesting = cts.Cancel; + Assert.Throws( + () => writer.BackfillFoldedColumns(rewriteAll: true, cts.Token)); + } + finally + { + DbWriter.FoldBackfillRowUpdatedForTesting = null; + } + Assert.True(writer.HasFoldBackfillRewriteCheckpoint()); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":4946,"method":"tools/call","params":{"name":"backfill_fold","arguments":{}}}""")!; + var response = _server.HandleMessage(request)!; + + Assert.False(response["result"]!["isError"]?.GetValue() ?? false, response.ToJsonString()); + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(1, structured["symbols"]!.GetValue()); + Assert.True(structured["rewrite_all"]!.GetValue()); + Assert.True(structured["verified"]!.GetValue()); + Assert.True(structured["fold_ready"]!.GetValue()); + Assert.False(writer.HasFoldBackfillRewriteCheckpoint()); + Assert.True(writer.AllFoldedColumnsBackfilled(requireCurrentFoldKeys: true)); + } + [Fact] public void ToolsCall_BackfillFold_RejectsNewerCSharpIdentityContract_Issue4866Review() { From ce5876ad879ac47aa7791cb491ad66255b931de1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 2 Aug 2026 03:58:40 +0900 Subject: [PATCH 3/3] Consume completed backfill checkpoints (#4946) --- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4946.fixed.md | 4 +- .../Cli/IndexCommandRunner.Maintenance.cs | 8 +- .../IndexCommandRunnerTests.cs | 91 +++++++++++++++++++ 4 files changed, 102 insertions(+), 5 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index ba1af2698..254f67f31 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -379,7 +379,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding share seeded databases to verify that optimize dry-run reports sizes, readiness, lock state, recommendations, and planned work while preserving the source bytes and creating no lock artifacts; the same fixtures retain the writable optimize and lock/read-only URI mutation guards. `ConsoleUiTests.PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags` keeps the optimize dry-run flag visible in full CLI usage. - Issue #4889 backfill-checkpoint coverage keeps the mutation preflight, folded-value validation, index lock, and artifact decision in one contract. Reuse the completed fixture for default no-op, forced `--checkpoint`, `--no-checkpoint` mutation, human output, a non-NULL drifted fold that requires a protected full repair, and a zero-row pending graph refresh; keep WAL/SHM open during the forced snapshot so Windows sharing semantics and the complete DB/WAL/SHM/manifest file set are exercised. Separate dry-run, invalid-DB, and held-lock assertions must prove that no checkpoint directory is created, while a real pending rewrite or pending graph refresh must retain the automatic checkpoint. -- Issue #4946 fold-backfill coverage keeps CLI and MCP preflight aligned for a database that simultaneously contains NULL folded values and non-NULL drifted values. Both surfaces must promote that mixed state to one full rewrite and verify successfully in the first invocation. If that promoted rewrite is interrupted, its checkpoint must keep the retry in full-rewrite mode until completion and then be cleared; complete databases must not pay for an additional present-value scan before their existing exact verification. +- Issue #4946 fold-backfill coverage keeps CLI and MCP preflight aligned for a database that simultaneously contains NULL folded values and non-NULL drifted values. Both surfaces must promote that mixed state to one full rewrite and verify successfully in the first invocation. If that promoted rewrite is interrupted, its checkpoint must keep the retry in full-rewrite mode until completion and then be cleared; even an end-of-table checkpoint must be consumed and exactly verified before a later scoped NULL update can use targeted repair. Complete databases without a checkpoint must not pay for an additional present-value scan before their existing exact verification. - Issue #4856 maintenance-error coverage spans `CommandErrorWriterTests`, `QueryCommandRunnerTests`, `IndexCommandRunnerTests`, and `DbCommandRunnerTests`. Keep the SQLite primary-code matrix (`5`, `6`, `8`, `11`, `26`), caller-spelled relative paths, whole-value Unix/Windows/file-URI redaction, missing and invalid-header files, directory/inaccessible preflight classification, integrity corruption rows, lock-holder metadata, the `index --optimize` human preamble, valid JSON, and category-specific recovery hints aligned with classifier version `1`. - `IndexCommandRunnerTests.Run_DryRunWithRebuildAndMemoryTrace_SkipsConfirmationAndPreservesWorkspace_Issue4580` @@ -1339,7 +1339,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" seed 済み database を共有し、optimize dry-run が size、readiness、lock state、推奨、planned work を報告しつつ source byte を保持し、lock artifact を作成しないことを検証する。同じ fixture で、書き込み版 optimize と lock/read-only URI の mutation guard も維持する。 `ConsoleUiTests.PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags` は、full CLI usage に optimize dry-run flag が表示され続けることを固定します。 - Issue #4889 の backfill-checkpoint coverage は、mutation preflight、folded値の検証、index lock、artifact 作成判断を1つの契約として固定します。完了済み fixture を既定no-op、明示 `--checkpoint`、`--no-checkpoint` mutation、human output、保護された全行修復を必要とする非NULLのfold drift、zero-rowのpending graph refreshで再利用し、明示snapshot中は WAL/SHM をopenしたままにしてWindowsの共有 semanticsとDB/WAL/SHM/manifest一式を検証してください。dry-run、無効DB、lock保持中のassertionではcheckpoint directoryが作られないことを個別に証明し、実際にpending rewriteまたはpending graph refreshがある場合はautomatic checkpointを維持してください。 -- Issue #4946 の fold-backfill coverage は、NULL の folded 値と非 NULL だが drift した値が同時に存在する database で CLI と MCP の preflight を同期させます。両 surface はこの混在状態を1回の全行 rewrite に昇格し、最初の invocation で検証成功しなければなりません。昇格した rewrite が中断された場合は、完了まで checkpoint に従って全行 rewrite mode を維持してから checkpoint を消去し、complete database では既存の厳密検証より前に追加の present-value scan を実行しないでください。 +- Issue #4946 の fold-backfill coverage は、NULL の folded 値と非 NULL だが drift した値が同時に存在する database で CLI と MCP の preflight を同期させます。両 surface はこの混在状態を1回の全行 rewrite に昇格し、最初の invocation で検証成功しなければなりません。昇格した rewrite が中断された場合は、完了まで checkpoint に従って全行 rewrite mode を維持してから checkpoint を消去します。table 末尾に到達した checkpoint も、後続の scoped NULL update が対象修復を使う前に消費して厳密検証してください。checkpoint のない complete database では、既存の厳密検証より前に追加の present-value scan を実行しないでください。 - Issue #4856 の maintenance-error coverage は `CommandErrorWriterTests`、`QueryCommandRunnerTests`、`IndexCommandRunnerTests`、`DbCommandRunnerTests` にまたがります。 SQLite primary-code matrix (`5`、`6`、`8`、`11`、`26`)、呼び出し側表記を維持する relative path、Unix / Windows / file URI の値全体 redaction、missing / invalid-header file、directory / inaccessible preflight classification、integrity corruption row、lock-holder metadata、`index --optimize` の human preamble、有効な JSON、category 別 recovery hint を classifier version `1` と同期させてください。 - `IndexCommandRunnerTests.Run_DryRunWithRebuildAndMemoryTrace_SkipsConfirmationAndPreservesWorkspace_Issue4580` diff --git a/changelog.d/unreleased/4946.fixed.md b/changelog.d/unreleased/4946.fixed.md index cdbf66bbc..24a616bd6 100644 --- a/changelog.d/unreleased/4946.fixed.md +++ b/changelog.d/unreleased/4946.fixed.md @@ -13,8 +13,8 @@ affected: ## English -- **Fold backfill now repairs mixed missing and stale folded keys in one pass (#4946)** — `backfill-fold` and MCP `backfill_fold` distinguish targetable NULL values from non-NULL drift during preflight and promote mixed state to a full rewrite, so verification succeeds without requiring a rebuild or retry. An interrupted promoted rewrite remains in full-rewrite mode until its checkpointed work completes. +- **Fold backfill now repairs mixed missing and stale folded keys in one pass (#4946)** — `backfill-fold` and MCP `backfill_fold` distinguish targetable NULL values from non-NULL drift during preflight and promote mixed state to a full rewrite, so verification succeeds without requiring a rebuild or retry. An interrupted promoted rewrite remains in full-rewrite mode until its checkpointed work completes, and the CLI consumes and verifies even a completed persisted checkpoint before later scoped updates can use targeted repair. ## 日本語 -- **fold backfill が missing / stale folded key の混在を1回で修復するようになりました (#4946)** — `backfill-fold` と MCP `backfill_fold` は preflight で対象修復可能な NULL 値と非 NULL の drift を区別し、混在時は全行 rewrite に昇格するため、rebuild や再試行なしで検証が成功します。昇格後に中断された rewrite は、checkpoint の処理が完了するまで全行 rewrite mode を維持します。 +- **fold backfill が missing / stale folded key の混在を1回で修復するようになりました (#4946)** — `backfill-fold` と MCP `backfill_fold` は preflight で対象修復可能な NULL 値と非 NULL の drift を区別し、混在時は全行 rewrite に昇格するため、rebuild や再試行なしで検証が成功します。昇格後に中断された rewrite は checkpoint の処理が完了するまで全行 rewrite mode を維持し、CLI は完了済みの永続 checkpoint も消費して検証してから、後続の scoped update に対する対象修復へ戻ります。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs index 1794483ea..2f2676f9e 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.Maintenance.cs @@ -638,8 +638,9 @@ internal static int RunBackfillFold( // 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 hasRewriteCheckpoint = writer.HasFoldBackfillRewriteCheckpoint(); var rewriteAll = writer.ResolveFoldBackfillRewriteAll( - !foldMetadataCurrentBefore || writer.HasFoldBackfillRewriteCheckpoint()); + !foldMetadataCurrentBefore || hasRewriteCheckpoint); var symbols = 0; var symbolReferences = 0; @@ -675,6 +676,11 @@ internal static int RunBackfillFold( var symbolExtractorVersionsCurrent = writer.SymbolExtractorVersionsMatchCurrent(); var mutationRequired = pendingRows.Symbols > 0 || pendingRows.SymbolReferences > 0 + // Even when its cursors have reached the end, a persisted rewrite checkpoint + // must run through BackfillFoldedColumns so it is cleared before exact verification. + // cursor が末尾でも、永続化済み rewrite checkpoint は BackfillFoldedColumns を + // 通して消去してから厳密検証する必要がある。 + || hasRewriteCheckpoint || !foldReadyBefore || csharpSymbolNameContractUpgradeRequired || graphRefreshPending diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 40a846091..782e40671 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -6686,6 +6686,97 @@ ELSE NULL } } + [Fact] + public void RunBackfillFold_ClearsCompletedRewriteCheckpointBeforeLaterScopedRepair_Issue4946Review2() + { + var dbPath = CreateTempDbPath("cdidx_backfill_fold_completed_checkpoint_4946"); + try + { + using (var db = new DbContext(DbOpenIntent.WriteIndex, 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(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc), + }); + writer.InsertSymbols([ + new SymbolRecord { FileId = fileId, Kind = "function", Name = "first", Line = 1, StartLine = 1, EndLine = 1 }, + new SymbolRecord { FileId = fileId, Kind = "function", Name = "second", Line = 2, StartLine = 2, EndLine = 2 }, + ]); + writer.BackfillFoldedColumns(rewriteAll: true); + Assert.True(writer.MarkFoldReady()); + + // Model cancellation after the graph refresh but before the completed + // full-rewrite checkpoint is cleared. + writer.SetMeta("fold_backfill_phase", "references"); + writer.SetMeta("fold_backfill_last_symbol_id", long.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); + writer.SetMeta("fold_backfill_last_reference_id", long.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Assert.True(writer.HasFoldBackfillRewriteCheckpoint()); + } + + var cleanup = RunBackfill(); + Assert.Equal(CommandExitCodes.Success, cleanup.ExitCode); + Assert.Equal(0, cleanup.Json.GetProperty("symbols").GetInt32()); + Assert.Equal(0, cleanup.Json.GetProperty("symbol_references").GetInt32()); + Assert.True(cleanup.Json.GetProperty("rewrite_all").GetBoolean()); + Assert.False(cleanup.Json.GetProperty("was_already_complete").GetBoolean()); + Assert.True(cleanup.Json.GetProperty("verified").GetBoolean()); + + using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) + { + var writer = new DbWriter(db.Connection); + Assert.False(writer.HasFoldBackfillRewriteCheckpoint()); + using var scopedUpdate = db.Connection.CreateCommand(); + scopedUpdate.CommandText = "UPDATE symbols SET name_folded = NULL WHERE name = 'first'"; + Assert.Equal(1, scopedUpdate.ExecuteNonQuery()); + } + + var repair = RunBackfill(); + Assert.Equal(CommandExitCodes.Success, repair.ExitCode); + Assert.Equal(1, repair.Json.GetProperty("symbols").GetInt32()); + Assert.False(repair.Json.GetProperty("rewrite_all").GetBoolean()); + Assert.True(repair.Json.GetProperty("verified").GetBoolean()); + + using var verifyDb = new DbContext(DbOpenIntent.WriteIndex, dbPath); + var verifyWriter = new DbWriter(verifyDb.Connection); + Assert.False(verifyWriter.HasFoldBackfillRewriteCheckpoint()); + Assert.True(verifyWriter.AllFoldedColumnsBackfilled(requireCurrentFoldKeys: true)); + + (int ExitCode, JsonElement Json) RunBackfill() + { + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var output = new StringWriter(); + try + { + Console.SetOut(output); + var exitCode = IndexCommandRunner.RunBackfillFold( + ["--db", dbPath, "--json", "--no-checkpoint"], + _jsonOptions); + using var document = JsonDocument.Parse(output.ToString()); + return (exitCode, document.RootElement.Clone()); + } + finally + { + Console.SetOut(originalOut); + } + } + } + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteFile(dbPath); + TestProjectHelper.DeleteDirectory(dbPath + ".checkpoints"); + } + } + [Fact] public void RunBackfillFold_DryRunReportsRowsWithoutWriting() {