Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions TESTING_GUIDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# 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.
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,
short/long names, aliases, relative imports and repeated same-line member calls.
Expand Down Expand Up @@ -1813,6 +1825,16 @@ Issue #5300 のテストは隣接・入れ子の C# callable、対象行の除

# テストガイド

`JsonEnvelopeWrapperIssue5412Tests` は分離した索引 fixture を共有し、リテラル・行単位正規表現・
複数行 find のカーソルエラーを検証します。JSON・envelope・fields・compact の各指定、
不正・不一致・世代変更済みカーソル、人向け出力と件数の対照、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、ゼロ幅、非重複、件数と行の
再開、カーソル条件変更、読取上限、取消・タイムアウト、投影と応答サイズを検証します。
Expand Down
17 changes: 17 additions & 0 deletions changelog.d/unreleased/5412.fixed.md
Original file line number Diff line number Diff line change
@@ -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 バイト上限に収まらない場合は必要な最小サイズと元の検証エラーを示します。人向け診断と独立した件数取得の継続処理は既存の動作を維持します。
51 changes: 51 additions & 0 deletions docs/find-scan-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 をまたぐ証拠を黙って省略しません。
Expand Down
82 changes: 67 additions & 15 deletions src/CodeIndex/Cli/JsonEnvelopeWrapper.Bounded.cs
Original file line number Diff line number Diff line change
Expand Up @@ -294,8 +294,8 @@ private static int RunBoundedResponse(
JsonSerializerOptions jsonOptions,
Func<string[], int> 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,
Expand All @@ -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"))
Expand Down Expand Up @@ -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);
Expand All @@ -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.");
}
Expand Down Expand Up @@ -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.");
}
Expand Down Expand Up @@ -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.");
}
Expand Down Expand Up @@ -2244,14 +2250,16 @@ private static bool TryParseBoundedResponseControls(
string command,
string[] args,
out BoundedResponseControls controls,
out string? error)
out string? error,
out string category)
{
List<string>? fields = null;
string? cursor = null;
int? maxJsonBytes = null;
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++)
{
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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++)
{
Expand All @@ -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)
Expand Down Expand Up @@ -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";

Expand Down
12 changes: 8 additions & 4 deletions tests/CodeIndex.Tests/JsonEnvelopeWrapperIssue4585Tests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
Loading
Loading