From 9c9987ec0958a28e7c28e94e55a641beb4a89dcf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 6 Sep 2026 02:54:12 +0900 Subject: [PATCH 1/2] Fix batch structured child error preservation (#5259) --- DEVELOPER_GUIDE.md | 23 +++ README.md | 2 + TESTING_GUIDE.md | 2 + USER_GUIDE.md | 35 +++- changelog.d/unreleased/5259.fixed.md | 16 ++ src/CodeIndex/Cli/BatchChildErrorParser.cs | 172 ++++++++++++++++++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.Batch.cs | 4 + .../BatchChildErrorParserTests.cs | 103 +++++++++++ .../QueryCommandRunnerBatchIssue5259Tests.cs | 122 +++++++++++++ 11 files changed, 473 insertions(+), 10 deletions(-) create mode 100644 changelog.d/unreleased/5259.fixed.md create mode 100644 src/CodeIndex/Cli/BatchChildErrorParser.cs create mode 100644 tests/CodeIndex.Tests/BatchChildErrorParserTests.cs create mode 100644 tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5259Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 72fb03b6ed..6995f8ea2b 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -882,6 +882,19 @@ single-document JSON is embedded as typed `result`, while successful NDJSON is embedded as a stable typed `results` array even when it has one row. Successful text remains `stdout`, while every failure uses one typed `error` object with a stable `error_code`, `category`, safe `message` / `hint`, and `scope`. +The common serial/parallel record writer first projects valid child JSON errors +through `BatchChildErrorParser`, with a 64 KiB UTF-8 input cap, depth 16, unique +object keys, matching command/exit identity when supplied, and an explicit field +allowlist. It accepts standalone `status: "error"` objects or JSON envelopes with +matching `metadata.exit_code` and a `metadata.error` object; it never parses stderr or +splices NDJSON fragments. Keep byte counts as nonnegative nullable Int64 values, +known/uncertain flags as booleans, and strings sanitized to at most 1,024 characters. +Retain `requested_bytes`, `effective_bytes`, `minimum_required_bytes`, both minimum +availability/uncertainty flags and reasons, and `retry.action`, `option`, +`recommended_bytes`, `maximum_effective_bytes`, and `command`. Unknown fields are +omitted; invalid or oversized child errors keep the exit-code fallback. Explicit +capture-limit, cancellation, timeout, and dispatch-policy errors take precedence. +The parent output budget still applies to the entire projected error. Malformed or over-limit input lines use `record: "batch_error"` and the same typed error serializer. Failed records omit captured child stdout/stderr by default; `--include-raw-streams` explicitly adds them under a bounded @@ -5128,6 +5141,16 @@ command / output format で projection を選び、成功した単一 document J は型付き `result`、NDJSON は 1 row の場合も安定した型付き `results` array として埋め込む。 成功した text command は `stdout` のまま保持する一方、すべての失敗は安定した `error_code`、 `category`、安全な `message` / `hint`、`scope` を持つ共通の型付き `error` object を使う。 +serial / parallel 共通の record writer は、まず `BatchChildErrorParser` で有効な子 JSON エラーを +許可フィールドへ射影する。入力は UTF-8 で 64 KiB、深さ 16 に制限し、object key の一意性と、 +指定されている command / exit の一致を検証する。単独の `status: "error"` object、または +一致する `metadata.exit_code` と `metadata.error` object を持つ JSON envelope を受け付け、stderr の解析や +NDJSON fragment の継ぎ合わせは行わない。byte 数は非負の nullable Int64、既知・不確実性のフラグは +boolean、文字列は機密情報を除去した最大 1,024 文字とする。`requested_bytes`、`effective_bytes`、 +`minimum_required_bytes`、最小値の既知・不確実性の両フラグと理由、および `retry` の `action`、 +`option`、`recommended_bytes`、`maximum_effective_bytes`、`command` を保持する。未知のフィールドは +省略し、不正または上限超過の子エラーは終了コード由来の汎用エラーを維持する。明示的な capture 上限、 +取消、timeout、dispatch policy のエラーを優先し、射影後のエラー全体にも親の出力上限を適用する。 malformed line や入力上限超過 line は `record: "batch_error"` と同じ typed error serializer を 使う。失敗 record は既定で捕捉した child stdout / stderr を省略し、 `--include-raw-streams` を明示した場合だけ上限付きの `raw_streams` object に追加する。 diff --git a/README.md b/README.md index f4912db950..35bd8fe4af 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ cdidx lsp --db .cdidx/codeindex.db | Browse every command | [Command reference](USER_GUIDE.md#command-reference) | | Keep the index current | [Keeping the index fresh](USER_GUIDE.md#keeping-the-index-fresh) and [incremental update reliability](USER_GUIDE.md#incremental-update-reliability) | | Control JSON size and pagination | [JSON output format](USER_GUIDE.md#json-output-format) | +| Automate multiple queries | `batch --json-summary` preserves safe child JSON error codes and budget/retry fields; see [JSON output format](USER_GUIDE.md#json-output-format). | | Configure MCP, Codex, or an editor | [AI Integration](USER_GUIDE.md#ai-integration) | | Tune large repositories | [Performance tuning](USER_GUIDE.md#performance-tuning-for-large-repositories) | @@ -370,6 +371,7 @@ cdidx lsp --db .cdidx/codeindex.db | 全 command を確認する | [コマンドリファレンス](USER_GUIDE.md#コマンドリファレンス) | | index を最新に保つ | [インデックスを最新に保つ](USER_GUIDE.md#インデックスを最新に保つ) と [インクリメンタル更新の信頼性](USER_GUIDE.md#インクリメンタル更新の信頼性) | | JSON size と pagination を制御する | [出力形式](USER_GUIDE.md#出力形式) | +| 複数の query を自動実行する | `batch --json-summary` は子 JSON の安全なエラーコードとサイズ上限・再試行情報を保持します。[出力形式](USER_GUIDE.md#出力形式)を参照してください。 | | MCP、Codex、editor を設定する | [AI との連携](USER_GUIDE.md#aiとの連携) | | 大規模リポジトリを調整する | [大規模リポジトリの performance tuning](USER_GUIDE.md#大規模リポジトリの-performance-tuning) | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index a7c36f2828..c1d5eb37f0 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -292,6 +292,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result Ctags export JSON coverage reuses one seeded database for default and `--include-generated` variants, asserts the fixed skip-reason keys sum to `skipped_count`, and keeps the missing-`files.generated` degradation in a separate legacy-schema fixture. Dry-run JSON coverage for ambiguous `.h` files locks the bounded `language_detections` entries and their stable source/confidence codes without mutating the index. Batch `--json-summary` coverage keeps side-effect-free dispatch plus successful JSON, single-row NDJSON, and JSON-looking text projection in one seeded fixture when the assertions stay distinct; include case-insensitive format values, command-specific JSON formats, and alias-injected JSON defaults so batch classification cannot drift from child parsers. Serialized-output exhaustion remains a separate boundary fixture because of its large escaped payload. Assert `result` / `results` versus raw `stdout` presence explicitly, compare `output_chars` with the actual captured stream length, and cover repeated malformed lines up to the input cap so neither syntax sniffing nor envelope overhead can bypass the transport bounds. Typed-failure coverage reuses one mixed scalar/array/schema/policy/child-failure input across serial and parallel modes, asserts stable ordered categories, and keeps timeout, caller cancellation, and explicit `--include-raw-streams` behavior in focused fixtures. Shared line-preparation/accounting coverage compares mixed blank, valid, malformed, and unsupported input across serial and parallel modes, including ordered records, first failure, counters, limits, and output characters. Output-limit drain coverage gates two active workers without sleeps, proves the batch remains pending until the held worker completes, keeps an unpublished input-limit record out of committed counters, verifies console restoration after the join, and replays only accepted-but-undispatched nonblank input records in source order before pump-buffered future input with fresh line numbers and counters on the next invocation; blocked-input preservation remains in the focused cancellation fixture. Cancellation coverage includes pre-cancelled setup, cancellation after an earlier failure and after parallel item preparation, and a blocking stdin reader that proves both prompt wake-up and preservation of an in-flight line for the next batch invocation. + Issue #5259 pairs `BatchChildErrorParserTests` with `QueryCommandRunnerBatchIssue5259Tests`: preserve direct/child E028 classification and every measured budget/retry field for status explanations and search arrays in serial/parallel batches, alongside mixed success/text failures, explicit raw-stream compatibility, parent-budget accounting, and the existing cancellation/timeout fixtures. Parser coverage must retain malformed/duplicate/type/identity rejection, UTF-8 byte and depth boundaries, unknown-minimum/reduce-size retry variants, nested envelopes, text bounds, and secret/path/control sanitization without unknown-field reflection. Argument-validation variants that only differ by invalid scalar input share one database fixture and iterate within a fact when no per-case state or discovery identity is required. Positional `files` glob coverage shares one indexed-file fixture and iterates `*`, `?`, and recursive `**` patterns in a fact, matching the exact tokens that a quoted shell argument passes to the CLI. Excerpt focus coverage reuses one indexed fixture for line-only leading-window behavior, the focus-length dependency, and focus-column range validation; zero and non-numeric focus-column values share one indexed Markdown fixture. @@ -1466,6 +1467,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ctags export JSON coverage は1つの seeded database を既定と `--include-generated` variant で再利用し、固定された skip-reason key の合計が `skipped_count` と一致することを検証します。`files.generated` がない場合の縮退は別の legacy-schema fixture に保ってください。 曖昧な `.h` に対する dry-run JSON coverage は、index を変更せず、上限付き `language_detections` entry と安定した判定元・信頼度 code を固定します。 batch `--json-summary` coverage は、assertion を明確に保てる場合、副作用なし dispatch と成功時の JSON / 1 row NDJSON / JSON に見える text projection を1つの seeded fixture で共有し、case-insensitive な format 値、command 固有の JSON format、alias が内部追加する JSON default も含めて child parser と batch classification の drift を防いでください。serialized output の枯渇は escape を多く含む大きな payload の境界 fixture として分離します。test 側で `result` / `results` と raw `stdout` の有無を明示し、`output_chars` を実際に捕捉した stream 長と比較し、input cap までの malformed line 反復も検証して、syntax sniffing や envelope overhead が transport 上限を迂回しないようにしてください。typed failure coverage は scalar / array / schema / policy / child failure を混在させた1つの input を serial / parallel mode で共有し、安定した順序と category を検証してください。timeout、caller cancellation、明示的な `--include-raw-streams` の挙動は焦点を絞った fixture に分離します。共有 line preparation / accounting の coverage では blank、valid、malformed、unsupported input の混在を serial / parallel mode で比較し、record 順、first failure、counter、limit、output character 数を固定します。output-limit drain coverage は sleep なしの gate で active worker 2つを保持し、一方の worker が完了するまで batch が pending のままであること、未配送の input-limit record が確定済み counter に混入しないこと、join 後に console が復元されること、受理済みでも未 dispatch の nonblank input record だけが source 順で pump buffer 内の後続入力より前に戻され、次の invocation では line number と counter が新しく付け直されることを検証します。blocked input の保持は focused cancellation fixture に分離してください。cancellation coverage には事前 cancel 済み setup、先行 failure 後と parallel item 準備後の cancellation、blocking stdin reader を含め、型付き summary、迅速な wait 解除、同時に読み取った line が次の batch invocation 用に保持されることを決定的に検証します。 + Issue #5259 は `BatchChildErrorParserTests` と `QueryCommandRunnerBatchIssue5259Tests` を対にし、status 説明と search 配列の直接実行/子実行における E028 分類および全サイズ・再試行フィールドを逐次/並列 batch で維持します。成功/text 失敗の混在、生 stream の明示指定互換性、親上限の計数、既存の取消/timeout fixture も検証してください。parser は不正 JSON/重複/型/identity の拒否、UTF-8 byte/深さの境界、最小値不明/サイズ縮小の再試行、nested envelope、文字数上限、および未知フィールドを反映しない機密情報/path/制御文字の除去を検証します。 invalid scalar input だけが異なる argument-validation variant は、case ごとの state や discovery identity が不要なら1つの database fixture を共有し、fact 内で反復してください。 `files` の positional glob coverage は1つの indexed-file fixture を共有し、quote された shell 引数が CLI に渡す token と同じ `*`、`?`、recursive `**` pattern を fact 内で反復してください。 excerpt の focus coverage は、line-only 時の先頭側 window、focus-length の依存関係、focus-column の範囲検証を1つの indexed fixture で共有してください。focus-column の zero / non-numeric value も1つの indexed Markdown fixture を再利用してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 3fb5584e5f..0b07949e0d 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1921,11 +1921,21 @@ standalone command output shape. With no input, `batch` exits 0 and prints nothing by default. Pass `--json-summary` when a non-interactive caller needs a machine-readable batch stream: each non-blank stdin line emits one JSON envelope before the final summary. Parsed commands use -`record: "batch_result"` with `line`, `command`, `arguments`, `exit_code`, and -captured child `stderr`. Successful single-document JSON is embedded as typed +`record: "batch_result"` with `line`, `command`, `arguments`, and `exit_code`. +Successful records retain captured child `stderr`. Successful single-document JSON is embedded as typed `result`, successful NDJSON is embedded as a stable `results` array even when it -contains one row, and text or failed output remains raw `stdout`. Malformed or -over-limit lines use `record: "batch_error"` with an `error` object. The complete +contains one row, and successful text remains raw `stdout`. Failures use a typed +`error` with `error_code`, `category`, sanitized `message` / `hint`, and `scope`. +Valid child JSON errors preserve their classification and documented budget/retry +fields by default, including `E028_RESPONSE_BUDGET_TOO_SMALL`, `requested_bytes`, +`effective_bytes`, `minimum_required_bytes`, its known/uncertain flags and reasons, +and `retry` (`action`, `option`, `recommended_bytes`, `maximum_effective_bytes`, +`command`). Parsing is limited to 64 KiB of UTF-8 and depth 16; strings are sanitized +and limited to 1,024 characters. Unknown fields are omitted. Text, malformed, +over-limit, or invalid error objects retain the safe exit-code-based fallback. +Failed stdout/stderr are available only with `--include-raw-streams`, under the +bounded `raw_streams` object; these diagnostic streams retain their original content. +Malformed or over-limit input lines use `record: "batch_error"` with an `error` object. The complete serialized stream, including envelopes, arguments, JSON escaping, terminal errors, and the final summary, defaults to a 10,485,760-character budget. `--max-output-chars ` can change that budget from 4,096 through the safe @@ -5695,10 +5705,19 @@ cdidx search "authenticate" --json --verbose ない場合、`batch` は既定で exit 0 かつ無出力です。非対話の呼び出し元が machine-readable な batch stream を必要とする場合は `--json-summary` を渡します。この場合、空白でない stdin 行ごとに 1 つの JSON envelope を出力してから final summary を出します。parse 済み command は -`record: "batch_result"` として `line`、`command`、`arguments`、`exit_code`、捕捉した child -`stderr` を持ちます。成功した単一 document JSON は型付き `result`、成功した NDJSON は 1 row -の場合も安定して `results` array に埋め込み、text または失敗時の出力は raw `stdout` のまま -保持します。malformed line や上限超過 line は `record: "batch_error"` と `error` object を +`record: "batch_result"` として `line`、`command`、`arguments`、`exit_code` を持ちます。 +成功した record は捕捉した child `stderr` を保持します。成功した単一 document JSON は型付き +`result`、成功した NDJSON は 1 row の場合も安定して `results` array に埋め込み、成功した text は +raw `stdout` のまま保持します。失敗時は `error_code`、`category`、機密情報を除去した +`message` / `hint`、`scope` を持つ型付き `error` を返します。有効な子 JSON エラーの分類と +文書化されたサイズ上限・再試行情報は既定で保持されます。対象は `E028_RESPONSE_BUDGET_TOO_SMALL`、 +`requested_bytes`、`effective_bytes`、`minimum_required_bytes` と既知・不確実性のフラグ/理由、 +`retry`(`action`、`option`、`recommended_bytes`、`maximum_effective_bytes`、`command`)です。 +解析は UTF-8 で 64 KiB、深さ 16 までとし、文字列は機密情報を除去して 1,024 文字以内に制限します。 +未知のフィールドは省略し、text、不正 JSON、上限超過、不正なエラー object は終了コード由来の安全な +汎用エラーを維持します。失敗時の stdout / stderr は `--include-raw-streams` を指定した場合だけ、 +上限付きの `raw_streams` object に元の診断内容のまま追加されます。 +malformed な入力 line や上限超過 line は `record: "batch_error"` と `error` object を 持ちます。envelope、arguments、JSON escape、terminal error、final summary を含む serialized stream 全体の既定 budget は 10,485,760 文字です。`--max-output-chars ` で 4,096 から安全な 最大値 67,108,864 文字まで変更できます。最後の `record: "batch_summary"` object は diff --git a/changelog.d/unreleased/5259.fixed.md b/changelog.d/unreleased/5259.fixed.md new file mode 100644 index 0000000000..7eee7a97d6 --- /dev/null +++ b/changelog.d/unreleased/5259.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 5259 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.Batch.cs + - src/CodeIndex/Cli/BatchChildErrorParser.cs +--- + +## English + +- **Batch JSON summaries preserve structured child errors and retry guidance (#5259)** — Sequential and parallel summaries retain safe child JSON error classifications, measured output-budget fields, and retry guidance, including E028 failures from status explanations and search arrays. Parsing is bounded and sanitized; raw failed streams remain an explicit `--include-raw-streams` option, and parent output limits still apply. + +## 日本語 + +- **Batch JSON summary が子エラーの構造化情報と再試行案内を保持するようになりました (#5259)** — 逐次・並列の summary は、status 説明や search 配列の E028 を含め、子 JSON の安全なエラー分類、測定済みの出力サイズ情報、再試行案内を保持します。解析には上限と機密情報除去を適用し、失敗時の生 stream は引き続き `--include-raw-streams` の明示指定を必要とします。親の出力上限も維持します。 diff --git a/src/CodeIndex/Cli/BatchChildErrorParser.cs b/src/CodeIndex/Cli/BatchChildErrorParser.cs new file mode 100644 index 0000000000..93b52b2384 --- /dev/null +++ b/src/CodeIndex/Cli/BatchChildErrorParser.cs @@ -0,0 +1,172 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Diagnostics; + +namespace CodeIndex.Cli; + +// Child stdout is untrusted. Never copy arbitrary properties or inspect stderr. +internal static class BatchChildErrorParser +{ + internal const int MaxUtf8Bytes = 64 * 1024; + internal const int MaxDepth = 16; + internal const int MaxTextChars = 1024; + + internal static JsonObject? Parse(string stdout, string command, int exitCode) + { + if (string.IsNullOrWhiteSpace(stdout) || exitCode == CommandExitCodes.Success) + return null; + + try + { + using var document = BoundedJson.ParseDocument(stdout, MaxUtf8Bytes, MaxDepth); + var root = document.RootElement; + if (!IsUniqueObject(root)) + return null; + + var source = root; + if (root.TryGetProperty("metadata", out var metadata)) + { + if (!IsUniqueObject(metadata) + || !metadata.TryGetProperty("error", out var nestedError) + || !IsUniqueObject(nestedError) + || !metadata.TryGetProperty("exit_code", out var envelopeExit) + || envelopeExit.ValueKind != JsonValueKind.Number + || !envelopeExit.TryGetInt32(out var envelopeExitCode) + || envelopeExitCode != exitCode + || !IdentityMatches(metadata, command, exitCode)) + return null; + source = nestedError; + } + else if (!root.TryGetProperty("status", out var status) + || status.ValueKind != JsonValueKind.String || status.GetString() != "error") + { + return null; + } + + if (!IdentityMatches(source, command, exitCode) + || !TryGetMachineString(source, "error_code", out var errorCode) + || !source.TryGetProperty("message", out var message) + || message.ValueKind != JsonValueKind.String + || string.IsNullOrWhiteSpace(message.GetString())) + return null; + + var (_, defaultCategory) = CommandErrorWriter.ResolveMachineContract(exitCode); + var category = defaultCategory; + if (source.TryGetProperty("category", out _) + && !TryGetMachineString(source, "category", out category)) + return null; + + var result = new JsonObject + { + ["error_code"] = errorCode, + ["category"] = category, + ["message"] = Sanitize(message.GetString()!), + ["scope"] = "command", + }; + foreach (var property in source.EnumerateObject()) + { + switch (property.Name) + { + case "hint": + case "minimum_required_bytes_unavailable_reason": + case "minimum_required_bytes_uncertainty_reason": + if (!CopyString(property, result)) + return null; + break; + case "requested_bytes": + case "effective_bytes": + case "minimum_required_bytes": + if (!CopyBytes(property, result)) + return null; + break; + case "minimum_required_bytes_known": + case "minimum_required_bytes_uncertain": + if (property.Value.ValueKind is not (JsonValueKind.True or JsonValueKind.False)) + return null; + result[property.Name] = property.Value.GetBoolean(); + break; + case "retry": + if (!IsUniqueObject(property.Value)) + return null; + var retry = new JsonObject(); + foreach (var child in property.Value.EnumerateObject()) + { + if (child.Name is "action" or "option" or "command") + { + if (!CopyString(child, retry)) + return null; + } + else if (child.Name is "recommended_bytes" or "maximum_effective_bytes") + { + if (!CopyBytes(child, retry)) + return null; + } + } + result["retry"] = retry; + break; + } + } + return result; + } + catch (Exception ex) when (ex is JsonException or InvalidDataException) + { + // Malformed or over-budget output retains the exit-code fallback. + return null; + } + } + + private static bool IdentityMatches(JsonElement source, string command, int exitCode) + => (!source.TryGetProperty("command", out var name) + || (name.ValueKind == JsonValueKind.String + && name.GetString() == JsonEnvelopeWrapper.CanonicalizeCommandName(command))) + && (!source.TryGetProperty("exit_code", out var code) + || (code.ValueKind == JsonValueKind.Number + && code.TryGetInt32(out var value) && value == exitCode)); + + private static bool IsUniqueObject(JsonElement source) + { + if (source.ValueKind != JsonValueKind.Object) + return false; + var names = new HashSet(StringComparer.Ordinal); + return source.EnumerateObject().All(property => names.Add(property.Name)); + } + + private static bool TryGetMachineString(JsonElement source, string name, out string value) + { + value = string.Empty; + if (!source.TryGetProperty(name, out var property) || property.ValueKind != JsonValueKind.String) + return false; + value = property.GetString()!; + return value.Length is > 0 and <= 128 + && value.All(ch => char.IsAsciiLetterOrDigit(ch) || ch is '_' or '-' or '.') + && Sanitize(value) == value; + } + + private static bool CopyString(JsonProperty property, JsonObject target) + { + if (property.Value.ValueKind == JsonValueKind.Null) + target[property.Name] = null; + else if (property.Value.ValueKind == JsonValueKind.String) + target[property.Name] = Sanitize(property.Value.GetString()!); + else + return false; + return true; + } + + private static bool CopyBytes(JsonProperty property, JsonObject target) + { + if (property.Value.ValueKind == JsonValueKind.Null) + target[property.Name] = null; + else if (property.Value.ValueKind == JsonValueKind.Number + && property.Value.TryGetInt64(out var bytes) && bytes >= 0) + target[property.Name] = bytes; + else + return false; + return true; + } + + private static string Sanitize(string value) + => DiagnosticSanitizer.ForMessage( + new string(value.Select(ch => char.IsControl(ch) ? ' ' : ch).ToArray()), + MaxTextChars - 3); +} diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 7824927bdd..db3cb04b38 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -458,7 +458,7 @@ private static IReadOnlyList BuildAll() ["config"] = Set("show"), }, }, - new() { Name = "--json-summary", Description = "Batch: emit one typed result/error record per input plus a final summary", PrimaryCommands = Set("batch") }, + new() { Name = "--json-summary", Description = "Batch: emit typed results/errors, preserving safe child JSON error codes and budget/retry fields, plus a final summary", PrimaryCommands = Set("batch") }, new() { Name = "--include-raw-streams", Description = "Batch JSON-summary: attach bounded child stdout/stderr to failed records", PrimaryCommands = Set("batch") }, new() { Name = "--max-input-lines", ValuePlaceholder = "", Description = $"Batch: input-line budget (default {QueryCommandRunner.BatchDefaultInputLines}, max {QueryCommandRunner.BatchMaxInputLines})", PrimaryCommands = Set("batch") }, new() { Name = "--max-output-chars", ValuePlaceholder = "", Description = $"Batch JSON-summary output budget (default {QueryCommandRunner.BatchDefaultTotalOutputChars}, max {QueryCommandRunner.BatchMaxTotalOutputChars})", PrimaryCommands = Set("batch") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 069282dbb8..7dd1b8440f 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -146,7 +146,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("export-ctags", "cdidx export ctags [--output ] [--db ] [--json] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--include-generated]"), ("import", "cdidx import [--db ] [--prune-paths] [--no-backup] [--dry-run|--check] [--limit ] [--offset ] [--json]"), ("languages", "cdidx languages [--db ] [--json] [--format ] [--summary-only] [--limit |--top ] [--cursor ] [--max-json-bytes ] [--indexed-only] [--language |--extension |--alias ] [--capability ]"), - ("batch", "cdidx batch [--db ] [--json-summary] [--include-raw-streams] [--max-input-lines ] [--max-output-chars ] [--parallel ] # stdin is JSON Lines; --json-summary embeds typed child JSON plus a final summary"), + ("batch", "cdidx batch [--db ] [--json-summary] [--include-raw-streams] [--max-input-lines ] [--max-output-chars ] [--parallel ] # stdin is JSON Lines; --json-summary preserves safe child JSON errors and budget/retry fields; raw failed output requires --include-raw-streams"), ("hooks-install", "cdidx hooks install [--project ] [--force] [--dry-run] [--json]"), ("hooks-uninstall", "cdidx hooks uninstall [--project ] [--force] [--json]"), ("hooks-status", "cdidx hooks status [--project ] [--json]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs b/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs index 5ebccc319e..d8863c8226 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs @@ -281,6 +281,10 @@ private static bool WriteBatchCommandRecordJson( } else { + if (error is null + && (outputKind != BatchOutputKind.Text || JsonEnvelopeWrapper.ShouldWrap(commandName, subArgs)) + && CliCommandCatalog.IsBatchReadOnlyCommand(commandName)) + error = BatchChildErrorParser.Parse(stdout, commandName, exitCode); payload["error"] = BuildBatchCommandFailureError(commandName, exitCode, error); if (includeRawStreams) { diff --git a/tests/CodeIndex.Tests/BatchChildErrorParserTests.cs b/tests/CodeIndex.Tests/BatchChildErrorParserTests.cs new file mode 100644 index 0000000000..5ba4752e68 --- /dev/null +++ b/tests/CodeIndex.Tests/BatchChildErrorParserTests.cs @@ -0,0 +1,103 @@ +using System.Text; +using System.Text.Json.Nodes; +using CodeIndex.Cli; + +namespace CodeIndex.Tests; + +public class BatchChildErrorParserTests +{ + private const string ErrorJson = """ + {"status":"error","error_code":"E028_RESPONSE_BUDGET_TOO_SMALL","category":"response_budget", + "message":"Increase the output budget.","hint":"Retry with more bytes.","command":"search","exit_code":1, + "requested_bytes":512,"effective_bytes":512,"minimum_required_bytes":2048, + "minimum_required_bytes_known":true,"minimum_required_bytes_unavailable_reason":null, + "minimum_required_bytes_uncertain":false,"minimum_required_bytes_uncertainty_reason":null, + "retry":{"action":"increase_max_json_bytes","option":"--max-json-bytes","recommended_bytes":3072, + "maximum_effective_bytes":null,"command":"search"}} + """; + + [Fact] + public void Parse_PreservesAllowlistedFieldsAndSanitizesUntrustedText() + { + var source = JsonNode.Parse(ErrorJson)!.AsObject(); + source["message"] = "Failed\u001b\n at /private/secret/file.cs password=hunter2 Bearer abcdefghijklmnopqrstuvwxyz"; + source["hint"] = @"Retry C:\private\secret.cs --token hidden-value"; + source["path"] = "/private/secret/file.cs"; + source["unknown"] = new JsonObject { ["password"] = "unlisted-secret" }; + source["scope"] = "batch"; + source["retry"]!["unknown"] = "unlisted-secret"; + var parsed = Assert.IsType(BatchChildErrorParser.Parse(source.ToJsonString(), "search", 1)); + Assert.Equal("command", parsed["scope"]!.GetValue()); + Assert.Equal(2048, parsed["minimum_required_bytes"]!.GetValue()); + Assert.Equal(3072, parsed["retry"]!["recommended_bytes"]!.GetValue()); + Assert.False(parsed.ContainsKey("path")); + Assert.False(parsed.ContainsKey("unknown")); + Assert.False(parsed["retry"]!.AsObject().ContainsKey("unknown")); + var serialized = parsed.ToJsonString(); + foreach (var secret in new[] { "hunter2", "abcdefghijklmnopqrstuvwxyz", "hidden-value", "private", "unlisted-secret" }) + Assert.DoesNotContain(secret, serialized); + Assert.DoesNotContain(parsed["message"]!.GetValue(), char.IsControl); + + source["message"] = new string('x', 3000); + parsed = Assert.IsType(BatchChildErrorParser.Parse(source.ToJsonString(), "search", 1)); + Assert.True(parsed["message"]!.GetValue().Length <= BatchChildErrorParser.MaxTextChars); + + source = JsonNode.Parse(ErrorJson)!.AsObject(); + source.Remove("status"); + var envelope = new JsonObject + { + ["metadata"] = new JsonObject { ["command"] = "search", ["exit_code"] = 1, ["error"] = source }, + ["results"] = new JsonArray(), + }; + var parsedEnvelope = BatchChildErrorParser.Parse(envelope.ToJsonString(), "search", 1); + Assert.True(parsedEnvelope is not null, envelope.ToJsonString()); + parsed = parsedEnvelope; + Assert.Equal("response_budget", parsed["category"]!.GetValue()); + + source["minimum_required_bytes"] = null; + source["minimum_required_bytes_known"] = false; + source["minimum_required_bytes_unavailable_reason"] = "normal_payload_not_materialized"; + source["retry"]!["action"] = "reduce_response_size"; + source["retry"]!["option"] = null; + source["retry"]!["recommended_bytes"] = null; + source["retry"]!["maximum_effective_bytes"] = 4096; + parsed = Assert.IsType(BatchChildErrorParser.Parse(envelope.ToJsonString(), "search", 1)); + Assert.Null(parsed["minimum_required_bytes"]); + Assert.False(parsed["minimum_required_bytes_known"]!.GetValue()); + Assert.Equal("normal_payload_not_materialized", parsed["minimum_required_bytes_unavailable_reason"]!.GetValue()); + Assert.Equal(4096, parsed["retry"]!["maximum_effective_bytes"]!.GetValue()); + } + + [Fact] + public void Parse_RejectsMalformedMismatchedAndOverBudgetOutput() + { + foreach (var invalid in new[] + { + "", "Error: password=secret", "{", "[]", "null", "42", + ErrorJson + "\n" + ErrorJson, + ErrorJson.Replace("\"status\":\"error\"", "\"status\":\"ok\""), + ErrorJson.Replace("\"category\":\"response_budget\"", "\"category\":{}"), + ErrorJson.Replace("\"exit_code\":1", "\"exit_code\":2"), + ErrorJson.Replace("\"command\":\"search\"", "\"command\":\"status\""), + ErrorJson.Replace("\"requested_bytes\":512", "\"requested_bytes\":-1"), + ErrorJson.Replace("\"recommended_bytes\":3072", "\"recommended_bytes\":1e100"), + ErrorJson.Replace("\"minimum_required_bytes_known\":true", "\"minimum_required_bytes_known\":\"true\""), + ErrorJson.Replace("\"message\":", "\"message\":\"duplicate\",\"message\":"), + ErrorJson.Replace("\"action\":", "\"action\":\"duplicate\",\"action\":"), + ErrorJson.Replace("\"status\":\"error\"", "\"status\":\"error\",\"unknown\":" + + new string('[', BatchChildErrorParser.MaxDepth) + "0" + new string(']', BatchChildErrorParser.MaxDepth)), + """{"metadata":{"exit_code":"1","error":{}}}""", + }) + Assert.Null(BatchChildErrorParser.Parse(invalid, "search", 1)); + + Assert.Null(BatchChildErrorParser.Parse(ErrorJson, "search", 0)); + var exactFit = ErrorJson + new string(' ', BatchChildErrorParser.MaxUtf8Bytes - Encoding.UTF8.GetByteCount(ErrorJson)); + Assert.NotNull(BatchChildErrorParser.Parse(exactFit, "search", 1)); + Assert.Null(BatchChildErrorParser.Parse(exactFit + " ", "search", 1)); + var unicode = JsonNode.Parse(ErrorJson)!.AsObject(); + unicode["ignored"] = new string('日', BatchChildErrorParser.MaxUtf8Bytes / 2); + var unicodeJson = unicode.ToJsonString().Replace("\\u65E5", "日", StringComparison.OrdinalIgnoreCase); + Assert.True(unicodeJson.Length < BatchChildErrorParser.MaxUtf8Bytes); + Assert.Null(BatchChildErrorParser.Parse(unicodeJson, "search", 1)); + } +} diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5259Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5259Tests.cs new file mode 100644 index 0000000000..e2109742d2 --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5259Tests.cs @@ -0,0 +1,122 @@ +using System.Text.Json; +using CodeIndex.Cli; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [Fact] + public void RunBatch_PreservesChildBudgetErrorsAndRetryInBothModes_Issue5259() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_batch_budget_5259"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Delete.cs", "csharp", + "class Example { void Delete() { File.Delete(\"one\"); File.Delete(\"two\"); } }"); + string[][] children = + [ + ["status", "--explain", "index_complete", "--json", "--max-json-bytes", "1024"], + ["search", "File.Delete", "--json=array", "--max-json-bytes", "512", "--limit", "2"], + ]; + var input = string.Join('\n', children.Select(child => JsonSerializer.Serialize(child))) + + "\n[\"languages\",\"--format\",\"count\"]\n" + + "[\"search\",\"--limit\",\"invalid\"]\n"; + string[] retainedFields = + [ + "error_code", "category", "message", "hint", "requested_bytes", "effective_bytes", + "minimum_required_bytes", "minimum_required_bytes_known", + "minimum_required_bytes_unavailable_reason", "minimum_required_bytes_uncertain", + "minimum_required_bytes_uncertainty_reason", "retry", + ]; + + foreach (var child in children) + { + string[] args = [.. child.Skip(1), "--db", dbPath]; + int RunChild(string[] effectiveArgs) => child[0] == "status" + ? QueryCommandRunner.RunStatus(effectiveArgs, _jsonOptions) + : QueryCommandRunner.RunSearch(effectiveArgs, _jsonOptions); + var (exitCode, stdout, _) = CaptureConsole(() => JsonEnvelopeWrapper.ShouldWrap(child[0], args) + ? JsonEnvelopeWrapper.RunWrapped(child[0], args, "", _jsonOptions, RunChild) + : RunChild(args)); + Assert.Equal(CommandExitCodes.UsageError, exitCode); + using var direct = JsonDocument.Parse(stdout); + Assert.Equal(CommandErrorCodes.ResponseBudgetTooSmall, direct.RootElement.GetProperty("error_code").GetString()); + Assert.Equal("response_budget", direct.RootElement.GetProperty("category").GetString()); + Assert.True(direct.RootElement.GetProperty("minimum_required_bytes").GetInt64() > 0); + } + + foreach (var parallelism in new[] { "1", "4" }) + { + foreach (var raw in new[] { false, true }) + { + var (exitCode, stdout, stderr) = CaptureConsoleWithInput(input, + () => QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", parallelism, + .. raw ? new[] { "--include-raw-streams" } : Array.Empty()], _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Empty(stderr); + var lines = ParseJsonLines(stdout); + try + { + Assert.Equal(5, lines.Count); + for (var index = 0; index < children.Length; index++) + { + var record = lines[index].RootElement; + Assert.Equal(index + 1, record.GetProperty("line").GetInt32()); + Assert.Equal(children[index][0], record.GetProperty("command").GetString()); + Assert.Equal(CommandExitCodes.UsageError, record.GetProperty("exit_code").GetInt32()); + Assert.False(record.TryGetProperty("stdout", out _)); + Assert.False(record.TryGetProperty("stderr", out _)); + Assert.Equal(raw, record.TryGetProperty("raw_streams", out var streams)); + var error = record.GetProperty("error"); + Assert.Equal("command", error.GetProperty("scope").GetString()); + Assert.Equal(CommandErrorCodes.ResponseBudgetTooSmall, error.GetProperty("error_code").GetString()); + Assert.Equal("response_budget", error.GetProperty("category").GetString()); + Assert.Equal(index == 0 ? 1024 : 512, error.GetProperty("requested_bytes").GetInt64()); + var minimum = error.GetProperty("minimum_required_bytes").GetInt64(); + Assert.True(minimum > error.GetProperty("effective_bytes").GetInt64()); + Assert.True(error.GetProperty("minimum_required_bytes_known").GetBoolean()); + Assert.Equal("increase_max_json_bytes", error.GetProperty("retry").GetProperty("action").GetString()); + Assert.True(error.GetProperty("retry").GetProperty("recommended_bytes").GetInt64() >= minimum); + if (raw) + { + using var childOutput = JsonDocument.Parse(streams.GetProperty("stdout").GetString()!); + foreach (var field in retainedFields) + Assert.Equal(childOutput.RootElement.GetProperty(field).GetRawText(), error.GetProperty(field).GetRawText()); + } + } + Assert.Equal("ok", lines[2].RootElement.GetProperty("status").GetString()); + Assert.Equal("batch_child_usage", lines[3].RootElement.GetProperty("error").GetProperty("category").GetString()); + var summary = lines[^1].RootElement; + Assert.Equal(4, summary.GetProperty("commands_processed").GetInt32()); + Assert.Equal(3, summary.GetProperty("command_failures").GetInt32()); + Assert.Equal(stdout.Length, summary.GetProperty("output_chars").GetInt32()); + } + finally + { + foreach (var line in lines) + line.Dispose(); + } + } + + var (limitedExit, limitedOutput, limitedError) = CaptureConsoleWithInput(input, + () => QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", parallelism, + "--max-output-chars", QueryCommandRunner.BatchMinTotalOutputChars.ToString()], _jsonOptions)); + Assert.Equal(CommandExitCodes.InvalidArgument, limitedExit); + Assert.Empty(limitedError); + Assert.True(limitedOutput.Length <= QueryCommandRunner.BatchMinTotalOutputChars); + var limitedLines = ParseJsonLines(limitedOutput); + try + { + Assert.Equal("batch_output_limit", limitedLines[0].RootElement.GetProperty("error").GetProperty("category").GetString()); + Assert.True(limitedLines[^1].RootElement.GetProperty("output_limit_reached").GetBoolean()); + Assert.Equal(limitedOutput.Length, limitedLines[^1].RootElement.GetProperty("output_chars").GetInt32()); + } + finally + { + foreach (var line in limitedLines) + line.Dispose(); + } + } + } +} From 423aa1f33faee9b67e26b21c3f8cdc2ab150ab81 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 6 Sep 2026 03:00:07 +0900 Subject: [PATCH 2/2] Handle malformed Unicode in batch child errors (#5259) --- TESTING_GUIDE.md | 4 ++-- src/CodeIndex/Cli/BatchChildErrorParser.cs | 4 +++- tests/CodeIndex.Tests/BatchChildErrorParserTests.cs | 4 ++++ 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index c1d5eb37f0..e474559ecc 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -292,7 +292,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result Ctags export JSON coverage reuses one seeded database for default and `--include-generated` variants, asserts the fixed skip-reason keys sum to `skipped_count`, and keeps the missing-`files.generated` degradation in a separate legacy-schema fixture. Dry-run JSON coverage for ambiguous `.h` files locks the bounded `language_detections` entries and their stable source/confidence codes without mutating the index. Batch `--json-summary` coverage keeps side-effect-free dispatch plus successful JSON, single-row NDJSON, and JSON-looking text projection in one seeded fixture when the assertions stay distinct; include case-insensitive format values, command-specific JSON formats, and alias-injected JSON defaults so batch classification cannot drift from child parsers. Serialized-output exhaustion remains a separate boundary fixture because of its large escaped payload. Assert `result` / `results` versus raw `stdout` presence explicitly, compare `output_chars` with the actual captured stream length, and cover repeated malformed lines up to the input cap so neither syntax sniffing nor envelope overhead can bypass the transport bounds. Typed-failure coverage reuses one mixed scalar/array/schema/policy/child-failure input across serial and parallel modes, asserts stable ordered categories, and keeps timeout, caller cancellation, and explicit `--include-raw-streams` behavior in focused fixtures. Shared line-preparation/accounting coverage compares mixed blank, valid, malformed, and unsupported input across serial and parallel modes, including ordered records, first failure, counters, limits, and output characters. Output-limit drain coverage gates two active workers without sleeps, proves the batch remains pending until the held worker completes, keeps an unpublished input-limit record out of committed counters, verifies console restoration after the join, and replays only accepted-but-undispatched nonblank input records in source order before pump-buffered future input with fresh line numbers and counters on the next invocation; blocked-input preservation remains in the focused cancellation fixture. Cancellation coverage includes pre-cancelled setup, cancellation after an earlier failure and after parallel item preparation, and a blocking stdin reader that proves both prompt wake-up and preservation of an in-flight line for the next batch invocation. - Issue #5259 pairs `BatchChildErrorParserTests` with `QueryCommandRunnerBatchIssue5259Tests`: preserve direct/child E028 classification and every measured budget/retry field for status explanations and search arrays in serial/parallel batches, alongside mixed success/text failures, explicit raw-stream compatibility, parent-budget accounting, and the existing cancellation/timeout fixtures. Parser coverage must retain malformed/duplicate/type/identity rejection, UTF-8 byte and depth boundaries, unknown-minimum/reduce-size retry variants, nested envelopes, text bounds, and secret/path/control sanitization without unknown-field reflection. + Issue #5259 pairs `BatchChildErrorParserTests` with `QueryCommandRunnerBatchIssue5259Tests`: preserve direct/child E028 classification and every measured budget/retry field for status explanations and search arrays in serial/parallel batches, alongside mixed success/text failures, explicit raw-stream compatibility, parent-budget accounting, and the existing cancellation/timeout fixtures. Parser coverage must retain malformed/duplicate/type/identity rejection, unpaired Unicode surrogate escapes in values and property names, UTF-8 byte and depth boundaries, unknown-minimum/reduce-size retry variants, nested envelopes, text bounds, and secret/path/control sanitization without unknown-field reflection. Argument-validation variants that only differ by invalid scalar input share one database fixture and iterate within a fact when no per-case state or discovery identity is required. Positional `files` glob coverage shares one indexed-file fixture and iterates `*`, `?`, and recursive `**` patterns in a fact, matching the exact tokens that a quoted shell argument passes to the CLI. Excerpt focus coverage reuses one indexed fixture for line-only leading-window behavior, the focus-length dependency, and focus-column range validation; zero and non-numeric focus-column values share one indexed Markdown fixture. @@ -1467,7 +1467,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ctags export JSON coverage は1つの seeded database を既定と `--include-generated` variant で再利用し、固定された skip-reason key の合計が `skipped_count` と一致することを検証します。`files.generated` がない場合の縮退は別の legacy-schema fixture に保ってください。 曖昧な `.h` に対する dry-run JSON coverage は、index を変更せず、上限付き `language_detections` entry と安定した判定元・信頼度 code を固定します。 batch `--json-summary` coverage は、assertion を明確に保てる場合、副作用なし dispatch と成功時の JSON / 1 row NDJSON / JSON に見える text projection を1つの seeded fixture で共有し、case-insensitive な format 値、command 固有の JSON format、alias が内部追加する JSON default も含めて child parser と batch classification の drift を防いでください。serialized output の枯渇は escape を多く含む大きな payload の境界 fixture として分離します。test 側で `result` / `results` と raw `stdout` の有無を明示し、`output_chars` を実際に捕捉した stream 長と比較し、input cap までの malformed line 反復も検証して、syntax sniffing や envelope overhead が transport 上限を迂回しないようにしてください。typed failure coverage は scalar / array / schema / policy / child failure を混在させた1つの input を serial / parallel mode で共有し、安定した順序と category を検証してください。timeout、caller cancellation、明示的な `--include-raw-streams` の挙動は焦点を絞った fixture に分離します。共有 line preparation / accounting の coverage では blank、valid、malformed、unsupported input の混在を serial / parallel mode で比較し、record 順、first failure、counter、limit、output character 数を固定します。output-limit drain coverage は sleep なしの gate で active worker 2つを保持し、一方の worker が完了するまで batch が pending のままであること、未配送の input-limit record が確定済み counter に混入しないこと、join 後に console が復元されること、受理済みでも未 dispatch の nonblank input record だけが source 順で pump buffer 内の後続入力より前に戻され、次の invocation では line number と counter が新しく付け直されることを検証します。blocked input の保持は focused cancellation fixture に分離してください。cancellation coverage には事前 cancel 済み setup、先行 failure 後と parallel item 準備後の cancellation、blocking stdin reader を含め、型付き summary、迅速な wait 解除、同時に読み取った line が次の batch invocation 用に保持されることを決定的に検証します。 - Issue #5259 は `BatchChildErrorParserTests` と `QueryCommandRunnerBatchIssue5259Tests` を対にし、status 説明と search 配列の直接実行/子実行における E028 分類および全サイズ・再試行フィールドを逐次/並列 batch で維持します。成功/text 失敗の混在、生 stream の明示指定互換性、親上限の計数、既存の取消/timeout fixture も検証してください。parser は不正 JSON/重複/型/identity の拒否、UTF-8 byte/深さの境界、最小値不明/サイズ縮小の再試行、nested envelope、文字数上限、および未知フィールドを反映しない機密情報/path/制御文字の除去を検証します。 + Issue #5259 は `BatchChildErrorParserTests` と `QueryCommandRunnerBatchIssue5259Tests` を対にし、status 説明と search 配列の直接実行/子実行における E028 分類および全サイズ・再試行フィールドを逐次/並列 batch で維持します。成功/text 失敗の混在、生 stream の明示指定互換性、親上限の計数、既存の取消/timeout fixture も検証してください。parser は不正 JSON/重複/型/identity の拒否、値と property 名の不対 Unicode surrogate escape、UTF-8 byte/深さの境界、最小値不明/サイズ縮小の再試行、nested envelope、文字数上限、および未知フィールドを反映しない機密情報/path/制御文字の除去を検証します。 invalid scalar input だけが異なる argument-validation variant は、case ごとの state や discovery identity が不要なら1つの database fixture を共有し、fact 内で反復してください。 `files` の positional glob coverage は1つの indexed-file fixture を共有し、quote された shell 引数が CLI に渡す token と同じ `*`、`?`、recursive `**` pattern を fact 内で反復してください。 excerpt の focus coverage は、line-only 時の先頭側 window、focus-length の依存関係、focus-column の範囲検証を1つの indexed fixture で共有してください。focus-column の zero / non-numeric value も1つの indexed Markdown fixture を再利用してください。 diff --git a/src/CodeIndex/Cli/BatchChildErrorParser.cs b/src/CodeIndex/Cli/BatchChildErrorParser.cs index 93b52b2384..b1c2186c8d 100644 --- a/src/CodeIndex/Cli/BatchChildErrorParser.cs +++ b/src/CodeIndex/Cli/BatchChildErrorParser.cs @@ -108,8 +108,10 @@ internal static class BatchChildErrorParser } return result; } - catch (Exception ex) when (ex is JsonException or InvalidDataException) + catch (Exception ex) when (ex is JsonException or InvalidDataException or InvalidOperationException) { + // JsonDocument defers decoding strings/property names; invalid surrogate + // escapes can throw InvalidOperationException during that later access. // Malformed or over-budget output retains the exit-code fallback. return null; } diff --git a/tests/CodeIndex.Tests/BatchChildErrorParserTests.cs b/tests/CodeIndex.Tests/BatchChildErrorParserTests.cs index 5ba4752e68..5deb73310d 100644 --- a/tests/CodeIndex.Tests/BatchChildErrorParserTests.cs +++ b/tests/CodeIndex.Tests/BatchChildErrorParserTests.cs @@ -84,6 +84,10 @@ public void Parse_RejectsMalformedMismatchedAndOverBudgetOutput() ErrorJson.Replace("\"minimum_required_bytes_known\":true", "\"minimum_required_bytes_known\":\"true\""), ErrorJson.Replace("\"message\":", "\"message\":\"duplicate\",\"message\":"), ErrorJson.Replace("\"action\":", "\"action\":\"duplicate\",\"action\":"), + ErrorJson.Replace("Increase the output budget.", "\\uD800"), + ErrorJson.Replace("Retry with more bytes.", "\\uDC00"), + ErrorJson.Replace("\"status\":", "\"\\uD800\":null,\"status\":"), + ErrorJson.Replace("\"action\":", "\"\\uDC00\":null,\"action\":"), ErrorJson.Replace("\"status\":\"error\"", "\"status\":\"error\",\"unknown\":" + new string('[', BatchChildErrorParser.MaxDepth) + "0" + new string(']', BatchChildErrorParser.MaxDepth)), """{"metadata":{"exit_code":"1","error":{}}}""",