From 416f7b0378b4fec4b9ccac8618888cef446ff648 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 23 Sep 2026 04:11:04 +0900 Subject: [PATCH 1/2] Fix structured bounded cursor validation errors (#5412) --- TESTING_GUIDE.md | 13 ++ changelog.d/unreleased/5412.fixed.md | 17 ++ docs/find-scan-controls.md | 51 +++++ .../Cli/JsonEnvelopeWrapper.Bounded.cs | 82 ++++++-- .../JsonEnvelopeWrapperIssue4585Tests.cs | 12 +- .../JsonEnvelopeWrapperIssue4730Tests.cs | 17 +- .../JsonEnvelopeWrapperIssue4863Tests.cs | 15 +- .../JsonEnvelopeWrapperIssue5412Tests.cs | 179 ++++++++++++++++++ .../JsonEnvelopeWrapperTests.cs | 5 +- .../QueryCommandRunnerIssue5187Tests.cs | 6 +- ...PartialFamilyContinuationIssue5101Tests.cs | 5 +- .../QueryCommandRunnerUnusedIssue4905Tests.cs | 10 +- 12 files changed, 364 insertions(+), 48 deletions(-) create mode 100644 changelog.d/unreleased/5412.fixed.md create mode 100644 tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue5412Tests.cs diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 96b3b7d019..f2f7b3a44a 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -1,5 +1,12 @@ # Testing Guide +`JsonEnvelopeWrapperIssue5412Tests` shares an isolated indexed fixture across literal, +line-regex and multiline find cursor errors. Keep JSON/envelope/fields/compact +selectors, malformed/mismatched/stale cursors, human and count controls, and exact +UTF-8 byte limits (including pretty output and the final newline) on net8/net9. +Run it with the envelope, find, pagination and response-budget regression suites; +the #4863 cursor helper now requires structured output for every machine request. + `RunDeps_PythonContextCoordinatesPreserveBothDirections_Issue5401` shares indexed fixtures across ordinary dependencies and cycles for zero/space/tab indentation, short/long names, aliases, relative imports and repeated same-line member calls. @@ -1813,6 +1820,12 @@ Issue #5300 のテストは隣接・入れ子の C# callable、対象行の除 # テストガイド +`JsonEnvelopeWrapperIssue5412Tests` は分離した索引 fixture を共有し、リテラル・行単位正規表現・ +複数行 find のカーソルエラーを検証します。JSON・envelope・fields・compact の各指定、 +不正・不一致・世代変更済みカーソル、人向け出力と件数の対照、pretty 出力と末尾改行を含む +UTF-8 バイト境界を net8/net9 で維持してください。envelope・find・ページ分割・応答サイズの +回帰テストと実行し、#4863 のカーソル検証も全機械向け指定で構造化出力を必須にします。 + `FindMultilineTests` と `McpServerIssue5399Tests` は、索引の LF/CRLF 窓、補助平面文字の UTF-16 座標、重複チャンク、一致範囲の上限内外、アンカー、dot-all、ゼロ幅、非重複、件数と行の 再開、カーソル条件変更、読取上限、取消・タイムアウト、投影と応答サイズを検証します。 diff --git a/changelog.d/unreleased/5412.fixed.md b/changelog.d/unreleased/5412.fixed.md new file mode 100644 index 0000000000..7821c747cb --- /dev/null +++ b/changelog.d/unreleased/5412.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 5412 +affected: + - src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs + - tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue5412Tests.cs + - docs/find-scan-controls.md +--- + +## English + +- **Bounded find cursor errors honor machine output (#5412)** — JSON, envelope, field and compact requests now receive versioned structured validation errors on stdout with cursor categories and recovery hints. Errors that cannot fit the requested UTF-8 byte budget report a measured minimum and retain the original validation error. Human diagnostics and standalone count continuation keep their existing behavior. + +## 日本語 + +- **上限付き find のカーソルエラーが機械向け出力に対応 (#5412)** — JSON・envelope・フィールド投影・compact の指定時、バージョン付き構造化エラーを stdout に返し、カーソルの理由と復旧案内を保持します。指定された UTF-8 バイト上限に収まらない場合は必要な最小サイズと元の検証エラーを示します。人向け診断と独立した件数取得の継続処理は既存の動作を維持します。 diff --git a/docs/find-scan-controls.md b/docs/find-scan-controls.md index 9a9865d53f..682a7790b8 100644 --- a/docs/find-scan-controls.md +++ b/docs/find-scan-controls.md @@ -2,6 +2,32 @@ ## English +### CLI cursor validation errors (#5412) + +Bounded `find` validation honors machine output selected by `--json`, +`--json=ndjson`, `--format json`, `--json-envelope`, `--fields`, compact output, +or `--max-json-bytes`. Malformed, query-mismatched and stale cursors return exit +`1` with a single error object on stdout and no human diagnostic on stderr. +Before query execution, this is a top-level error object even for envelope or +field projection requests: `api_version:"1"`, `status:"error"`, `command:"find"`, +`exit_code:1`, `error_code:"E010_USAGE_ERROR"`, `message`, `hint`, and `category` +(`cursor_malformed`, `cursor_mismatch`, or `cursor_stale`). Errors are not projected. +Other shared bounded-control validation failures use `category:"usage"`. +The shared bounded-query wrapper uses the same error contract for other commands +when machine output is selected, with their own `command` identity. + +Use an opaque `next_cursor` from the same query and filters. After indexing, +restart without `--cursor`. This applies to literal, line-regex and multiline +find; ordinary human diagnostics and standalone count continuation retain their +existing formats. + +The byte cap includes the serialized error's final platform newline. If the +complete error cannot fit, stdout instead contains `E028_RESPONSE_BUDGET_TOO_SMALL` +with the measured `minimum_required_bytes`, retry budget and original +`validation_error` object. This budget diagnostic may exceed the requested cap, +as with other response-budget errors; it never reports an empty success or drops +the cursor reason. Retry at the recommended size, then follow the validation hint. + Regex is line-local by default. To match adjacent lines such as `A\nB`, use `--regex --multiline` (MCP `regex:true, multiline:true`); see [bounded multiline windows](find-multiline.md#english). Window mode rejects semantic @@ -196,6 +222,31 @@ text or JSON output when context from `--before`, `--after`, or ## 日本語 +### CLI カーソルの検証エラー (#5412) + +上限付き `find` の検証は、`--json`、`--json=ndjson`、`--format json`、 +`--json-envelope`、`--fields`、compact 出力、`--max-json-bytes` による機械向け出力の +指定を尊重します。不正・クエリ不一致・索引の世代変更済みカーソルでは終了コード `1` と +単一のエラーオブジェクトを stdout に返し、人向け診断を stderr に出しません。 +クエリ実行前は envelope・フィールド投影の指定時もトップレベルのエラーです。 +`api_version:"1"`、`status:"error"`、`command:"find"`、`exit_code:1`、 +`error_code:"E010_USAGE_ERROR"`、`message`、`hint`、`category` を含みます。 +`category` は `cursor_malformed`、`cursor_mismatch`、`cursor_stale` のいずれかで、 +その他の共有出力制御の検証失敗は `usage` です。エラーにはフィールド投影を適用しません。 +共有ラッパーを使う他のコマンドでも、機械向け出力の指定時は同じエラー形式を使い、 +`command` にそのコマンド名を保持します。 + +同じクエリ・フィルターから返された不透明な `next_cursor` を使ってください。 +索引更新後は `--cursor` を外して再開します。リテラル・行単位正規表現・複数行 find に +適用され、人向け診断と独立した件数取得の継続処理は既存の形式を維持します。 + +バイト上限にはエラー末尾のプラットフォーム固有の改行も含めます。完全なエラーが収まらない +場合は `E028_RESPONSE_BUDGET_TOO_SMALL` を stdout に返し、実測した +`minimum_required_bytes`、再試行用の上限値、元の `validation_error` を保持します。 +他の応答サイズエラーと同様、この診断自体は指定上限を超える場合があります。 +空の成功応答にしたり、カーソルの理由を省略したりはしません。推奨サイズで再試行してから、 +検証エラーの復旧案内に従ってください。 + 通常の正規表現は行単位です。`A\nB` のような隣接行には `--regex --multiline` (MCP は `regex:true, multiline:true`)を使います。[上限付きの複数行窓](find-multiline.md#日本語)を 参照してください。窓モードは意味フィルターを明示的に拒否し、origin をまたぐ証拠を黙って省略しません。 diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs index 6333b2b0dd..f1b74606e4 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs @@ -294,8 +294,8 @@ private static int RunBoundedResponse( JsonSerializerOptions jsonOptions, Func runInner) { - if (TryReadRequestedMaxJsonBytes(command, args, out var requestedBytes) - && requestedBytes <= 0) + var hasRequestedBytes = TryReadRequestedMaxJsonBytes(command, args, out var requestedBytes); + if (hasRequestedBytes && requestedBytes <= 0) { return CommandErrorWriter.WriteResponseBudgetError( json: true, @@ -311,9 +311,13 @@ private static int RunBoundedResponse( usage: GetBoundedResponseUsage(command)); } - if (!TryParseBoundedResponseControls(command, args, out var controls, out var controlError)) + int WriteUsageError(string message, string hint, string category = "usage") + => WriteBoundedResponseUsageError(command, args, jsonOptions, message, hint, category, + hasRequestedBytes ? (int)requestedBytes : null); + + if (!TryParseBoundedResponseControls(command, args, out var controls, out var controlError, out var controlCategory)) { - return WriteBoundedResponseUsageError(controlError!, "Use the command help to pass positive --limit/--max-json-bytes values and a next_cursor returned by the same query."); + return WriteUsageError(controlError!, "Use the command help to pass positive --limit/--max-json-bytes values and a next_cursor returned by the same query.", controlCategory); } if (HasUnsupportedStandaloneBoundedControl(command, args) || command == "unused" && HasArgument(command, args, "--summary-only")) @@ -453,9 +457,9 @@ private static int RunBoundedResponse( return CommandExitCodes.UsageError; if (HasArgument(command, 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."); + return WriteUsageError("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."); + return WriteUsageError(mapProjectionError, "Remove the conflicting map filter, or select a collection enabled by --sections."); var queryNormalized = ExtractQueryArg(command, args); var (resolvedDbPath, dbPathExplicit) = ResolveQueryDbPath(command, args); @@ -467,20 +471,22 @@ private static int RunBoundedResponse( if (controls.CursorQueryFingerprint is not null && !string.Equals(controls.CursorQueryFingerprint, queryFingerprint, StringComparison.Ordinal)) { - return WriteBoundedResponseUsageError( + return WriteUsageError( "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."); + "Use next_cursor from the preceding page without changing query, filter, or sort arguments.", + "cursor_mismatch"); } if (controls.CursorGenerationFingerprint is not null && !string.Equals(controls.CursorGenerationFingerprint, snapshot.GenerationFingerprint, StringComparison.Ordinal)) { - return WriteBoundedResponseUsageError( + return WriteUsageError( "cursor_stale: --cursor is stale because the index generation changed.", - "Restart pagination without --cursor and use the next_cursor returned by the refreshed index."); + "Restart pagination without --cursor and use the next_cursor returned by the refreshed index.", + "cursor_stale"); } if (controls.Offset > MaxPageWindow - controls.PageLimit) { - return WriteBoundedResponseUsageError( + return WriteUsageError( $"The requested cursor window exceeds the {MaxPageWindow} row safety cap.", "Narrow the query or filters before continuing pagination."); } @@ -607,7 +613,7 @@ private static int RunBoundedResponse( : SafeReadResponseSnapshot(resolvedDbPath, dbPathExplicit, appVersion); if (!string.Equals(snapshot.GenerationFingerprint, completedArraySnapshot.GenerationFingerprint, StringComparison.Ordinal)) { - return WriteBoundedResponseUsageError( + return WriteUsageError( "The index generation changed while this projected array was being read.", "Retry the same command after the active index refresh completes."); } @@ -647,7 +653,7 @@ private static int RunBoundedResponse( : SafeReadResponseSnapshot(resolvedDbPath, dbPathExplicit, appVersion); if (!string.Equals(snapshot.GenerationFingerprint, completedSnapshot.GenerationFingerprint, StringComparison.Ordinal)) { - return WriteBoundedResponseUsageError( + return WriteUsageError( "The index generation changed while this page was being read.", "Restart pagination without --cursor after the active index refresh completes."); } @@ -2244,7 +2250,8 @@ private static bool TryParseBoundedResponseControls( string command, string[] args, out BoundedResponseControls controls, - out string? error) + out string? error, + out string category) { List? fields = null; string? cursor = null; @@ -2252,6 +2259,7 @@ private static bool TryParseBoundedResponseControls( var pageLimit = DefaultPageLimit; var compact = HasCompactOutputSelection(command, args); error = null; + category = "usage"; var tokens = ClassifyArgumentTokens(command, args).ToArray(); for (var i = 0; i < tokens.Length; i++) { @@ -2335,6 +2343,7 @@ private static bool TryParseBoundedResponseControls( { controls = default!; error = "cursor_malformed: --cursor must be an opaque response:v2 cursor returned as next_cursor."; + category = "cursor_malformed"; return false; } controls = new BoundedResponseControls( @@ -2410,6 +2419,7 @@ private static bool TryReadPositiveIntControl( private static bool TryReadRequestedMaxJsonBytes(string command, string[] args, out long requestedBytes) { requestedBytes = 0; + var found = false; var tokens = ClassifyArgumentTokens(command, args).ToArray(); for (var i = 0; i < tokens.Length; i++) { @@ -2436,9 +2446,10 @@ private static bool TryReadRequestedMaxJsonBytes(string command, string[] args, return true; if (requestedBytes > int.MaxValue) return false; + found = true; } - return false; + return found; } private static string BuildResponseFingerprint(string command, string[] args) @@ -2729,6 +2740,47 @@ private static int WriteBoundedResponseUsageError(string message, string hint) return CommandExitCodes.UsageError; } + private static int WriteBoundedResponseUsageError( + string command, + string[] args, + JsonSerializerOptions jsonOptions, + string message, + string hint, + string category, + int? maxJsonBytes) + { + var machineOutput = HasJsonOutputSelection(command, args) + || HasEnvelopeFlag(command, args) + || HasArgument(command, args, "--fields") + || HasArgument(command, args, "--max-json-bytes") + || HasCompactOutputSelection(command, args) + || string.Equals(GetExplicitOutputFormat(command, args), "json", StringComparison.OrdinalIgnoreCase); + if (!machineOutput) + return WriteBoundedResponseUsageError(message, hint); + + var payload = CommandErrorWriter.BuildJsonPayload( + jsonOptions, message, CommandExitCodes.UsageError, hint, + errorCode: CommandErrorCodes.UsageError, category: category, command: command, + omitNullUsage: true); + var json = payload.ToJsonString(jsonOptions); + if (maxJsonBytes.HasValue && !JsonFitsResponseBudget(json, maxJsonBytes.Value)) + { + return CommandErrorWriter.WriteResponseBudgetError( + json: true, + jsonOptions, + command, + $"--max-json-bytes {maxJsonBytes.Value} is too small for the complete bounded validation error.", + "Increase --max-json-bytes to receive the validation error, then follow its recovery hint.", + requestedBytes: maxJsonBytes.Value, + effectiveBytes: maxJsonBytes.Value, + minimumRequiredBytes: GetJsonResponseByteCount(json), + additionalJsonProperties: new JsonObject { ["validation_error"] = payload }); + } + + Console.WriteLine(json); + return CommandExitCodes.UsageError; + } + private static string GetBoundedResponseUsage(string command) => ConsoleUi.GetUsageLine(command) ?? $"cdidx {command} --help"; diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4585Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4585Tests.cs index 66ad9aaaea..d8bc265b8d 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4585Tests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4585Tests.cs @@ -1036,16 +1036,20 @@ public void Map_RejectsCollectionProjectionExcludedByLegacyShapeControls_Issue45 _jsonOptions, "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, summaryExitCode); - Assert.Equal(string.Empty, summaryStdout); - Assert.Contains("cannot be combined with --summary-only", summaryStderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, summaryStderr); + using var summaryError = JsonDocument.Parse(summaryStdout); + Assert.Equal("usage", summaryError.RootElement.GetProperty("category").GetString()); + Assert.Contains("cannot be combined with --summary-only", summaryError.RootElement.GetProperty("message").GetString(), StringComparison.Ordinal); var (sectionsExitCode, sectionsStdout, sectionsStderr) = CaptureConsole(() => ProgramRunner.Run( ["map", "--db", dbPath, "--fields", "top_files.path", "--sections", "languages", "--limit", "1"], _jsonOptions, "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, sectionsExitCode); - Assert.Equal(string.Empty, sectionsStdout); - Assert.Contains("requires --sections hotspots", sectionsStderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, sectionsStderr); + using var sectionsError = JsonDocument.Parse(sectionsStdout); + Assert.Equal("usage", sectionsError.RootElement.GetProperty("category").GetString()); + Assert.Contains("requires --sections hotspots", sectionsError.RootElement.GetProperty("message").GetString(), StringComparison.Ordinal); } finally { diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4730Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4730Tests.cs index 79fd75df5c..61f65ca484 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4730Tests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4730Tests.cs @@ -108,8 +108,9 @@ public void Search_DataDirCursorUsesEffectiveDatabaseGeneration_Issue4730() ProgramRunner.Run(args.Concat(["--cursor", cursor]).ToArray(), _jsonOptions, "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, staleExitCode); - Assert.Equal(string.Empty, staleStdout); - Assert.Contains("index generation changed", staleStderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, staleStderr); + using var staleError = JsonDocument.Parse(staleStdout); + Assert.Equal("cursor_stale", staleError.RootElement.GetProperty("category").GetString()); } finally { @@ -271,8 +272,9 @@ public void SymbolsFilesAndLanguages_PageWithGenerationBoundCursors_Issue4730() "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, mismatchExitCode); - Assert.Equal(string.Empty, mismatchStdout); - Assert.Contains("does not match this command, query, or filter set", mismatchStderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, mismatchStderr); + using var mismatchError = JsonDocument.Parse(mismatchStdout); + Assert.Equal("cursor_mismatch", mismatchError.RootElement.GetProperty("category").GetString()); using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) { @@ -284,9 +286,10 @@ public void SymbolsFilesAndLanguages_PageWithGenerationBoundCursors_Issue4730() ProgramRunner.Run(symbolsArgs.Concat(["--cursor", symbolsCursor]).ToArray(), _jsonOptions, "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, staleExitCode); - Assert.Equal(string.Empty, staleStdout); - Assert.Contains("index generation changed", staleStderr, StringComparison.Ordinal); - Assert.Contains("Restart pagination", staleStderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, staleStderr); + using var staleError = JsonDocument.Parse(staleStdout); + Assert.Equal("cursor_stale", staleError.RootElement.GetProperty("category").GetString()); + Assert.Contains("Restart pagination", staleError.RootElement.GetProperty("hint").GetString(), StringComparison.Ordinal); } finally { diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs index c2240df401..733d9ecffe 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4863Tests.cs @@ -654,21 +654,14 @@ private void AssertCursorFailure(string[] args, string expectedCategory) ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, exitCode); - if (string.IsNullOrEmpty(stdout)) - { - Assert.Contains(expectedCategory, stderr, StringComparison.Ordinal); - return; - } - Assert.Equal(string.Empty, stderr); using var document = JsonDocument.Parse(stdout); + var error = document.RootElement.TryGetProperty("metadata", out var metadata) + ? metadata.GetProperty("error") + : document.RootElement; Assert.Equal( expectedCategory, - document.RootElement - .GetProperty("metadata") - .GetProperty("error") - .GetProperty("category") - .GetString()); + error.GetProperty("category").GetString()); } private static string MutateCursor( diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue5412Tests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue5412Tests.cs new file mode 100644 index 0000000000..565b7169fe --- /dev/null +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue5412Tests.cs @@ -0,0 +1,179 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using CodeIndex.Cli; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +public sealed class JsonEnvelopeWrapperIssue5412Tests +{ + [Fact] + public void Find_CursorErrorsHonorMachineSelectorsAndByteBudgets_Issue5412() + { + using var project = TestProjectHelper.CreateTempProjectScope("find_cursor_json_5412"); + var db = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(db, "rows.txt", "text", "Needle\nEnd\nNeedle\nEnd\nNeedle\nEnd"); + string[][] selectors = + [ + ["--json"], ["--json=ndjson"], ["--json-envelope"], + ["--format", "json"], ["--format=json"], + ["--fields", "path"], ["--fields=path", "--json"], + ["--json-envelope", "--fields", "path"], + ["--compact"], ["--format", "compact"], ["--format=compact"], + ["--max-json-bytes", "8192"], + ]; + string[][] modes = [[], ["--regex"], ["--regex", "--multiline", "--window-lines", "2"]]; + var staleRequests = new List<(string[] Args, bool Human)>(); + foreach (var mode in modes) + { + var query = mode.Contains("--multiline") ? "Needle\\nEnd" : "Needle"; + string[] args = ["find", query, .. mode, "--path", "rows.txt", "--db", db, "--limit", "1"]; + var (exit, stdout, stderr) = Run([.. args, "--json-envelope", "--fields", "path,line"]); + Assert.Equal(CommandExitCodes.Success, exit); + Assert.Empty(stderr); + using var firstPage = JsonDocument.Parse(stdout); + var cursor = firstPage.RootElement.GetProperty("metadata").GetProperty("next_cursor").GetString()!; + Assert.StartsWith("response:v2:", cursor, StringComparison.Ordinal); + + foreach (var selector in selectors) + { + AssertCursorError([.. args, "--cursor", "bad", .. selector], "cursor_malformed"); + AssertCursorError([.. args, .. selector, "--exact", "--cursor", cursor], "cursor_mismatch"); + staleRequests.Add(([.. args, .. selector, "--cursor", cursor], false)); + } + AssertHumanError([.. args, "--cursor", "bad"], "cursor_malformed"); + AssertHumanError([.. args, "--format", "text", "--exact", "--cursor", cursor], "cursor_mismatch"); + AssertErrorBudgets([.. args, "--json", "--cursor", "bad"], "cursor_malformed"); + AssertErrorBudgets([.. args, "--json", "--exact", "--cursor", cursor], "cursor_mismatch"); + // Valid replay keeps its normal envelope and advances to a different occurrence. + var (replayExit, replay, replayError) = Run([.. args, "--json-envelope", "--fields", "path,line", "--cursor", cursor]); + Assert.Equal(CommandExitCodes.Success, replayExit); + Assert.Empty(replayError); + using var page = JsonDocument.Parse(replay); + Assert.Equal(3, page.RootElement.GetProperty("results")[0].GetProperty("line").GetInt32()); + staleRequests.Add(([.. args, "--cursor", cursor], true)); + } + + TestProjectHelper.InsertIndexedFile(db, "changed.txt", "text", "generation changed"); + foreach (var (args, human) in staleRequests) + { + if (human) + AssertHumanError(args, "cursor_stale"); + else + AssertCursorError(args, "cursor_stale"); + } + AssertErrorBudgets(staleRequests[0].Args, "cursor_stale"); + + // Output-looking values and the literal query marker do not select machine output. + foreach (var args in new[] + { + new[] { "find", "--path", "rows.txt", "--cursor", "bad", "--query", "--json" }, + new[] { "find", "--path", "rows.txt", "--cursor", "bad", "--", "--json-envelope" }, + new[] { "find", "Needle", "--path", "--compact", "--cursor", "bad" }, + }) + AssertHumanError(args, "cursor_malformed"); + AssertCursorError(["search", "Needle", "--json", "--cursor", "bad"], "cursor_malformed", "search"); + } + + [Fact] + public void Find_CountCursorAndBoundedControlErrorsRetainTheirContracts_Issue5412() + { + using var project = TestProjectHelper.CreateTempProjectScope("find_count_cursor_5412"); + var db = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(db, "rows.txt", "text", "Needle\nNeedle"); + string[] args = ["find", "Needle", "--all", "--count", "--db", db, "--line-scan-limit", "1"]; + var (invalidExit, invalidOutput, invalidError) = Run([.. args, "--json", "--cursor", "bad"]); + Assert.Equal(CommandExitCodes.UsageError, invalidExit); + Assert.Empty(invalidError); + using var invalidDocument = JsonDocument.Parse(invalidOutput); + Assert.Equal("cursor_malformed", invalidDocument.RootElement.GetProperty("category").GetString()); + Assert.Equal(CommandErrorCodes.UsageError, invalidDocument.RootElement.GetProperty("error_code").GetString()); + AssertHumanError([.. args, "--cursor", "bad"], "find count cursor must be an opaque resumable response:v2 cursor"); + var (exit, stdout, stderr) = Run([.. args, "--json"]); + Assert.Equal(CommandExitCodes.PartialResult, exit); + Assert.Empty(stderr); + using var first = JsonDocument.Parse(stdout); + var cursor = first.RootElement.GetProperty("next_cursor").GetString()!; + var (resumedExit, resumed, resumedError) = Run([.. args, "--json", "--cursor", cursor]); + Assert.Equal(CommandExitCodes.Success, resumedExit); + Assert.Empty(resumedError); + using var last = JsonDocument.Parse(resumed); + Assert.Equal(1, last.RootElement.GetProperty("count").GetInt32()); + Assert.False(last.RootElement.TryGetProperty("metadata", out _)); + + foreach (var control in new[] { new[] { "--limit", "0" }, new[] { "--count" } }) + { + string[] invalid = ["find", "Needle", "--path", "rows.txt", "--fields", "path", .. control]; + var error = AssertCursorError(invalid, "usage"); + Assert.Contains(control[0], error.GetProperty("message").GetString(), StringComparison.Ordinal); + AssertErrorBudgets(invalid, "usage"); + } + } + + private static void AssertErrorBudgets(string[] args, string category) + { + foreach (var pretty in new[] { Array.Empty(), new[] { "--pretty" } }) + { + var request = args.Concat(pretty).ToArray(); + var (exit, stdout, stderr) = Run(request); + Assert.Equal(CommandExitCodes.UsageError, exit); + Assert.Empty(stderr); + var bytes = Encoding.UTF8.GetByteCount(stdout); + var exact = AssertCursorError([.. request, "--max-json-bytes", bytes.ToString(CultureInfo.InvariantCulture)], category); + using var original = JsonDocument.Parse(stdout); + Assert.Equal(original.RootElement.GetRawText(), exact.GetRawText()); + foreach (var budget in new[] { 1, bytes - 1 }) + { + // The cap must be found even before control parsing reaches a failure. + var (budgetExit, budgetOut, budgetError) = Run([request[0], "--max-json-bytes=" + budget.ToString(CultureInfo.InvariantCulture), .. request[1..]]); + Assert.Equal(CommandExitCodes.UsageError, budgetExit); + Assert.Empty(budgetError); + using var document = JsonDocument.Parse(budgetOut); + var error = document.RootElement; + Assert.Equal(CommandErrorCodes.ResponseBudgetTooSmall, error.GetProperty("error_code").GetString()); + Assert.Equal(budget, error.GetProperty("requested_bytes").GetInt32()); + Assert.Equal(budget, error.GetProperty("effective_bytes").GetInt32()); + Assert.True(error.GetProperty("minimum_required_bytes_known").GetBoolean()); + Assert.False(error.GetProperty("minimum_required_bytes_uncertain").GetBoolean()); + Assert.Equal(bytes, error.GetProperty("minimum_required_bytes").GetInt32()); + Assert.Equal(bytes, error.GetProperty("retry").GetProperty("recommended_bytes").GetInt32()); + Assert.Equal(category, error.GetProperty("validation_error").GetProperty("category").GetString()); + Assert.Equal(JsonSerializer.Serialize(original.RootElement), JsonSerializer.Serialize(error.GetProperty("validation_error"))); + } + } + } + + private static JsonElement AssertCursorError(string[] args, string category, string command = "find") + { + var (exit, stdout, stderr) = Run(args); + Assert.Equal(CommandExitCodes.UsageError, exit); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + var error = document.RootElement; + Assert.Equal("1", error.GetProperty("api_version").GetString()); + Assert.Equal("error", error.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.UsageError, error.GetProperty("error_code").GetString()); + Assert.Equal(CommandExitCodes.UsageError, error.GetProperty("exit_code").GetInt32()); + Assert.Equal(command, error.GetProperty("command").GetString()); + Assert.Equal(category, error.GetProperty("category").GetString()); + Assert.False(string.IsNullOrWhiteSpace(error.GetProperty("hint").GetString())); + Assert.False(error.TryGetProperty("next_cursor", out _)); + if (category != "usage") + Assert.Contains(category, error.GetProperty("message").GetString(), StringComparison.Ordinal); + return error.Clone(); + } + + private static void AssertHumanError(string[] args, string reason) + { + var (exit, stdout, stderr) = Run(args); + Assert.Equal(CommandExitCodes.UsageError, exit); + Assert.Empty(stdout); + Assert.Contains(CommandErrorCodes.UsageError, stderr, StringComparison.Ordinal); + Assert.Contains(reason, stderr, StringComparison.Ordinal); + Assert.Contains("Hint:", stderr, StringComparison.Ordinal); + } + + private static (int Result, string Stdout, string Stderr) Run(string[] args) + => CaptureConsole(() => ProgramRunner.Run(args, JsonOptions, "test")); +} diff --git a/tests/CodeIndex.Tests/JsonEnvelopeWrapperTests.cs b/tests/CodeIndex.Tests/JsonEnvelopeWrapperTests.cs index 665d778b66..c7e901dc8f 100644 --- a/tests/CodeIndex.Tests/JsonEnvelopeWrapperTests.cs +++ b/tests/CodeIndex.Tests/JsonEnvelopeWrapperTests.cs @@ -743,8 +743,9 @@ public void Search_EndOfOptionsPreservesLiteralQueryInCursorFingerprint_Issue520 "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, resumeExitCode); - Assert.Equal(string.Empty, resumeStdout); - Assert.Contains("cursor_mismatch", resumeStderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, resumeStderr); + using var error = JsonDocument.Parse(resumeStdout); + Assert.Equal("cursor_mismatch", error.RootElement.GetProperty("category").GetString()); } } finally diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5187Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5187Tests.cs index 0e58b87207..f69ac0df09 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5187Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5187Tests.cs @@ -233,9 +233,9 @@ public void BoundedGraphCursor_IsBoundToTheSelectedSymbolIdentity_Issue5187() "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, mismatchExitCode); - Assert.Equal(string.Empty, mismatchStdout); - Assert.Contains("cursor", mismatchStderr, StringComparison.OrdinalIgnoreCase); - Assert.Contains("cursor_mismatch", mismatchStderr, StringComparison.OrdinalIgnoreCase); + Assert.Equal(string.Empty, mismatchStderr); + using var error = QueryCommandTestSupport.ParseJsonOutput(mismatchStdout); + Assert.Equal("cursor_mismatch", error.RootElement.GetProperty("category").GetString()); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerPartialFamilyContinuationIssue5101Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerPartialFamilyContinuationIssue5101Tests.cs index b26e7a45e1..555134971b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerPartialFamilyContinuationIssue5101Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerPartialFamilyContinuationIssue5101Tests.cs @@ -315,8 +315,9 @@ private void AssertIssue5101CursorFailure(string[] args, string expectedError) ProgramRunner.Run(args, _jsonOptions, "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, exitCode); - Assert.Equal(string.Empty, stdout); - Assert.Contains(expectedError, stderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, stderr); + using var error = JsonDocument.Parse(stdout); + Assert.Equal(expectedError, error.RootElement.GetProperty("category").GetString()); } private static string MutateIssue5101Cursor(string cursor, Action mutate) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerUnusedIssue4905Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerUnusedIssue4905Tests.cs index 7951856db6..379320d9fb 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerUnusedIssue4905Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerUnusedIssue4905Tests.cs @@ -357,8 +357,9 @@ private void AssertCursorBindingAndGenerationIssue4905(string dbPath) "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, mismatchExitCode); - Assert.Equal(string.Empty, mismatchStdout); - Assert.Contains("cursor_mismatch", mismatchStderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, mismatchStderr); + using var mismatch = JsonDocument.Parse(mismatchStdout); + Assert.Equal("cursor_mismatch", mismatch.RootElement.GetProperty("category").GetString()); TestProjectHelper.InsertIndexedFile( dbPath, @@ -375,8 +376,9 @@ private void AssertCursorBindingAndGenerationIssue4905(string dbPath) "1.0.0-test")); Assert.Equal(CommandExitCodes.UsageError, staleExitCode); - Assert.Equal(string.Empty, staleStdout); - Assert.Contains("cursor_stale", staleStderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, staleStderr); + using var stale = JsonDocument.Parse(staleStdout); + Assert.Equal("cursor_stale", stale.RootElement.GetProperty("category").GetString()); } private static (string ProjectRoot, string DbPath) CreateUnusedByteBudgetFixtureDbIssue4905() From d6f10aa2a69cb06bca034fd1009902f36b229ba8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 23 Sep 2026 05:39:42 +0900 Subject: [PATCH 2/2] Align NDJSON cursor regression with structured errors (#5412) --- TESTING_GUIDE.md | 9 ++++ .../QueryCommandRunnerIssue5230Tests.cs | 41 ++++++++++++------- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index f2f7b3a44a..8528f2a5b9 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -6,6 +6,11 @@ selectors, malformed/mismatched/stale cursors, human and count controls, and exa UTF-8 byte limits (including pretty output and the final newline) on net8/net9. Run it with the envelope, find, pagination and response-budget regression suites; the #4863 cursor helper now requires structured output for every machine request. +Include `QueryCommandRunnerIssue5230Tests` when validating the shared wrapper: +its symbols NDJSON cursor fixture must assert stdout error identity, all three +cursor categories and recovery hints with empty stderr. Use Release net8/net9 +coverage for `Cursor|Bounded|JsonEnvelopeWrapper|RunFind|FindMultiline|ResponseBudget` +test-name groups alongside the issue-specific find/MCP suites. `RunDeps_PythonContextCoordinatesPreserveBothDirections_Issue5401` shares indexed fixtures across ordinary dependencies and cycles for zero/space/tab indentation, @@ -1825,6 +1830,10 @@ Issue #5300 のテストは隣接・入れ子の C# callable、対象行の除 不正・不一致・世代変更済みカーソル、人向け出力と件数の対照、pretty 出力と末尾改行を含む UTF-8 バイト境界を net8/net9 で維持してください。envelope・find・ページ分割・応答サイズの 回帰テストと実行し、#4863 のカーソル検証も全機械向け指定で構造化出力を必須にします。 +共有ラッパーの検証には `QueryCommandRunnerIssue5230Tests` も含めます。symbols の +NDJSON カーソル fixture で stdout のエラー識別情報・3種類の理由・復旧案内と、空の stderr を +検証してください。Issue 別の find/MCP テストとともに、`Cursor|Bounded|JsonEnvelopeWrapper|RunFind|FindMultiline|ResponseBudget` +を名前に含むテストを Release 構成の net8/net9 で実行します。 `FindMultilineTests` と `McpServerIssue5399Tests` は、索引の LF/CRLF 窓、補助平面文字の UTF-16 座標、重複チャンク、一致範囲の上限内外、アンカー、dot-all、ゼロ幅、非重複、件数と行の diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5230Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5230Tests.cs index 33903639ed..164ee91d7a 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5230Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5230Tests.cs @@ -286,22 +286,19 @@ public void Symbols_NdjsonCursorRejectsFilterMismatchAndIndexGenerationChange_Is .GetProperty("next_cursor") .GetString()); - var (invalidExit, _, invalidError) = CaptureConsole(() => ProgramRunner.Run( - [.. args, "--cursor", "response:v2:invalid"], _jsonOptions, "test")); - Assert.Equal(CommandExitCodes.UsageError, invalidExit); - Assert.Contains("cursor", invalidError, StringComparison.OrdinalIgnoreCase); + AssertMachineCursorError(CaptureConsole(() => ProgramRunner.Run( + [.. args, "--cursor", "response:v2:invalid"], _jsonOptions, "test")), + "cursor_malformed"); - var (mismatchExitCode, mismatchStdout, mismatchStderr) = CaptureConsole(() => + AssertMachineCursorError(CaptureConsole(() => ProgramRunner.Run( [ "symbols", "Issue5230Validation", "--kind", "interface", "--db", dbPath, "--json", "--limit", "1", "--cursor", cursor, ], _jsonOptions, - "1.0.0-test")); - Assert.Equal(CommandExitCodes.UsageError, mismatchExitCode); - Assert.Equal(string.Empty, mismatchStdout); - Assert.Contains("does not match this command, query, or filter set", mismatchStderr, StringComparison.Ordinal); + "1.0.0-test")), + "cursor_mismatch"); using (var db = new DbContext(DbOpenIntent.WriteIndex, dbPath)) { @@ -311,19 +308,35 @@ public void Symbols_NdjsonCursorRejectsFilterMismatchAndIndexGenerationChange_Is "2026-08-31T23:59:59.0000000+00:00"); } - var (staleExitCode, staleStdout, staleStderr) = CaptureConsole(() => + AssertMachineCursorError(CaptureConsole(() => ProgramRunner.Run( args.Concat(["--cursor", cursor]).ToArray(), _jsonOptions, - "1.0.0-test")); - Assert.Equal(CommandExitCodes.UsageError, staleExitCode); - Assert.Equal(string.Empty, staleStdout); - Assert.Contains("index generation changed", staleStderr, StringComparison.Ordinal); + "1.0.0-test")), + "cursor_stale"); } finally { TestProjectHelper.DeleteDirectory(projectRoot); } + + static void AssertMachineCursorError( + (int ExitCode, string Stdout, string Stderr) response, + string category) + { + Assert.Equal(CommandExitCodes.UsageError, response.ExitCode); + Assert.Empty(response.Stderr); + using var document = JsonDocument.Parse(response.Stdout); + var error = document.RootElement; + Assert.Equal("1", error.GetProperty("api_version").GetString()); + Assert.Equal("error", error.GetProperty("status").GetString()); + Assert.Equal("symbols", error.GetProperty("command").GetString()); + Assert.Equal(CommandExitCodes.UsageError, error.GetProperty("exit_code").GetInt32()); + Assert.Equal(CommandErrorCodes.UsageError, error.GetProperty("error_code").GetString()); + Assert.Equal(category, error.GetProperty("category").GetString()); + Assert.Contains(category, error.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.False(string.IsNullOrWhiteSpace(error.GetProperty("hint").GetString())); + } } [Fact]