From 32676b23e873d6c674ebf4edc944cb2e77148dec Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 22:23:17 +0900 Subject: [PATCH 1/3] Fix resumable partial find scans (#4863) --- changelog.d/unreleased/4863.fixed.md | 20 + docs/find-scan-controls.md | 45 +- src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- .../Cli/JsonEnvelopeWrapper.Bounded.cs | 243 +++++++++- src/CodeIndex/Cli/ProgramRunner.Dispatch.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.Find.cs | 134 +++++- .../Database/DbReader.FilesStatus.cs | 203 ++++++++- .../Mcp/McpToolHandlers.Pagination.cs | 10 +- src/CodeIndex/Models/QueryResults.cs | 6 +- .../JsonEnvelopeWrapperIssue4863Tests.cs | 430 ++++++++++++++++++ .../QueryCommandRunnerFindIssue4350Tests.cs | 3 +- .../QueryCommandRunnerFindIssue4578Tests.cs | 11 +- 12 files changed, 1046 insertions(+), 63 deletions(-) create mode 100644 changelog.d/unreleased/4863.fixed.md create mode 100644 tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs diff --git a/changelog.d/unreleased/4863.fixed.md b/changelog.d/unreleased/4863.fixed.md new file mode 100644 index 000000000..db1ec87a3 --- /dev/null +++ b/changelog.d/unreleased/4863.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4863 +affected: + - src/CodeIndex/Cli + - src/CodeIndex/Database + - src/CodeIndex/Mcp + - src/CodeIndex/Models + - tests/CodeIndex.Tests + - docs/find-scan-controls.md +--- + +## English + +- **Partial `find` scans now return stable continuation cursors (#4863)** — Result-limit and scan-cap pages resume at the next match boundary without duplicates or gaps, including repeated matches on Unicode or very large lines. Cursors are bound to the query, scan options, source position, and index generation, with typed malformed, mismatch, and stale failures. + +## 日本語 + +- **部分的な `find` scan が安定した continuation cursor を返すようになりました (#4863)** — result limit または scan cap で区切られた page は、Unicode や非常に長い行に同じ match が複数ある場合も、重複や欠落なしで次の match boundary から再開します。cursor は query、scan option、source position、index generation に紐づき、不正形式、条件不一致、stale の失敗を型付きで返します。 diff --git a/docs/find-scan-controls.md b/docs/find-scan-controls.md index 2750aa63a..8186af749 100644 --- a/docs/find-scan-controls.md +++ b/docs/find-scan-controls.md @@ -20,9 +20,27 @@ streaming NDJSON, or count output instead. Candidate-file or line-scan truncation returns partial-result exit code `11`. Pass `--allow-partial` only when an incomplete scan may return exit code `0`. An ordinary result-limit early stop remains successful, but the row terminal -sets `scan_complete=false`, `result_limit_reached=true`, and explains how to -increase `--limit` or narrow the query. Human output writes its scan summary to -stderr and uses the same partial exit semantics. +sets `scan_complete=false`, `result_limit_reached=true`, and returns a +`next_cursor`. Pass it back with `--cursor`; `--limit` may be changed for the +next page. Human output writes the cursor and scan summary to stderr and uses +the same partial exit semantics. + +Find continuation cursors are opaque and resume at the next match record, +including when multiple matches share a line, the line contains Unicode, or +the line is very large. The cursor binds the query, literal/regex mode and +other result-affecting options, candidate-file ordinal and path, line and match +ordinal, UTF-8 byte position, source identity, and index generation. Reusing a +cursor with different options returns `cursor_mismatch`; using it after the +indexed source changes returns `cursor_stale`; malformed or invalid positions +return `cursor_malformed`. The final page sets `has_more=false` and +`next_cursor=null`. A cancelled or regex-timeout request does not advance or +issue a continuation cursor; retry the last cursor from a successfully +completed page. + +When count mode reaches a scan cap, its `count` is the count for that partial +scan page and `next_cursor` resumes at the next line boundary. Continue until +`has_more=false`; summing the page counts yields the complete count for an +unchanged indexed source. For scoped `--path` searches, `--format compact` returns locations only. Use text or JSON output when context from `--before`, `--after`, or @@ -47,9 +65,24 @@ NDJSON、count 出力を使ってください。 candidate-file または line-scan による切り詰めは partial-result 終了コード `11` を 返します。不完全な scan でも終了コード `0` を許容する場合だけ `--allow-partial` を 指定してください。通常の result limit による早期停止は成功のままですが、row 終端は -`scan_complete=false`、`result_limit_reached=true` を設定し、`--limit` を増やすか -query を絞る方法を示します。human output は scan summary を stderr に出し、同じ -partial exit semantics を使います。 +`scan_complete=false`、`result_limit_reached=true` と `next_cursor` を返します。 +この値を `--cursor` へ渡して続行してください。次の page では `--limit` を変更できます。 +human output は cursor と scan summary を stderr に出し、同じ partial exit semantics を +使います。 + +find continuation cursor は opaque で、同じ行に複数の match がある場合、Unicode を +含む行、非常に長い行でも、次の match record から再開します。cursor は query、 +literal / regex mode、結果に影響するその他の option、candidate-file ordinal と path、 +line と match ordinal、UTF-8 byte position、source identity、index generation に +紐づきます。異なる option での再利用は `cursor_mismatch`、indexed source の変更後の +再利用は `cursor_stale`、不正な形式または位置は `cursor_malformed` を返します。 +最終 page は `has_more=false`、`next_cursor=null` になります。cancel または regex +timeout になった request は continuation cursor を進めたり新しく発行したりしません。 +正常に完了した直前の page が返した cursor を再試行してください。 + +count mode が scan cap に達した場合、`count` はその部分 scan page の件数となり、 +`next_cursor` は次の line boundary から再開します。`has_more=false` になるまで続けると、 +変更されていない indexed source では各 page の count の合計が完全な件数になります。 `--path` で scope を限定した検索では、`--format compact` は location のみを返します。 `--before`、`--after`、`--snippet-lines` の context が必要な場合は text または diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index a29bdc682..e0510112d 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -329,7 +329,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--duplicate-threshold", ValuePlaceholder = "", Description = "Issue-drafts: explicit duplicate-preflight minimum score from 0 to 1", PrimaryCommands = Set("search", "suggestions") }, new() { Name = "--issue-title", ValuePlaceholder = "", Description = "Search issue-drafts: override the title for an ad hoc search draft", PrimaryCommands = Set("search") }, new() { Name = "--issue-label", ValuePlaceholder = "<label>", Description = "Search issue-drafts: add a label hint; repeat or comma-separate values", PrimaryCommands = Set("search") }, - new() { Name = "--cursor", ValuePlaceholder = "<cursor>", Description = "Opaque pagination cursor returned as next_cursor and bound to its query and index generation", PrimaryCommands = Set(CursorCommands) }, + new() { Name = "--cursor", ValuePlaceholder = "<cursor>", Description = "Opaque continuation cursor returned as next_cursor and bound to its query, options, and index generation; find cursors resume at match boundaries", PrimaryCommands = Set(CursorCommands) }, new() { Name = "--status", ValuePlaceholder = "<all|draft|submitted_pending_triage|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded|submitted|unsubmitted>", Description = "Suggestions: filter by suggestion status", PrimaryCommands = Set("suggestions") }, new() { Name = "--category", ValuePlaceholder = "<symbol_extraction|reference_extraction|search_ranking|language_support|output_format|crash_report|unexpected_error|other>", Description = "Suggestions: filter by category", PrimaryCommands = Set("suggestions") }, new() { Name = "--agent", ValuePlaceholder = "<agent>", Description = "Suggestions: filter by agent", PrimaryCommands = Set("suggestions") }, diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs index 487ef9e3a..a2bd75a1c 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs @@ -61,6 +61,8 @@ internal static bool ShouldAutoWrapBoundedResponse(string command, string[] args return false; if (command == "search" && IsSearchAggregateResponseRequest(args)) return false; + if (command == "find" && IsFindCountResponseRequest(args)) + return false; if (HasArgument(args, "--fields") || HasArgument(args, "--cursor")) return true; if (command == "languages" @@ -80,6 +82,8 @@ private static bool IsBoundedResponseRequest(string command, string[] args) return false; if (command == "search" && IsSearchAggregateResponseRequest(args)) return false; + if (command == "find" && IsFindCountResponseRequest(args)) + return false; return HasArgument(args, "--fields") || HasArgument(args, "--cursor") @@ -88,6 +92,33 @@ private static bool IsBoundedResponseRequest(string command, string[] args) || ShouldAutoWrapBoundedResponse(command, args); } + private static bool IsFindCountResponseRequest(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], "--", StringComparison.Ordinal)) + break; + if (string.Equals(args[i], "--query", StringComparison.Ordinal) + && i + 1 < args.Length) + { + i++; + continue; + } + if (string.Equals(args[i], "--count", StringComparison.Ordinal) + || string.Equals(args[i], "--format=count", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if (string.Equals(args[i], "--format", StringComparison.Ordinal) + && i + 1 < args.Length + && string.Equals(args[i + 1], "count", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + private static bool IsSearchAggregateResponseRequest(string[] args) => HasArgument(args, "--recipe") || HasArgument(args, "--list-recipes") @@ -166,14 +197,14 @@ private static int RunBoundedResponse( && !string.Equals(controls.CursorQueryFingerprint, queryFingerprint, StringComparison.Ordinal)) { return WriteBoundedResponseUsageError( - "--cursor does not match this command, query, or filter set.", + "cursor_mismatch: --cursor does not match this command, query, or filter set.", "Use next_cursor from the preceding page without changing query, filter, or sort arguments."); } if (controls.CursorGenerationFingerprint is not null && !string.Equals(controls.CursorGenerationFingerprint, snapshot.GenerationFingerprint, StringComparison.Ordinal)) { return WriteBoundedResponseUsageError( - "--cursor is stale because the index generation changed.", + "cursor_stale: --cursor is stale because the index generation changed.", "Restart pagination without --cursor and use the next_cursor returned by the refreshed index."); } if (controls.Offset > MaxPageWindow - controls.PageLimit) @@ -199,7 +230,10 @@ private static int RunBoundedResponse( controls.Fields, controls.Compact, controls.ResumePath, - controls.ResumeLine); + controls.ResumeLine, + controls.ResumeFileOrdinal, + controls.ResumeMatchOrdinal, + controls.ResumeByteOffset); using var executionScope = EnterBoundedExecution(executionContext); exitCode = runInner(innerArgs); } @@ -379,8 +413,15 @@ JsonObject BuildCandidate(int count) var scanCursor = ReadString(streamTerminal, "next_cursor"); var emittedAllCapturedRows = count == pageItems.Count; var selectedScanCursor = emittedAllCapturedRows ? scanCursor : null; + var findScanTerminalAuthoritative = command == "find" + && streamTerminal?["scan_complete"] is JsonValue; + var capturedRowsRemain = !emittedAllCapturedRows && count > 0; var hasMore = selectedScanCursor is not null - || count > 0 && nextOffset < totalCount && !paginationWindowExhausted; + || capturedRowsRemain + || !findScanTerminalAuthoritative + && count > 0 + && nextOffset < totalCount + && !paginationWindowExhausted; metadata["result_count"] = count; metadata["returned_count"] = count; metadata["total_count"] = totalCount; @@ -392,7 +433,15 @@ JsonObject BuildCandidate(int count) metadata["has_more"] = hasMore; metadata["next_cursor"] = selectedScanCursor ?? (hasMore && count > 0 - ? FormatResponseCursor(nextOffset, queryFingerprint, snapshot.GenerationFingerprint) + ? FormatResponseCursor( + nextOffset, + queryFingerprint, + snapshot.GenerationFingerprint, + controls.ResumePath, + controls.ResumeLine, + controls.ResumeFileOrdinal, + controls.ResumeMatchOrdinal, + controls.ResumeByteOffset) : null); metadata["truncated"] = scanCursor is not null || totalCount > count; metadata["pagination_window_limit"] = MaxPageWindow; @@ -1147,6 +1196,9 @@ private static bool TryParseBoundedResponseControls( string? cursorGenerationFingerprint = null; string? resumePath = null; int? resumeLine = null; + int? resumeFileOrdinal = null; + int? resumeMatchOrdinal = null; + int? resumeByteOffset = null; if (cursor is not null && !TryParseResponseCursor( cursor, @@ -1154,10 +1206,13 @@ private static bool TryParseBoundedResponseControls( out cursorQueryFingerprint, out cursorGenerationFingerprint, out resumePath, - out resumeLine)) + out resumeLine, + out resumeFileOrdinal, + out resumeMatchOrdinal, + out resumeByteOffset)) { controls = default!; - error = "--cursor must be an opaque response:v2 cursor returned as next_cursor."; + error = "cursor_malformed: --cursor must be an opaque response:v2 cursor returned as next_cursor."; return false; } controls = new BoundedResponseControls( @@ -1169,7 +1224,10 @@ private static bool TryParseBoundedResponseControls( cursorQueryFingerprint, cursorGenerationFingerprint, resumePath, - resumeLine); + resumeLine, + resumeFileOrdinal, + resumeMatchOrdinal, + resumeByteOffset); return true; } @@ -1258,7 +1316,10 @@ internal static string FormatResponseCursor( string queryFingerprint, string generationFingerprint, string? resumePath = null, - int? resumeLine = null) + int? resumeLine = null, + int? resumeFileOrdinal = null, + int? resumeMatchOrdinal = null, + int? resumeByteOffset = null) { var payload = new JsonObject { @@ -1270,6 +1331,12 @@ internal static string FormatResponseCursor( payload["resume_path"] = resumePath; if (resumeLine.HasValue) payload["resume_line"] = resumeLine.Value; + if (resumeFileOrdinal.HasValue) + payload["resume_file_ordinal"] = resumeFileOrdinal.Value; + if (resumeMatchOrdinal.HasValue) + payload["resume_match_ordinal"] = resumeMatchOrdinal.Value; + if (resumeByteOffset.HasValue) + payload["resume_byte_offset"] = resumeByteOffset.Value; var encoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(payload.ToJsonString())) .TrimEnd('=') .Replace('+', '-') @@ -1283,13 +1350,19 @@ internal static bool TryParseResponseCursor( out string? queryFingerprint, out string? generationFingerprint, out string? resumePath, - out int? resumeLine) + out int? resumeLine, + out int? resumeFileOrdinal, + out int? resumeMatchOrdinal, + out int? resumeByteOffset) { offset = 0; queryFingerprint = null; generationFingerprint = null; resumePath = null; resumeLine = null; + resumeFileOrdinal = null; + resumeMatchOrdinal = null; + resumeByteOffset = null; if (cursor.StartsWith(LegacyResponseCursorPrefix, StringComparison.Ordinal)) { var remainder = cursor[LegacyResponseCursorPrefix.Length..]; @@ -1331,13 +1404,56 @@ internal static bool TryParseResponseCursor( queryFingerprint = ReadString(payload, "query"); generationFingerprint = ReadString(payload, "generation"); resumePath = ReadString(payload, "resume_path"); - if (payload["resume_line"] is JsonValue resumeValue && resumeValue.TryGetValue<int>(out var parsedResumeLine)) + if (payload.ContainsKey("resume_line")) + { + if (payload["resume_line"] is not JsonValue resumeValue + || !resumeValue.TryGetValue<int>(out var parsedResumeLine)) + { + return false; + } resumeLine = parsedResumeLine; + } + if (payload.ContainsKey("resume_file_ordinal")) + { + if (payload["resume_file_ordinal"] is not JsonValue fileOrdinalValue + || !fileOrdinalValue.TryGetValue<int>(out var parsedFileOrdinal)) + { + return false; + } + resumeFileOrdinal = parsedFileOrdinal; + } + if (payload.ContainsKey("resume_match_ordinal")) + { + if (payload["resume_match_ordinal"] is not JsonValue matchOrdinalValue + || !matchOrdinalValue.TryGetValue<int>(out var parsedMatchOrdinal)) + { + return false; + } + resumeMatchOrdinal = parsedMatchOrdinal; + } + if (payload.ContainsKey("resume_byte_offset")) + { + if (payload["resume_byte_offset"] is not JsonValue byteOffsetValue + || !byteOffsetValue.TryGetValue<int>(out var parsedByteOffset)) + { + return false; + } + resumeByteOffset = parsedByteOffset; + } + var extendedResumeFieldsPresent = resumeFileOrdinal.HasValue + || resumeMatchOrdinal.HasValue + || resumeByteOffset.HasValue; return IsCursorFingerprint(queryFingerprint) && IsCursorFingerprint(generationFingerprint) && (resumePath is null || resumePath.Length <= 4096) && (!resumeLine.HasValue || resumeLine.Value > 0) - && (resumePath is null) == !resumeLine.HasValue; + && (resumePath is null) == !resumeLine.HasValue + && (!extendedResumeFieldsPresent + || resumePath is not null + && resumeFileOrdinal is >= 0 + && resumeByteOffset is >= 0 + && resumeMatchOrdinal is null or >= 0) + && (!resumeMatchOrdinal.HasValue || resumeByteOffset.HasValue); } private static bool IsCursorFingerprint(string? fingerprint) @@ -1420,7 +1536,10 @@ private sealed record BoundedResponseControls( string? CursorQueryFingerprint, string? CursorGenerationFingerprint, string? ResumePath, - int? ResumeLine) + int? ResumeLine, + int? ResumeFileOrdinal, + int? ResumeMatchOrdinal, + int? ResumeByteOffset) { public IReadOnlyList<string>? EffectiveFields(string command, string? primaryCollection) { @@ -1507,7 +1626,10 @@ internal static (string Cursor, string? ResultStableAt) BuildFindResumeCursor( string[] args, DbReader reader, string resumePath, - int resumeLine) + int resumeLine, + int resumeFileOrdinal, + int? resumeMatchOrdinal, + int resumeByteOffset) { var snapshot = BuildResponseSnapshot(reader); return ( @@ -1516,7 +1638,10 @@ internal static (string Cursor, string? ResultStableAt) BuildFindResumeCursor( BuildResponseFingerprint("find", args), snapshot.GenerationFingerprint, resumePath, - resumeLine), + resumeLine, + resumeFileOrdinal, + resumeMatchOrdinal, + resumeByteOffset), snapshot.ResultStableAt); } @@ -1529,12 +1654,89 @@ internal static int GetBoundedResponseOffset(string command) : 0; } - internal static (string? Path, int? Line) GetBoundedFindResume() + internal static (string? Path, int? Line, int? FileOrdinal, int? MatchOrdinal, int? ByteOffset) GetBoundedFindResume() { var execution = BoundedExecution.Value; return execution is not null && string.Equals(execution.Command, "find", StringComparison.Ordinal) - ? (execution.ResumePath, execution.ResumeLine) - : (null, null); + ? ( + execution.ResumePath, + execution.ResumeLine, + execution.ResumeFileOrdinal, + execution.ResumeMatchOrdinal, + execution.ResumeByteOffset) + : (null, null, null, null, null); + } + + internal static (string? Path, int? Line, int? FileOrdinal, int? MatchOrdinal, int? ByteOffset) + GetStandaloneFindResume(string[] args, DbReader reader) + { + string? cursor = null; + var cursorSeen = false; + for (var i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], "--", StringComparison.Ordinal)) + break; + if (string.Equals(args[i], "--query", StringComparison.Ordinal) + && i + 1 < args.Length) + { + i++; + continue; + } + if (args[i].StartsWith("--cursor=", StringComparison.Ordinal)) + { + if (cursorSeen) + throw new FindContinuationException("cursor_malformed", "find count accepts exactly one --cursor value."); + cursorSeen = true; + cursor = args[i]["--cursor=".Length..]; + continue; + } + if (!string.Equals(args[i], "--cursor", StringComparison.Ordinal)) + continue; + if (cursorSeen + || i + 1 >= args.Length + || args[i + 1].StartsWith("--", StringComparison.Ordinal)) + { + throw new FindContinuationException("cursor_malformed", "find count requires exactly one non-empty --cursor value."); + } + cursorSeen = true; + cursor = args[++i]; + } + if (cursor is null) + return (null, null, null, null, null); + if (!TryParseResponseCursor( + cursor, + out var offset, + out var queryFingerprint, + out var generationFingerprint, + out var resumePath, + out var resumeLine, + out var resumeFileOrdinal, + out var resumeMatchOrdinal, + out var resumeByteOffset) + || offset != 0 + || resumePath is null + || !resumeLine.HasValue) + { + throw new FindContinuationException( + "cursor_malformed", + "find count cursor must be an opaque resumable response:v2 cursor returned as next_cursor."); + } + + var expectedQueryFingerprint = BuildResponseFingerprint("find", args); + if (!string.Equals(queryFingerprint, expectedQueryFingerprint, StringComparison.Ordinal)) + { + throw new FindContinuationException( + "cursor_mismatch", + "find count cursor does not match this query, scan mode, or option set."); + } + var snapshot = BuildResponseSnapshot(reader); + if (!string.Equals(generationFingerprint, snapshot.GenerationFingerprint, StringComparison.Ordinal)) + { + throw new FindContinuationException( + "cursor_stale", + "find count cursor is stale because the index generation changed."); + } + return (resumePath, resumeLine, resumeFileOrdinal, resumeMatchOrdinal, resumeByteOffset); } internal static int? GetBoundedResponseLimit(string command) @@ -1613,7 +1815,10 @@ private sealed record BoundedExecutionContext( IReadOnlyList<string>? Fields, bool Compact, string? ResumePath, - int? ResumeLine) + int? ResumeLine, + int? ResumeFileOrdinal, + int? ResumeMatchOrdinal, + int? ResumeByteOffset) { public int? ReportedTotalCount { get; set; } public bool ReportedTotalCountAuthoritative { get; set; } diff --git a/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs b/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs index a555a3b23..22980e9f6 100644 --- a/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs +++ b/src/CodeIndex/Cli/ProgramRunner.Dispatch.cs @@ -70,7 +70,7 @@ private static int RunDispatchedCommand( "callees" => a => QueryCommandRunner.RunCallees(a, context.JsonOptions), "symbols" => a => QueryCommandRunner.RunSymbols(a, context.JsonOptions), "files" => a => QueryCommandRunner.RunFiles(a, context.JsonOptions), - "find" => a => QueryCommandRunner.RunFind(a, context.JsonOptions), + "find" => a => QueryCommandRunner.RunFind(a, context.JsonOptions, context.CancellationToken), "excerpt" => a => QueryCommandRunner.RunExcerpt(a, context.JsonOptions), "map" => a => QueryCommandRunner.RunMap(a, context.JsonOptions), "inspect" => a => QueryCommandRunner.RunInspect(a, context.JsonOptions), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs index 47d00aca4..e289a8ada 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs @@ -12,8 +12,12 @@ public static partial class QueryCommandRunner internal const int MaxFindLineScanLimit = 10_000_000; private const string FindUsage = "Usage: cdidx find <query> (--path <glob>|--all) [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--fields <csv>] [--cursor <next_cursor>] [--max-json-bytes <n>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--exclude-path <glob>] [--exclude-tests] [--context <n>] [--before <n>] [--after <n>] [--snippet-lines <n>] [--focus-line <line>] [--focus-column <n>] [--max-line-width <n>] [--line-scan-limit <n>] [--allow-partial] [--exact] [--regex] [--count]\n cdidx find --query <query> (--path <glob>|--all) [...]\n cdidx find [options] -- <query>"; - public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) + public static int RunFind( + string[] cmdArgs, + JsonSerializerOptions jsonOptions, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var preparedFindArgs = PrepareFindArgs(cmdArgs, out var preparationError); if (preparationError != null) { @@ -29,7 +33,10 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; } - var findValidationError = ValidateFindArgs(normalizedFindArgs); + var parserFindArgs = IsFindCountRequest(normalizedFindArgs) + ? StripFindCountCursor(normalizedFindArgs) + : normalizedFindArgs; + var findValidationError = ValidateFindArgs(parserFindArgs); if (findValidationError != null) { CommandErrorWriter.WriteStderr(findValidationError); @@ -38,7 +45,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) } var options = ParseArgs( - normalizedFindArgs, + parserFindArgs, jsonDefault: false, allowNamedQuery: true, validateDefaultSnippetLines: false); @@ -129,6 +136,8 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) FindCountResult counts; try { + var (countResumePath, countResumeLine, countResumeFileOrdinal, countResumeMatchOrdinal, countResumeByteOffset) + = JsonEnvelopeWrapper.GetStandaloneFindResume(cmdArgs, reader); counts = reader.CountFindInFiles( options.Query, options.Lang, @@ -141,7 +150,17 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) options.Regex, candidateFileLimit, lineLimit, - useIndexedLiteralCandidates: options.All); + useIndexedLiteralCandidates: options.All, + resumePath: countResumePath, + resumeLine: countResumeLine, + resumeFileOrdinal: countResumeFileOrdinal, + resumeMatchOrdinal: countResumeMatchOrdinal, + resumeByteOffset: countResumeByteOffset, + cancellationToken: cancellationToken); + } + catch (FindContinuationException ex) + { + return WriteFindCursorError(ex, jsonOptions, options.Json); } catch (Exception ex) when (options.Regex && (ex is ArgumentException || ex is RegexMatchTimeoutException)) { @@ -149,6 +168,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) ? WriteFindRegexTimeoutError(timeout, jsonOptions, options.Json) : WriteFindInvalidRegexError(ex, jsonOptions, options.Json); } + var countFindResume = BuildFindResumeCursor(cmdArgs, reader, counts.Scan); if (options.Json) { var payload = BuildCountJsonPayload( @@ -168,7 +188,8 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) counts.Scan, counts.Count, countMode: true, - resultStableAt: reader.GetPaginationGeneration().StableAt); + nextCursor: countFindResume.Cursor, + resultStableAt: countFindResume.ResultStableAt); } }); Console.WriteLine(payload.ToJsonString(jsonOptions)); @@ -176,13 +197,14 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) else { Console.WriteLine($"{counts.Count}"); - WriteFindScanSummary(counts.Scan, countMode: true); + WriteFindScanSummary(counts.Scan, countMode: true, nextCursor: countFindResume.Cursor); } return FindScanExitCode(options, counts.Scan); } var (contextBefore, contextAfter, snippetLines) = ResolveFindContext(options, preparedFindArgs); - var (resumePath, resumeLine) = JsonEnvelopeWrapper.GetBoundedFindResume(); + var (resumePath, resumeLine, resumeFileOrdinal, resumeMatchOrdinal, resumeByteOffset) + = JsonEnvelopeWrapper.GetBoundedFindResume(); FindResults findResults; try { @@ -205,7 +227,15 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) JsonEnvelopeWrapper.GetBoundedResponseOffset("find"), useIndexedLiteralCandidates: options.All, resumePath: resumePath, - resumeLine: resumeLine); + resumeLine: resumeLine, + resumeFileOrdinal: resumeFileOrdinal, + resumeMatchOrdinal: resumeMatchOrdinal, + resumeByteOffset: resumeByteOffset, + cancellationToken: cancellationToken); + } + catch (FindContinuationException ex) + { + return WriteFindCursorError(ex, jsonOptions, options.Json); } catch (ArgumentException ex) when (options.Regex) { @@ -320,7 +350,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) jsonOptions, findResults.Scan, results.Count, - resultLimitReached: results.Count >= options.Limit, + resultLimitReached: findResults.Scan.ResultLimitReached, commandArgs: cmdArgs); } } @@ -336,7 +366,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) CommandErrorWriter.WriteStderr($"({results.Count} matches in {fileCount} files)"); WriteFindScanSummary( findResults.Scan, - resultLimitReached: results.Count >= options.Limit, + resultLimitReached: findResults.Scan.ResultLimitReached, nextCursor: findResume.Cursor); } return FindScanExitCode(options, findResults.Scan); @@ -369,6 +399,72 @@ internal static int WriteFindRegexTimeoutError(RegexMatchTimeoutException ex, Js category: RegexTimeoutPolicy.RegexTimeoutCategory); } + private static int WriteFindCursorError( + FindContinuationException ex, + JsonSerializerOptions jsonOptions, + bool json) + => CommandErrorWriter.WriteJsonOrHuman( + json, + jsonOptions, + ex.Message, + CommandExitCodes.UsageError, + "Restart find pagination without --cursor and use an unmodified next_cursor from the preceding page.", + errorCode: CommandErrorCodes.UsageError, + category: ex.Reason); + + private static string[] StripFindCountCursor(string[] args) + { + var stripped = new List<string>(args.Length); + for (var i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], "--query", StringComparison.Ordinal) + && i + 1 < args.Length) + { + stripped.Add(args[i]); + stripped.Add(args[++i]); + continue; + } + if (args[i].StartsWith("--cursor=", StringComparison.Ordinal)) + continue; + if (string.Equals(args[i], "--cursor", StringComparison.Ordinal)) + { + if (i + 1 < args.Length + && !args[i + 1].StartsWith("--", StringComparison.Ordinal)) + i++; + continue; + } + stripped.Add(args[i]); + } + return [.. stripped]; + } + + private static bool IsFindCountRequest(string[] args) + { + for (var i = 0; i < args.Length; i++) + { + if (string.Equals(args[i], "--", StringComparison.Ordinal)) + break; + if (string.Equals(args[i], "--query", StringComparison.Ordinal) + && i + 1 < args.Length) + { + i++; + continue; + } + if (string.Equals(args[i], "--count", StringComparison.Ordinal) + || string.Equals(args[i], "--format=count", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + if (string.Equals(args[i], "--format", StringComparison.Ordinal) + && i + 1 < args.Length + && string.Equals(args[i + 1], "count", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + internal static string FormatRegexMatchTimeout(TimeSpan timeout) => RegexTimeoutPolicy.FormatDuration(timeout); @@ -662,7 +758,7 @@ private static void AddFindTerminalScanFields( { var scanComplete = !scan.Truncated && !resultLimitReached; payload["terminal_record"] = true; - payload["done"] = !scan.Truncated; + payload["done"] = scanComplete; payload["returned_count"] = returnedCount; payload["partial_result"] = scan.Truncated; payload["scan_complete"] = scanComplete; @@ -702,7 +798,9 @@ private static void AddFindTerminalScanFields( }; private static string? FindScanRecoveryGuidance(FindScanSummary scan, bool resultLimitReached) - => scan.NextPath is not null && scan.NextLine.HasValue + => scan.ResultLimitReached && scan.NextPath is not null && scan.NextLine.HasValue + ? "Pass next_cursor back with --cursor to continue at the next match without duplicates or gaps; --limit may be changed for the next page." + : scan.NextPath is not null && scan.NextLine.HasValue ? "Pass next_cursor back with --cursor to resume after the scan cap without rescanning completed lines; --line-scan-limit may be increased for the next page." : scan.TruncationReason switch { @@ -750,9 +848,10 @@ private static (string? Cursor, string? ResultStableAt) BuildFindResumeCursor( DbReader reader, FindScanSummary scan) { - if (!scan.Truncated - || scan.NextPath is null - || !scan.NextLine.HasValue) + if (scan.NextPath is null + || !scan.NextLine.HasValue + || !scan.NextFileOrdinal.HasValue + || !scan.NextByteOffset.HasValue) { return (null, reader.GetPaginationGeneration().StableAt); } @@ -761,7 +860,10 @@ private static (string? Cursor, string? ResultStableAt) BuildFindResumeCursor( commandArgs, reader, scan.NextPath, - scan.NextLine.Value); + scan.NextLine.Value, + scan.NextFileOrdinal.Value, + scan.NextMatchOrdinal, + scan.NextByteOffset.Value); return (cursor.Cursor, cursor.ResultStableAt); } } diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index f3f1899c9..6de722dc2 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -19,6 +19,11 @@ internal sealed record ResourceFileMetadata( string? Checksum, DateTime? Modified); +internal sealed class FindContinuationException(string reason, string message) : Exception(message) +{ + internal string Reason { get; } = reason; +} + public partial class DbReader { internal const int MaxBoundedFileReadUtf8Bytes = 4 * 1024 * 1024; @@ -118,10 +123,17 @@ internal static int? LegacyResourceReadSqliteVmStepLimitForTesting set => LegacyResourceReadSqliteVmStepLimitOverride.Value = value; } - public FindResults FindInFiles(string query, int limit, string? lang = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, int before = 0, int after = 0, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? focusLine = null, int? focusColumn = null, bool regex = false, int? maxCandidateFiles = null, int? maxLinesScanned = null, int offset = 0, bool useIndexedLiteralCandidates = false, string? resumePath = null, int? resumeLine = null) + public FindResults FindInFiles(string query, int limit, string? lang = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, int before = 0, int after = 0, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? focusLine = null, int? focusColumn = null, bool regex = false, int? maxCandidateFiles = null, int? maxLinesScanned = null, int offset = 0, bool useIndexedLiteralCandidates = false, string? resumePath = null, int? resumeLine = null, int? resumeFileOrdinal = null, int? resumeMatchOrdinal = null, int? resumeByteOffset = null, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(query) || limit <= 0) return new FindResults([], new FindScanSummary(0, 0, 0)); + ValidateFindContinuation( + resumePath, + resumeLine, + resumeFileOrdinal, + resumeMatchOrdinal, + resumeByteOffset); before = Math.Max(0, before); after = Math.Max(0, after); @@ -146,24 +158,43 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe string? truncationReason = null; string? nextPath = null; int? nextLine = null; + int? nextFileOrdinal = null; + int? nextMatchOrdinal = null; + int? nextByteOffset = null; + var resultLimitReached = false; var matchesSkipped = 0; offset = Math.Max(0, offset); var resumePending = resumePath is not null; + var resumeMatchPending = resumeMatchOrdinal.HasValue; + var candidateFileOrdinal = -1; var results = new List<FileFindResult>(); using var fileReader = fileCmd.ExecuteTrackedReader(); while (fileReader.TrackedRead()) { - if (results.Count >= limit) - break; - + cancellationToken.ThrowIfCancellationRequested(); + candidateFileOrdinal++; var fileId = fileReader.GetInt64(0); var path = fileReader.GetString(1); var fileLang = GetNullableString(fileReader, 2); var totalLines = fileReader.GetInt32(3); if (resumePending) { - if (!string.Equals(path, resumePath, StringComparison.Ordinal)) + if (resumeFileOrdinal.HasValue) + { + if (candidateFileOrdinal < resumeFileOrdinal.Value) + continue; + if (candidateFileOrdinal != resumeFileOrdinal.Value + || !string.Equals(path, resumePath, StringComparison.Ordinal)) + { + throw new FindContinuationException( + "cursor_malformed", + "find cursor file position does not match the current candidate order."); + } + } + else if (!string.Equals(path, resumePath, StringComparison.Ordinal)) + { continue; + } resumePending = false; } if (maxCandidateFiles.HasValue && filesScanned >= maxCandidateFiles.Value) @@ -172,6 +203,8 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe truncationReason ??= "candidate_file_limit"; nextPath = path; nextLine = 1; + nextFileOrdinal = candidateFileOrdinal; + nextByteOffset = 0; break; } @@ -192,6 +225,7 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe foreach (var indexedLine in EnumerateIndexedFileLines(fileId)) { + cancellationToken.ThrowIfCancellationRequested(); if (indexedLine.Number > totalLines) break; if (indexedLine.Number < firstContextLine) @@ -201,8 +235,13 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe { truncated = true; truncationReason ??= "line_scan_limit"; - nextPath = path; - nextLine = indexedLine.Number; + if (nextPath is null) + { + nextPath = path; + nextLine = indexedLine.Number; + nextFileOrdinal = candidateFileOrdinal; + nextByteOffset = 0; + } stopScanning = true; break; } @@ -211,10 +250,12 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe AddLineToFindWindow(indexedLine, snippetWindow, snippetLinesByNumber); - if ((matchesSkipped < offset || acceptedMatches < limit) + if (!resultLimitReached + && (matchesSkipped < offset || acceptedMatches <= limit) && eligibleForMatch && (!focusLine.HasValue || indexedLine.Number == focusLine.Value)) { + var matchOrdinal = 0; foreach (var lineMatch in EnumerateFindLineMatches( indexedLine.Text, fileLang, @@ -224,13 +265,43 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe exact && !regex, focusColumn)) { + var byteOffset = Encoding.UTF8.GetByteCount( + indexedLine.Text.AsSpan(0, lineMatch.Column)); + if (resumeMatchPending + && indexedLine.Number == firstEligibleLine) + { + if (matchOrdinal < resumeMatchOrdinal!.Value) + { + matchOrdinal++; + continue; + } + if (matchOrdinal != resumeMatchOrdinal.Value + || byteOffset != resumeByteOffset) + { + throw new FindContinuationException( + "cursor_malformed", + "find cursor match position is not a record boundary for the current query."); + } + resumeMatchPending = false; + } + if (matchesSkipped < offset) { matchesSkipped++; + matchOrdinal++; continue; } if (acceptedMatches >= limit) + { + nextPath = path; + nextLine = indexedLine.Number; + nextFileOrdinal = candidateFileOrdinal; + nextMatchOrdinal = matchOrdinal; + nextByteOffset = byteOffset; + resultLimitReached = true; + stopScanning = true; break; + } pendingMatches.Enqueue(new PendingFileFindMatch( indexedLine.Number, @@ -239,6 +310,7 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe Math.Max(1, indexedLine.Number - before), Math.Min(totalLines, indexedLine.Number + after))); acceptedMatches++; + matchOrdinal++; } } @@ -252,7 +324,7 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe indexedLine.Number); PruneFindWindow(indexedLine.Number, before, pendingMatches, snippetWindow, snippetLinesByNumber); - if (results.Count >= limit && pendingMatches.Count == 0) + if (stopScanning && pendingMatches.Count == 0) break; } @@ -267,6 +339,12 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe if (stopScanning) break; } + if (resumePending || resumeMatchPending) + { + throw new FindContinuationException( + "cursor_malformed", + "find cursor position does not exist in the current result sequence."); + } var capReached = truncated; return new FindResults( @@ -284,7 +362,45 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe searchPlan.Strategy, searchPlan.FallbackReason, nextPath, - nextLine)); + nextLine, + nextFileOrdinal, + nextMatchOrdinal, + nextByteOffset, + resultLimitReached)); + } + + private static void ValidateFindContinuation( + string? resumePath, + int? resumeLine, + int? resumeFileOrdinal, + int? resumeMatchOrdinal, + int? resumeByteOffset) + { + if (resumePath is null) + { + if (resumeLine.HasValue + || resumeFileOrdinal.HasValue + || resumeMatchOrdinal.HasValue + || resumeByteOffset.HasValue) + { + throw new FindContinuationException( + "cursor_malformed", + "find cursor continuation fields require a resume path."); + } + return; + } + + if (!resumeLine.HasValue + || resumeLine.Value <= 0 + || resumeFileOrdinal is < 0 + || resumeMatchOrdinal is < 0 + || resumeByteOffset is < 0 + || resumeMatchOrdinal.HasValue && !resumeByteOffset.HasValue) + { + throw new FindContinuationException( + "cursor_malformed", + "find cursor continuation position is invalid."); + } } public int CountFindCandidateFiles(string? lang = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false) @@ -302,10 +418,23 @@ public int CountFindCandidateFiles(string? lang = null, IReadOnlyList<string>? p return SqliteCommandPolicy.ReadInt32Scalar(fileCmd, "find candidate file count"); } - public FindCountResult CountFindInFiles(string query, string? lang = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int? focusLine = null, int? focusColumn = null, bool regex = false, int? maxCandidateFiles = null, int? maxLinesScanned = null, bool useIndexedLiteralCandidates = false) + public FindCountResult CountFindInFiles(string query, string? lang = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int? focusLine = null, int? focusColumn = null, bool regex = false, int? maxCandidateFiles = null, int? maxLinesScanned = null, bool useIndexedLiteralCandidates = false, string? resumePath = null, int? resumeLine = null, int? resumeFileOrdinal = null, int? resumeMatchOrdinal = null, int? resumeByteOffset = null, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(query)) return new FindCountResult(0, 0, new FindScanSummary(0, 0, 0)); + ValidateFindContinuation( + resumePath, + resumeLine, + resumeFileOrdinal, + resumeMatchOrdinal, + resumeByteOffset); + if (resumeMatchOrdinal.HasValue || resumeByteOffset is not (null or 0)) + { + throw new FindContinuationException( + "cursor_malformed", + "find count cursor must resume at a line boundary."); + } var comparison = exact ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; var regexMatcher = regex @@ -324,21 +453,55 @@ public FindCountResult CountFindInFiles(string query, string? lang = null, IRead var linesScanned = 0; var truncated = false; string? truncationReason = null; + string? nextPath = null; + int? nextLine = null; + int? nextFileOrdinal = null; var count = 0; var fileCount = 0; + var resumePending = resumePath is not null; + var candidateFileOrdinal = -1; using var fileReader = fileCmd.ExecuteTrackedReader(); while (fileReader.TrackedRead()) { + cancellationToken.ThrowIfCancellationRequested(); + candidateFileOrdinal++; + var path = fileReader.GetString(1); + if (resumePending) + { + if (resumeFileOrdinal.HasValue) + { + if (candidateFileOrdinal < resumeFileOrdinal.Value) + continue; + if (candidateFileOrdinal != resumeFileOrdinal.Value + || !string.Equals(path, resumePath, StringComparison.Ordinal)) + { + throw new FindContinuationException( + "cursor_malformed", + "find count cursor file position does not match the current candidate order."); + } + } + else if (!string.Equals(path, resumePath, StringComparison.Ordinal)) + { + continue; + } + resumePending = false; + } if (maxCandidateFiles.HasValue && filesScanned >= maxCandidateFiles.Value) { truncated = true; truncationReason ??= "candidate_file_limit"; + nextPath = path; + nextLine = 1; + nextFileOrdinal = candidateFileOrdinal; break; } var fileId = fileReader.GetInt64(0); var fileLang = GetNullableString(fileReader, 2); var totalLines = fileReader.GetInt32(3); + var firstEligibleLine = string.Equals(path, resumePath, StringComparison.Ordinal) + ? Math.Max(1, resumeLine ?? 1) + : 1; filesScanned++; if (totalLines <= 0) continue; @@ -348,12 +511,18 @@ public FindCountResult CountFindInFiles(string query, string? lang = null, IRead var stopScanning = false; foreach (var indexedLine in EnumerateIndexedFileLines(fileId)) { + cancellationToken.ThrowIfCancellationRequested(); if (indexedLine.Number > totalLines) break; + if (indexedLine.Number < firstEligibleLine) + continue; if (maxLinesScanned.HasValue && linesScanned >= maxLinesScanned.Value) { truncated = true; truncationReason ??= "line_scan_limit"; + nextPath = path; + nextLine = indexedLine.Number; + nextFileOrdinal = candidateFileOrdinal; stopScanning = true; break; } @@ -383,6 +552,12 @@ public FindCountResult CountFindInFiles(string query, string? lang = null, IRead if (stopScanning) break; } + if (resumePending) + { + throw new FindContinuationException( + "cursor_malformed", + "find count cursor position does not exist in the current candidate sequence."); + } var capReached = truncated; return new FindCountResult( @@ -399,7 +574,11 @@ public FindCountResult CountFindInFiles(string query, string? lang = null, IRead maxCandidateFiles, maxLinesScanned, searchPlan.Strategy, - searchPlan.FallbackReason)); + searchPlan.FallbackReason, + nextPath, + nextLine, + nextFileOrdinal, + NextByteOffset: nextPath is null ? null : 0)); } private readonly record struct FindSearchPlan( diff --git a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs index bb49c14a2..49acdfeab 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.Pagination.cs @@ -24,11 +24,17 @@ private static bool TryParseMcpQueryCursor(string cursor, out McpQueryCursor? pa out var queryFingerprint, out var generationFingerprint, out var resumePath, - out var resumeLine) + out var resumeLine, + out var resumeFileOrdinal, + out var resumeMatchOrdinal, + out var resumeByteOffset) || queryFingerprint is null || generationFingerprint is null || resumePath is not null - || resumeLine.HasValue) + || resumeLine.HasValue + || resumeFileOrdinal.HasValue + || resumeMatchOrdinal.HasValue + || resumeByteOffset.HasValue) { return false; } diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index f4d617f3c..98b3d1c47 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -91,7 +91,11 @@ public readonly record struct FindScanSummary( string SearchStrategy = "line_scan", string? SearchFallbackReason = null, string? NextPath = null, - int? NextLine = null); + int? NextLine = null, + int? NextFileOrdinal = null, + int? NextMatchOrdinal = null, + int? NextByteOffset = null, + bool ResultLimitReached = false); public readonly record struct FindCountResult(int Count, int FileCount, FindScanSummary Scan); diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs new file mode 100644 index 000000000..88cb74f75 --- /dev/null +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs @@ -0,0 +1,430 @@ +using System.Text; +using System.Text.Json; +using CodeIndex.Cli; +using CodeIndex.Database; + +namespace CodeIndex.Tests; + +[Collection("Console sensitive")] +public sealed class JsonEnvelopeWrapperIssue4863Tests +{ + private readonly JsonSerializerOptions _jsonOptions = ProgramRunner.CreateDefaultJsonOptions(); + +#if NET8_0 + [Fact] +#else + [Fact(Skip = PracticalBudgetTestTarget.SecondaryTargetSkipReason)] +#endif + public void FindAll_RegexPageWalkEnumeratesCorpusBeyondDefaultLineCap_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_regex_page_walk_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var expectedLines = new[] { 1, 50_001, 100_001, 150_001, 200_001, 250_001 }; + var matchLines = expectedLines.ToHashSet(); + var content = new StringBuilder(capacity: 600_000); + for (var line = 1; line <= 250_005; line++) + { + content.Append(matchLines.Contains(line) ? "Needle" : "x"); + if (line < 250_005) + content.Append('\n'); + } + TestProjectHelper.InsertIndexedFile(dbPath, "src/Large.txt", "text", content.ToString()); + + var args = new[] + { + "find", "Needle", "--db", dbPath, "--all", "--regex", + "--json=ndjson", "--limit", "2", + }; + + var actualLines = new List<int>(); + string? cursor = null; + var pageCount = 0; + do + { + var page = RunFindPage(args, cursor); + pageCount++; + Assert.True( + pageCount <= 3, + $"find cursor page walk did not make forward progress; cursor={cursor ?? "<null>"}."); + actualLines.AddRange(page.Rows.Select(row => row.GetProperty("line").GetInt32())); + cursor = page.NextCursor; + Assert.Equal(page.HasMore, cursor is not null); + } + while (cursor is not null); + + Assert.Equal(3, pageCount); + Assert.Equal(expectedLines, actualLines); + Assert.Equal(actualLines.Count, actualLines.Distinct().Count()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void FindAll_CursorResumesSameLineUnicodeAndLargeLineAtRecordBoundary_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_unicode_record_cursor_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var longPrefix = new string('x', 20_000); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Unicode.txt", + "text", + $"{longPrefix}猫 猫 猫\n猫\n"); + var args = new[] + { + "find", "猫", "--db", dbPath, "--all", "--regex", + "--json=ndjson", "--limit", "1", "--max-line-width", "80", + }; + + var locations = new List<(int Line, int Column)>(); + string? cursor = null; + var pageCount = 0; + do + { + var page = RunFindPage(args, cursor); + pageCount++; + Assert.True( + pageCount <= 4, + $"find cursor page walk did not make forward progress; locations={string.Join(",", locations)}; cursor={cursor ?? "<null>"}."); + var row = Assert.Single(page.Rows); + locations.Add(( + row.GetProperty("line").GetInt32(), + row.GetProperty("column").GetInt32())); + cursor = page.NextCursor; + } + while (cursor is not null); + + Assert.Equal( + new[] + { + (Line: 1, Column: 20_001), + (Line: 1, Column: 20_003), + (Line: 1, Column: 20_005), + (Line: 2, Column: 1), + }, + locations); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void FindAll_CursorFailuresAreTypedForMalformedMismatchAndStaleState_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_cursor_errors_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Rows.txt", + "text", + "Needle one\nNeedle two\nNeedle three\n"); + var args = new[] + { + "find", "Needle", "--db", dbPath, "--all", "--exact", + "--json=ndjson", "--limit", "1", + }; + var cursor = Assert.IsType<string>(RunFindPage(args, cursor: null).NextCursor); + + AssertCursorFailure( + args.Concat(["--cursor", "response:v2:not-base64"]).ToArray(), + "cursor_malformed"); + AssertCursorFailure( + args.Select(value => value == "Needle" ? "Different" : value) + .Concat(["--cursor", cursor]) + .ToArray(), + "cursor_mismatch"); + + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Changed.txt", + "text", + "Needle after cursor creation\n"); + AssertCursorFailure( + args.Concat(["--cursor", cursor]).ToArray(), + "cursor_stale"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void FindContinuationIsExposedInHelpAndFlagSchema_Issue4863() + { + var cursorFlag = Assert.Single( + CliFlagSchema.GetCompletionFlagsForCommand("find"), + flag => flag.Name == "--cursor"); + + Assert.Contains("match boundaries", cursorFlag.Description, StringComparison.Ordinal); + var (printed, stdout, stderr) = ConsoleCapture.Capture(() => + ConsoleUi.PrintCommandUsage("find") ? 1 : 0); + Assert.Equal(1, printed); + Assert.Equal(string.Empty, stderr); + Assert.Contains("--cursor <next_cursor>", stdout, StringComparison.Ordinal); + } + + [Fact] + public void FindAll_ByteBudgetCursorWalkHasNoDuplicatesOrGaps_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_byte_budget_cursor_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var suffix = new string('x', 2_000); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Budget.txt", + "text", + string.Join('\n', Enumerable.Range(1, 6).Select(line => $"Needle {line} {suffix}"))); + var args = new[] + { + "find", "Needle", "--db", dbPath, "--all", "--exact", + "--json=ndjson", "--limit", "4", "--max-json-bytes", "4096", + }; + + var lines = new List<int>(); + string? cursor = null; + var pageCount = 0; + do + { + var page = RunFindPage(args, cursor); + pageCount++; + Assert.True(pageCount <= 6, "byte-budget cursor page walk did not make forward progress."); + lines.AddRange(page.Rows.Select(row => row.GetProperty("line").GetInt32())); + cursor = page.NextCursor; + } + while (cursor is not null); + + Assert.True(pageCount > 1); + Assert.Equal(Enumerable.Range(1, 6), lines); + Assert.Equal(lines.Count, lines.Distinct().Count()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void FindAll_CancelledResumeCanRetryTheSameCursor_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_cancelled_resume_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Cancelled.txt", + "text", + "Needle one\nNeedle two\nNeedle three\n"); + var args = new[] + { + "find", "Needle", "--db", dbPath, "--all", "--exact", + "--json=ndjson", "--limit", "1", + }; + var cursor = Assert.IsType<string>(RunFindPage(args, cursor: null).NextCursor); + + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var cancelledArgs = args.Concat(["--cursor", cursor]).ToArray(); + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + cancelledArgs, + _jsonOptions, + "1.0.0-test", + cancellationToken: cancellation.Token)); + + Assert.Equal(CommandExitCodes.CancelledBySignal, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("cancelled", stderr, StringComparison.OrdinalIgnoreCase); + + var retried = RunFindPage(args, cursor); + var row = Assert.Single(retried.Rows); + Assert.Equal(2, row.GetProperty("line").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void FindAll_RegexTimeoutDoesNotIssueContinuationCursor_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_timeout_cursor_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Timeout.txt", + "text", + new string('a', 4_096) + "!\n"); + + try + { + DbReader.FindRegexMatchTimeoutForTesting = TimeSpan.FromMilliseconds(1); + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + [ + "find", "^(a+)+$", "--db", dbPath, "--all", "--regex", + "--json=ndjson", "--limit", "1", + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.RuntimeError, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.DoesNotContain("next_cursor", stdout, StringComparison.Ordinal); + using var document = JsonDocument.Parse(stdout); + Assert.Equal("regex_timeout", document.RootElement.GetProperty("category").GetString()); + } + finally + { + DbReader.FindRegexMatchTimeoutForTesting = null; + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void FindAll_CountPagesResumeAfterLineScanCaps_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_count_cursor_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Count.txt", + "text", + string.Join('\n', Enumerable.Range(1, 12).Select(line => line is 1 or 4 or 7 or 10 ? "Needle" : "x"))); + var args = new[] + { + "find", "Needle", "--db", dbPath, "--all", "--exact", "--count", + "--json", "--line-scan-limit", "4", "--allow-partial", + }; + + var counts = new List<int>(); + string? cursor = null; + do + { + var pageArgs = cursor is null ? args : args.Concat(["--cursor", cursor]).ToArray(); + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run(pageArgs, _jsonOptions, "1.0.0-test")); + Assert.True( + exitCode == CommandExitCodes.Success, + $"Expected count page success but received {exitCode}; cursor={cursor ?? "<null>"}; stdout={stdout}; stderr={stderr}"); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + var root = document.RootElement; + counts.Add(root.GetProperty("count").GetInt32()); + cursor = root.GetProperty("next_cursor").GetString(); + Assert.Equal(root.GetProperty("has_more").GetBoolean(), cursor is not null); + Assert.True(counts.Count <= 3, "count cursor page walk did not make forward progress."); + } + while (cursor is not null); + + Assert.Equal(new[] { 2, 1, 1 }, counts); + Assert.Equal(4, counts.Sum()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void FindAll_CountMissingCursorValuePreservesCountModeAndReturnsTypedError_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_count_missing_cursor_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Count.txt", "text", "Needle\n"); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + [ + "find", "Needle", "--db", dbPath, "--all", "--json", + "--cursor", "--count", + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + Assert.Contains("find count", document.RootElement.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.Equal("cursor_malformed", document.RootElement.GetProperty("category").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + private FindPage RunFindPage(string[] args, string? cursor) + { + var pageArgs = cursor is null + ? args + : args.Concat(["--cursor", cursor]).ToArray(); + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run(pageArgs, _jsonOptions, "1.0.0-test")); + + Assert.True( + exitCode == CommandExitCodes.Success, + $"Expected success but received exit code {exitCode}; cursor={cursor ?? "<null>"}; stdout={stdout}; stderr={stderr}"); + Assert.Equal(string.Empty, stderr); + var records = stdout.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonDocument.Parse(line).RootElement.Clone()) + .ToArray(); + if (records.Length > 1) + { + var terminal = records[^1]; + Assert.True(terminal.GetProperty("terminal_record").GetBoolean()); + var nextCursor = terminal.GetProperty("next_cursor").GetString(); + return new FindPage( + records[..^1], + terminal.GetProperty("has_more").GetBoolean(), + nextCursor); + } + + var root = Assert.Single(records); + var metadata = root.GetProperty("metadata"); + return new FindPage( + root.GetProperty("results").EnumerateArray().Select(row => row.Clone()).ToArray(), + metadata.GetProperty("has_more").GetBoolean(), + metadata.GetProperty("next_cursor").GetString()); + } + + private void AssertCursorFailure(string[] args, string expectedCategory) + { + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains(expectedCategory, stderr, StringComparison.Ordinal); + } + + private sealed record FindPage( + IReadOnlyList<JsonElement> Rows, + bool HasMore, + string? NextCursor); +} diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerFindIssue4350Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerFindIssue4350Tests.cs index 7b39adbcb..c42ee45c0 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerFindIssue4350Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerFindIssue4350Tests.cs @@ -31,7 +31,8 @@ public void RunFind_AllScopeLineScanLimitControlsCountJson_Issue4350() Assert.False(json.GetProperty("authoritative_count").GetBoolean()); Assert.Equal(1, json.GetProperty("line_scan_limit").GetInt32()); Assert.True(json.GetProperty("terminal_record").GetBoolean()); - Assert.Equal("increase_line_scan_limit_or_narrow_scope", json.GetProperty("continuation_action").GetString()); + Assert.Equal("resume_with_next_cursor", json.GetProperty("continuation_action").GetString()); + Assert.StartsWith("response:v2:", json.GetProperty("next_cursor").GetString(), StringComparison.Ordinal); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerFindIssue4578Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerFindIssue4578Tests.cs index ac5f9181e..017e75886 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerFindIssue4578Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerFindIssue4578Tests.cs @@ -78,7 +78,7 @@ public void RunFind_AllScopeRowsAndCountsExposeTerminalScanStateAndPartialOptIn_ Assert.Equal(string.Empty, limitedStderr); using var limitedTerminal = ParseLastNdjsonRecord(limitedStdout); var limited = limitedTerminal.RootElement; - Assert.True(limited.GetProperty("done").GetBoolean()); + Assert.False(limited.GetProperty("done").GetBoolean()); Assert.False(limited.GetProperty("scan_complete").GetBoolean()); Assert.False(limited.GetProperty("authoritative_rows").GetBoolean()); Assert.False(limited.GetProperty("partial_result").GetBoolean()); @@ -217,7 +217,8 @@ public void RunFind_AllScopeTextSummaryCarriesAuthorityCapsAndRecovery_Issue4578 Assert.Equal(CommandExitCodes.PartialResult, countExitCode); Assert.Contains("authoritative_count=false", countStderr, StringComparison.Ordinal); - Assert.Contains("--allow-partial", countStderr, StringComparison.Ordinal); + Assert.Contains("continuation_action=resume_with_next_cursor", countStderr, StringComparison.Ordinal); + Assert.Contains("next_cursor=response:v2:", countStderr, StringComparison.Ordinal); var (completeExitCode, _, completeStderr) = CaptureConsole(() => QueryCommandRunner.RunFind( ["alpha", "--db", dbPath, "--all", "--limit", "10"], @@ -253,8 +254,10 @@ private static void AssertPartialFindTerminal(JsonElement json, bool countMode) Assert.Equal(1, json.GetProperty("line_scan_limit").GetInt32()); if (countMode) { - Assert.Equal("increase_line_scan_limit_or_narrow_scope", json.GetProperty("continuation_action").GetString()); - Assert.Contains("--allow-partial", json.GetProperty("recovery_guidance").GetString(), StringComparison.Ordinal); + Assert.Equal("resume_with_next_cursor", json.GetProperty("continuation_action").GetString()); + Assert.Contains("--cursor", json.GetProperty("recovery_guidance").GetString(), StringComparison.Ordinal); + Assert.StartsWith("response:v2:", json.GetProperty("next_cursor").GetString(), StringComparison.Ordinal); + Assert.False(string.IsNullOrWhiteSpace(json.GetProperty("result_stable_at").GetString())); Assert.False(json.GetProperty("authoritative_count").GetBoolean()); } else From 4cf78fa067a2e8755aa6a535208ce04b50370097 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Tue, 28 Jul 2026 22:49:35 +0900 Subject: [PATCH 2/3] Harden find continuation handling (#4863) --- src/CodeIndex/Cli/QueryCommandRunner.Find.cs | 3 +- .../Database/DbReader.FilesStatus.cs | 47 ++++- .../JsonEnvelopeWrapperIssue4863Tests.cs | 190 +++++++++++++++++- 3 files changed, 229 insertions(+), 11 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs index e289a8ada..ab64d7a6b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs @@ -231,6 +231,7 @@ public static int RunFind( resumeFileOrdinal: resumeFileOrdinal, resumeMatchOrdinal: resumeMatchOrdinal, resumeByteOffset: resumeByteOffset, + captureContinuation: true, cancellationToken: cancellationToken); } catch (FindContinuationException ex) @@ -374,7 +375,7 @@ public static int RunFind( { if (findTerminalLine != null) Console.WriteLine(findTerminalLine); - }); + }, cancellationToken: cancellationToken); } private static int WriteFindInvalidRegexError(Exception ex, JsonSerializerOptions jsonOptions, bool json) diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index 6de722dc2..ada1fecd2 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -40,6 +40,7 @@ public partial class DbReader "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; private static readonly AsyncLocal<TimeSpan?> FindRegexMatchTimeoutOverride = new(); + private static readonly AsyncLocal<Action?> FindLineScannedOverride = new(); private static readonly AsyncLocal<int?> BoundedFileReadScanByteLimitOverride = new(); private static readonly AsyncLocal<int?> LegacyResourceReadSqliteVmStepLimitOverride = new(); @@ -111,6 +112,12 @@ internal static TimeSpan? FindRegexMatchTimeoutForTesting set => FindRegexMatchTimeoutOverride.Value = value; } + internal static Action? FindLineScannedForTesting + { + get => FindLineScannedOverride.Value; + set => FindLineScannedOverride.Value = value; + } + internal static int? BoundedFileReadScanByteLimitForTesting { get => BoundedFileReadScanByteLimitOverride.Value; @@ -123,7 +130,7 @@ internal static int? LegacyResourceReadSqliteVmStepLimitForTesting set => LegacyResourceReadSqliteVmStepLimitOverride.Value = value; } - public FindResults FindInFiles(string query, int limit, string? lang = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, int before = 0, int after = 0, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? focusLine = null, int? focusColumn = null, bool regex = false, int? maxCandidateFiles = null, int? maxLinesScanned = null, int offset = 0, bool useIndexedLiteralCandidates = false, string? resumePath = null, int? resumeLine = null, int? resumeFileOrdinal = null, int? resumeMatchOrdinal = null, int? resumeByteOffset = null, CancellationToken cancellationToken = default) + public FindResults FindInFiles(string query, int limit, string? lang = null, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, int before = 0, int after = 0, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? focusLine = null, int? focusColumn = null, bool regex = false, int? maxCandidateFiles = null, int? maxLinesScanned = null, int offset = 0, bool useIndexedLiteralCandidates = false, string? resumePath = null, int? resumeLine = null, int? resumeFileOrdinal = null, int? resumeMatchOrdinal = null, int? resumeByteOffset = null, bool captureContinuation = false, CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (string.IsNullOrWhiteSpace(query) || limit <= 0) @@ -172,6 +179,8 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe while (fileReader.TrackedRead()) { cancellationToken.ThrowIfCancellationRequested(); + if (!captureContinuation && results.Count >= limit) + break; candidateFileOrdinal++; var fileId = fileReader.GetInt64(0); var path = fileReader.GetString(1); @@ -195,6 +204,12 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe { continue; } + if (resumeLine > Math.Max(1, totalLines)) + { + throw new FindContinuationException( + "cursor_malformed", + "find cursor line position exceeds the selected file."); + } resumePending = false; } if (maxCandidateFiles.HasValue && filesScanned >= maxCandidateFiles.Value) @@ -246,12 +261,18 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe break; } if (eligibleForMatch) + { linesScanned++; + FindLineScannedForTesting?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); + } AddLineToFindWindow(indexedLine, snippetWindow, snippetLinesByNumber); if (!resultLimitReached - && (matchesSkipped < offset || acceptedMatches <= limit) + && (matchesSkipped < offset + || captureContinuation && acceptedMatches <= limit + || !captureContinuation && acceptedMatches < limit) && eligibleForMatch && (!focusLine.HasValue || indexedLine.Number == focusLine.Value)) { @@ -265,8 +286,6 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe exact && !regex, focusColumn)) { - var byteOffset = Encoding.UTF8.GetByteCount( - indexedLine.Text.AsSpan(0, lineMatch.Column)); if (resumeMatchPending && indexedLine.Number == firstEligibleLine) { @@ -275,8 +294,10 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe matchOrdinal++; continue; } + var resumeBoundaryByteOffset = Encoding.UTF8.GetByteCount( + indexedLine.Text.AsSpan(0, lineMatch.Column)); if (matchOrdinal != resumeMatchOrdinal.Value - || byteOffset != resumeByteOffset) + || resumeBoundaryByteOffset != resumeByteOffset) { throw new FindContinuationException( "cursor_malformed", @@ -293,6 +314,8 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe } if (acceptedMatches >= limit) { + var byteOffset = Encoding.UTF8.GetByteCount( + indexedLine.Text.AsSpan(0, lineMatch.Column)); nextPath = path; nextLine = indexedLine.Number; nextFileOrdinal = candidateFileOrdinal; @@ -324,7 +347,8 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe indexedLine.Number); PruneFindWindow(indexedLine.Number, before, pendingMatches, snippetWindow, snippetLinesByNumber); - if (stopScanning && pendingMatches.Count == 0) + if ((stopScanning || !captureContinuation && results.Count >= limit) + && pendingMatches.Count == 0) break; } @@ -336,7 +360,7 @@ public FindResults FindInFiles(string query, int limit, string? lang = null, IRe results, maxLineWidth, int.MaxValue); - if (stopScanning) + if (stopScanning || !captureContinuation && results.Count >= limit) break; } if (resumePending || resumeMatchPending) @@ -395,7 +419,8 @@ private static void ValidateFindContinuation( || resumeFileOrdinal is < 0 || resumeMatchOrdinal is < 0 || resumeByteOffset is < 0 - || resumeMatchOrdinal.HasValue && !resumeByteOffset.HasValue) + || resumeMatchOrdinal.HasValue && !resumeByteOffset.HasValue + || !resumeMatchOrdinal.HasValue && resumeByteOffset is not (null or 0)) { throw new FindContinuationException( "cursor_malformed", @@ -484,6 +509,12 @@ public FindCountResult CountFindInFiles(string query, string? lang = null, IRead { continue; } + if (resumeLine > Math.Max(1, fileReader.GetInt32(3))) + { + throw new FindContinuationException( + "cursor_malformed", + "find count cursor line position exceeds the selected file."); + } resumePending = false; } if (maxCandidateFiles.HasValue && filesScanned >= maxCandidateFiles.Value) diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs index 88cb74f75..82e6ee12c 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using CodeIndex.Cli; using CodeIndex.Database; @@ -117,6 +118,45 @@ public void FindAll_CursorResumesSameLineUnicodeAndLargeLineAtRecordBoundary_Iss } } + [Fact] + public void FindAll_HighOrdinalUnicodeCursorResumesWithoutWalkingUtf8Prefixes_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_high_ordinal_cursor_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + const int resumeMatchOrdinal = 10_000; + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/HighOrdinal.txt", + "text", + string.Join(' ', Enumerable.Repeat("猫", resumeMatchOrdinal + 2))); + + using var db = new DbContext(DbOpenIntent.QueryOnly, dbPath); + using var reader = new DbReader(db); + var page = reader.FindInFiles( + "猫", + limit: 1, + exact: true, + resumePath: "src/HighOrdinal.txt", + resumeLine: 1, + resumeFileOrdinal: 0, + resumeMatchOrdinal: resumeMatchOrdinal, + resumeByteOffset: resumeMatchOrdinal * 4, + captureContinuation: true); + + var row = Assert.Single(page.Results); + Assert.Equal(1, row.Line); + Assert.Equal((resumeMatchOrdinal * 2) + 1, row.Column); + Assert.Equal(resumeMatchOrdinal + 1, page.Scan.NextMatchOrdinal); + Assert.Equal((resumeMatchOrdinal + 1) * 4, page.Scan.NextByteOffset); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void FindAll_CursorFailuresAreTypedForMalformedMismatchAndStaleState_Issue4863() { @@ -139,6 +179,22 @@ public void FindAll_CursorFailuresAreTypedForMalformedMismatchAndStaleState_Issu AssertCursorFailure( args.Concat(["--cursor", "response:v2:not-base64"]).ToArray(), "cursor_malformed"); + AssertCursorFailure( + args.Concat([ + "--cursor", + MutateCursor(cursor, payload => payload["resume_line"] = 999_999), + ]).ToArray(), + "cursor_malformed"); + AssertCursorFailure( + args.Concat([ + "--cursor", + MutateCursor(cursor, payload => + { + payload.Remove("resume_match_ordinal"); + payload["resume_byte_offset"] = 123; + }), + ]).ToArray(), + "cursor_malformed"); AssertCursorFailure( args.Select(value => value == "Needle" ? "Different" : value) .Concat(["--cursor", cursor]) @@ -260,6 +316,50 @@ public void FindAll_CancelledResumeCanRetryTheSameCursor_Issue4863() } } + [Fact] + public void FindAll_MidScanCancellationReturnsSignalExitWithoutCursor_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_mid_scan_cancel_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Cancelled.txt", + "text", + "Needle one\nNeedle two\n"); + using var cancellation = new CancellationTokenSource(); + + (int ExitCode, string StdOut, string StdErr) result; + try + { + DbReader.FindLineScannedForTesting = cancellation.Cancel; + result = ConsoleCapture.Capture(() => + ProgramRunner.Run( + [ + "find", "Needle", "--db", dbPath, "--all", "--exact", + "--json=ndjson", "--limit", "1", + ], + _jsonOptions, + "1.0.0-test", + cancellationToken: cancellation.Token)); + } + finally + { + DbReader.FindLineScannedForTesting = null; + } + + Assert.Equal(CommandExitCodes.CancelledBySignal, result.ExitCode); + Assert.Equal(string.Empty, result.StdOut); + Assert.Contains("cancelled", result.StdErr, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("next_cursor", result.StdErr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void FindAll_RegexTimeoutDoesNotIssueContinuationCursor_Issue4863() { @@ -349,6 +449,53 @@ public void FindAll_CountPagesResumeAfterLineScanCaps_Issue4863() } } + [Fact] + public void FindAll_CountCursorRejectsLineBeyondSelectedFile_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_count_line_boundary_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Count.txt", + "text", + "Needle\nNeedle\nNeedle\n"); + var args = new[] + { + "find", "Needle", "--db", dbPath, "--all", "--exact", "--count", + "--json", "--line-scan-limit", "1", "--allow-partial", + }; + var (firstExitCode, firstStdout, firstStderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); + Assert.Equal(CommandExitCodes.Success, firstExitCode); + Assert.Equal(string.Empty, firstStderr); + using var firstDocument = JsonDocument.Parse(firstStdout); + var cursor = Assert.IsType<string>( + firstDocument.RootElement.GetProperty("next_cursor").GetString()); + var malformedCursor = MutateCursor( + cursor, + payload => payload["resume_line"] = 999_999); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + args.Concat(["--cursor", malformedCursor]).ToArray(), + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + Assert.Equal( + "cursor_malformed", + document.RootElement.GetProperty("category").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void FindAll_CountMissingCursorValuePreservesCountModeAndReturnsTypedError_Issue4863() { @@ -419,8 +566,47 @@ private void AssertCursorFailure(string[] args, string expectedCategory) ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, exitCode); - Assert.Equal(string.Empty, stdout); - Assert.Contains(expectedCategory, stderr, StringComparison.Ordinal); + if (string.IsNullOrEmpty(stdout)) + { + Assert.Contains(expectedCategory, stderr, StringComparison.Ordinal); + return; + } + + Assert.Equal(string.Empty, stderr); + using var document = JsonDocument.Parse(stdout); + Assert.Equal( + expectedCategory, + document.RootElement + .GetProperty("metadata") + .GetProperty("error") + .GetProperty("category") + .GetString()); + } + + private static string MutateCursor( + string cursor, + Action<JsonObject> mutate) + { + const string prefix = "response:v2:"; + Assert.StartsWith(prefix, cursor, StringComparison.Ordinal); + var encoded = cursor[prefix.Length..] + .Replace('-', '+') + .Replace('_', '/'); + encoded = (encoded.Length % 4) switch + { + 2 => encoded + "==", + 3 => encoded + "=", + _ => encoded, + }; + var payload = Assert.IsType<JsonObject>( + JsonNode.Parse(Encoding.UTF8.GetString(Convert.FromBase64String(encoded)))); + mutate(payload); + var mutated = Convert.ToBase64String( + Encoding.UTF8.GetBytes(payload.ToJsonString())) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); + return prefix + mutated; } private sealed record FindPage( From 5486d2a47586aeae2de6b6a0cc9ed68827e13ad4 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Tue, 28 Jul 2026 23:12:06 +0900 Subject: [PATCH 3/3] Bind find cursors to scan mode (#4863) --- docs/find-scan-controls.md | 6 +- .../Cli/JsonEnvelopeWrapper.Bounded.cs | 20 ++++- src/CodeIndex/Cli/QueryCommandRunner.Find.cs | 16 +++- .../JsonEnvelopeWrapperIssue4863Tests.cs | 88 +++++++++++++++++++ 4 files changed, 123 insertions(+), 7 deletions(-) diff --git a/docs/find-scan-controls.md b/docs/find-scan-controls.md index 8186af749..786adc636 100644 --- a/docs/find-scan-controls.md +++ b/docs/find-scan-controls.md @@ -40,7 +40,9 @@ completed page. When count mode reaches a scan cap, its `count` is the count for that partial scan page and `next_cursor` resumes at the next line boundary. Continue until `has_more=false`; summing the page counts yields the complete count for an -unchanged indexed source. +unchanged indexed source. Because a resumed page contains only its segment of +the total, every resumed count page keeps `authoritative_count=false`, including +the final page. For scoped `--path` searches, `--format compact` returns locations only. Use text or JSON output when context from `--before`, `--after`, or @@ -83,6 +85,8 @@ timeout になった request は continuation cursor を進めたり新しく発 count mode が scan cap に達した場合、`count` はその部分 scan page の件数となり、 `next_cursor` は次の line boundary から再開します。`has_more=false` になるまで続けると、 変更されていない indexed source では各 page の count の合計が完全な件数になります。 +再開後の page は全体の一部分だけを含むため、最終 page を含むすべての再開 count page で +`authoritative_count=false` のままになります。 `--path` で scope を限定した検索では、`--format compact` は location のみを返します。 `--before`、`--after`、`--snippet-lines` の context が必要な場合は text または diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs index a2bd75a1c..1f5d45d7f 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs @@ -61,7 +61,7 @@ internal static bool ShouldAutoWrapBoundedResponse(string command, string[] args return false; if (command == "search" && IsSearchAggregateResponseRequest(args)) return false; - if (command == "find" && IsFindCountResponseRequest(args)) + if (command == "find" && IsStandaloneFindCountContinuationRequest(args)) return false; if (HasArgument(args, "--fields") || HasArgument(args, "--cursor")) return true; @@ -82,8 +82,6 @@ private static bool IsBoundedResponseRequest(string command, string[] args) return false; if (command == "search" && IsSearchAggregateResponseRequest(args)) return false; - if (command == "find" && IsFindCountResponseRequest(args)) - return false; return HasArgument(args, "--fields") || HasArgument(args, "--cursor") @@ -92,6 +90,14 @@ private static bool IsBoundedResponseRequest(string command, string[] args) || ShouldAutoWrapBoundedResponse(command, args); } + private static bool IsStandaloneFindCountContinuationRequest(string[] args) + => IsFindCountResponseRequest(args) + && HasArgument(args, "--cursor") + && !HasArgument(args, "--fields") + && !HasArgument(args, "--max-json-bytes") + && !HasCompactOutputSelection(args) + && !HasEnvelopeFlag(args); + private static bool IsFindCountResponseRequest(string[] args) { for (var i = 0; i < args.Length; i++) @@ -184,7 +190,8 @@ private static int RunBoundedResponse( CommandExitCodes.UsageError, controls.MaxJsonBytes); } - if (HasArgument(args, "--count")) + if (HasArgument(args, "--count") + || command == "find" && IsFindCountResponseRequest(args)) return WriteBoundedResponseUsageError("Bounded response controls cannot be combined with --count.", "Run --count --json separately for a count-only response, or remove --count to page projected rows."); if (command == "map" && ValidateMapProjectionControls(args, controls.Fields) is { } mapProjectionError) return WriteBoundedResponseUsageError(mapProjectionError, "Remove the conflicting map filter, or select a collection enabled by --sections."); @@ -1285,11 +1292,16 @@ private static bool TryReadPositiveIntControl( private static string BuildResponseFingerprint(string command, string[] args) { + var scanMode = command == "find" + ? IsFindCountResponseRequest(args) ? "count" : "rows" + : null; var normalized = StripResponseOptions(args, stripLimit: true); normalized.RemoveAll(arg => string.Equals(arg, "--body", StringComparison.Ordinal)); normalized.RemoveAll(arg => arg is "--allow-partial" or "--results-only" or "--verbose" or "--profile"); RemoveOptionWithValue(normalized, "--line-scan-limit"); var input = command + "\0" + string.Join('\0', normalized); + if (scanMode is not null) + input += "\0scan-mode=" + scanMode; var hash = SHA256.HashData(Encoding.UTF8.GetBytes(input)); return Convert.ToHexString(hash.AsSpan(0, 8)).ToLowerInvariant(); } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs index ab64d7a6b..5218fc2f2 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Find.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Find.cs @@ -134,10 +134,12 @@ public static int RunFind( if (options.CountOnly) { FindCountResult counts; + var resumedCountPage = false; try { var (countResumePath, countResumeLine, countResumeFileOrdinal, countResumeMatchOrdinal, countResumeByteOffset) = JsonEnvelopeWrapper.GetStandaloneFindResume(cmdArgs, reader); + resumedCountPage = countResumePath is not null; counts = reader.CountFindInFiles( options.Query, options.Lang, @@ -192,12 +194,21 @@ public static int RunFind( resultStableAt: countFindResume.ResultStableAt); } }); + if (resumedCountPage) + { + payload["degraded"] = true; + payload["authoritative_count"] = false; + } Console.WriteLine(payload.ToJsonString(jsonOptions)); } else { Console.WriteLine($"{counts.Count}"); - WriteFindScanSummary(counts.Scan, countMode: true, nextCursor: countFindResume.Cursor); + WriteFindScanSummary( + counts.Scan, + countMode: true, + resumedCountPage: resumedCountPage, + nextCursor: countFindResume.Cursor); } return FindScanExitCode(options, counts.Scan); } @@ -816,6 +827,7 @@ private static void WriteFindScanSummary( FindScanSummary scan, bool resultLimitReached = false, bool countMode = false, + bool resumedCountPage = false, string? nextCursor = null) { var summary = $"scanned {scan.FilesScanned}/{scan.CandidateFiles} candidate files, {ConsoleUi.Counted(scan.LinesScanned, "line")}"; @@ -831,7 +843,7 @@ private static void WriteFindScanSummary( var scanComplete = !scan.Truncated && !resultLimitReached; summary += $"; scan_complete={scanComplete.ToString().ToLowerInvariant()}"; summary += countMode - ? $"; authoritative_count={(!scan.Truncated).ToString().ToLowerInvariant()}" + ? $"; authoritative_count={(!scan.Truncated && !resumedCountPage).ToString().ToLowerInvariant()}" : $"; authoritative_rows={scanComplete.ToString().ToLowerInvariant()}"; var continuationAction = FindScanContinuationAction(scan, resultLimitReached); if (continuationAction != null) diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs index 82e6ee12c..c2240df40 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs @@ -216,6 +216,59 @@ public void FindAll_CursorFailuresAreTypedForMalformedMismatchAndStaleState_Issu } } + [Fact] + public void FindAll_CursorIsBoundToRowOrCountScanMode_Issue4863() + { + var projectRoot = TestProjectHelper.CreateTempProject("find_cursor_scan_mode_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Rows.txt", + "text", + "Needle one\nNeedle two\nNeedle three\n"); + var rowArgs = new[] + { + "find", "Needle", "--db", dbPath, "--all", "--exact", + "--json=ndjson", "--limit", "1", + }; + var rowCursor = Assert.IsType<string>(RunFindPage(rowArgs, cursor: null).NextCursor); + var countArgs = new[] + { + "find", "Needle", "--db", dbPath, "--all", "--exact", "--count", + "--json", "--line-scan-limit", "1", "--allow-partial", + }; + var (countExitCode, countStdout, countStderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run(countArgs, _jsonOptions, "1.0.0-test")); + Assert.Equal(CommandExitCodes.Success, countExitCode); + Assert.Equal(string.Empty, countStderr); + using var countDocument = JsonDocument.Parse(countStdout); + var countCursor = Assert.IsType<string>( + countDocument.RootElement.GetProperty("next_cursor").GetString()); + + AssertCursorFailure( + rowArgs.Concat(["--cursor", countCursor]).ToArray(), + "cursor_mismatch"); + + var (resumeExitCode, resumeStdout, resumeStderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + countArgs.Concat(["--cursor", rowCursor]).ToArray(), + _jsonOptions, + "1.0.0-test")); + Assert.Equal(CommandExitCodes.UsageError, resumeExitCode); + Assert.Equal(string.Empty, resumeStderr); + using var resumeDocument = JsonDocument.Parse(resumeStdout); + Assert.Equal( + "cursor_mismatch", + resumeDocument.RootElement.GetProperty("category").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void FindContinuationIsExposedInHelpAndFlagSchema_Issue4863() { @@ -434,6 +487,7 @@ public void FindAll_CountPagesResumeAfterLineScanCaps_Issue4863() using var document = JsonDocument.Parse(stdout); var root = document.RootElement; counts.Add(root.GetProperty("count").GetInt32()); + Assert.False(root.GetProperty("authoritative_count").GetBoolean()); cursor = root.GetProperty("next_cursor").GetString(); Assert.Equal(root.GetProperty("has_more").GetBoolean(), cursor is not null); Assert.True(counts.Count <= 3, "count cursor page walk did not make forward progress."); @@ -449,6 +503,40 @@ public void FindAll_CountPagesResumeAfterLineScanCaps_Issue4863() } } + [Theory] + [InlineData("--fields", "path")] + [InlineData("--max-json-bytes", "4096")] + public void FindAll_CountRejectsBoundedRowControls_Issue4863( + string control, + string value) + { + var projectRoot = TestProjectHelper.CreateTempProject("find_count_bounded_control_4863"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Count.txt", "text", "Needle\n"); + + var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => + ProgramRunner.Run( + [ + "find", "Needle", "--db", dbPath, "--all", "--exact", + "--count", "--json", control, value, + ], + _jsonOptions, + "1.0.0-test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains( + "cannot be combined with --count", + stdout + stderr, + StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void FindAll_CountCursorRejectsLineBeyondSelectedFile_Issue4863() {