From 3fda121f2802644672536a0757caba1441b021ad Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 12 Sep 2026 17:22:21 +0900 Subject: [PATCH 1/4] Fix batch partial results and definition NDJSON (#5344) --- DEVELOPER_GUIDE.md | 42 ++++ TESTING_GUIDE.md | 2 + USER_GUIDE.md | 25 +- changelog.d/unreleased/5344.fixed.md | 17 ++ .../Cli/BatchChildPartialResultParser.cs | 102 +++++++++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.Batch.cs | 16 +- .../BatchChildPartialResultParserTests.cs | 63 +++++ .../QueryCommandRunnerBatchIssue5344Tests.cs | 216 ++++++++++++++++++ 9 files changed, 482 insertions(+), 3 deletions(-) create mode 100644 changelog.d/unreleased/5344.fixed.md create mode 100644 src/CodeIndex/Cli/BatchChildPartialResultParser.cs create mode 100644 tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs create mode 100644 tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index e26de7982..aa9ece734 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1045,6 +1045,28 @@ 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`. +`definition --json` / `--json=ndjson` / `--format json` use `results` for both +one and multiple definitions, including `--body`. Native document/array formats, +compact output, and explicit or automatic envelopes use `result`; unsupported +options and not-found failures retain their existing exit/error contracts. + +Exit 11 records can additionally contain `partial_result: true` and typed +`results` (NDJSON) or `result` (count/document/envelope). This requires a complete, +validated partial or interrupted terminal record, either last in the stream, in +the count document, or in matching envelope `metadata.stream_terminal`. Preserve +all rows, terminal/cursor, truncation, and authority fields. Consumers must still +check `status: "error"`, `exit_code: 11`, and the typed `error`; these records +still increment `command_failures` and do not establish authoritative absence. +For a partial `find --all`, resume with the retained `next_cursor` using the same +query and database; continue checking the child scan/authority flags on each page. +Raw-stream diagnostics are not needed to recover these results. Parsing uses the +existing 10,485,760-character capture cap and depth 32, rejects duplicate keys, +invalid Unicode, mixed/truncated streams and conflicting command/exit/error +identities, and never promotes arbitrary failed stdout. Other failures or partial +formats without a recognized terminal keep the typed-error fallback. Explicit +capture, cancellation, timeout and dispatch errors take precedence, and the parent +output budget applies to the entire retained payload. + 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 @@ -5506,6 +5528,26 @@ command / output format で projection を選び、成功した単一 document J は型付き `result`、NDJSON は 1 row の場合も安定した型付き `results` array として埋め込む。 成功した text command は `stdout` のまま保持する一方、すべての失敗は安定した `error_code`、 `category`、安全な `message` / `hint`、`scope` を持つ共通の型付き `error` object を使う。 +`definition --json` / `--json=ndjson` / `--format json` は、`--body` の有無や定義が +1件・複数件であるかにかかわらず `results` を使う。ネイティブの単一文書・配列形式、 +compact 出力、明示または自動の envelope は `result` を使う。未対応オプションと +定義が見つからない失敗は、既存の終了コードとエラー形式を維持する。 + +終了コード11のレコードには、追加で `partial_result: true` と型付きの `results` +(NDJSON)または `result`(count・単一文書・envelope)を含められる。ストリームの末尾、 +count 文書、または識別情報が一致する envelope の `metadata.stream_terminal` に、 +部分結果または中断を示す完全な終端レコードがあり、検証に成功した場合に限る。 +結果行、終端情報、cursor、切り詰め情報、確定性のフィールドをすべて保持する。 +利用側は引き続き `status: "error"`、`exit_code: 11`、型付き `error` を確認する必要がある。 +これらも `command_failures` に加算され、結果がないことの確定的な証拠にはならない。 +部分的な `find --all` は、保持した `next_cursor` を同じクエリとDBに渡して再開し、 +各ページの走査状態と確定性フラグを確認する。結果の取得に生ストリームの診断は不要である。 +解析は既存の10,485,760文字のcapture上限と深さ32を使い、重複キー、不正なUnicode、 +混在・途中切断ストリーム、command・終了コード・errorの矛盾を拒否する。 +任意の失敗stdoutを結果として扱わず、認識可能な終端情報のない部分形式とその他の失敗は +型付きエラーへフォールバックする。明示的なcapture上限、取消、timeout、dispatchのエラーを +優先し、保持したペイロード全体に親の出力上限を適用する。 + serial / parallel 共通の record writer は、まず `BatchChildErrorParser` で有効な子 JSON エラーを 許可フィールドへ射影する。入力は UTF-8 で 64 KiB、深さ 16 に制限し、object key の一意性と、 指定されている command / exit の一致を検証する。単独の `status: "error"` object、または diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 88d6f77a5..babf371de 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -497,6 +497,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result 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 #5282 coverage in `QueryCommandRunnerBatchIssue5282Tests.cs` keeps only literal `db schema` and `db integrity` forms batch-dispatchable, compares standalone, serial, and parallel typed results plus inherited and explicit database provenance, and preserves structured missing/corrupt errors, byte budgets, cancellation, and input order. Aliases, unknown arguments, mixed modes, and all mutating maintenance forms must remain fail-closed. 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. + Issue #5344 pairs `QueryCommandRunnerBatchIssue5344Tests` with `BatchChildPartialResultParserTests`. Share a small capped-find fixture for direct/batch row, cursor-resume, count, envelope and zero-row parity; preserve exit 11, incomplete flags, ordered mixed-batch failure accounting, optional raw streams and parent-budget fallback. Share definition fixtures across zero/one/multiple matches, body and supported output variants, retaining unsupported-option errors. Reject malformed, duplicate, invalid-Unicode, over-depth, oversized, mixed and unrelated partial captures. Run on net8/net9 with existing batch cancellation/timeout, JSON-envelope, find and #5259 E028 regressions. Nested batch integration remains console-sensitive; pure parser tests do not mutate shared state. 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. @@ -1837,6 +1838,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" 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 用に保持されることを決定的に検証します。 #5282 の coverage は `QueryCommandRunnerBatchIssue5282Tests.cs` で、literal の `db schema` と `db integrity` だけを batch dispatch 可能に保ち、standalone / serial / parallel の型付き result と継承・明示した database provenance を比較します。構造化された missing / corrupt error、byte budget、cancellation、入力順を維持し、alias、未知の引数、mode の混在、変更を伴うすべての maintenance form を fail-closed に固定してください。 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/制御文字の除去を検証します。 + Issue #5344 は `QueryCommandRunnerBatchIssue5344Tests` と `BatchChildPartialResultParserTests` を組み合わせます。小さな走査上限付きfind fixtureで、直接実行とbatchの結果行・cursor再開・count・envelope・0件結果を比較し、終了コード11、不完全性フラグ、混在batchの入力順と失敗件数、生ストリームの任意指定、親上限によるフォールバックを維持します。definitionは同じfixtureで0件・1件・複数件、本文と対応出力形式、未対応オプションのエラーを検証します。不正JSON、重複、不正Unicode、深さ・サイズ上限超過、混在出力、無関係な部分出力を拒否してください。既存のbatch取消・timeout、JSON envelope、find、#5259のE028回帰とともにnet8/net9で実行します。入れ子のbatch統合テストはconsole-sensitiveとし、純粋なparserテストは共有状態を変更しません。 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 e2eac90e7..82f30e647 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2166,6 +2166,18 @@ Successful records retain captured child `stderr`. Successful single-document JS `result`, successful NDJSON is embedded as a stable `results` array even when it contains one row, and successful text remains raw `stdout`. Failures use a typed `error` with `error_code`, `category`, sanitized `message` / `hint`, and `scope`. +Definition's default JSON is NDJSON: one or multiple definitions, including body +output, appear in `results`. Single-document formats and JSON envelopes use `result`. +Validated partial query output adds `partial_result: true` and `results` or `result` +to an exit-11 error record, retaining rows, terminal metadata and continuation cursors. +The record keeps `status: "error"`, its typed `error`, exit 11 and failure accounting; +partial output does not prove absence. For `find --all`, read the final `results` +item (or `result.metadata.stream_terminal` for envelopes, or `result` for counts), +check its scan/authority flags and pass `next_cursor` back with the same query and DB. +No `--include-raw-streams` is needed. Malformed or incomplete captures and partial +formats without a recognized terminal retain the typed-error fallback; normal +capture and parent-output limits still apply. + 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, @@ -6225,7 +6237,18 @@ stream を必要とする場合は `--json-summary` を渡します。この場 成功した record は捕捉した child `stderr` を保持します。成功した単一 document JSON は型付き `result`、成功した NDJSON は 1 row の場合も安定して `results` array に埋め込み、成功した text は raw `stdout` のまま保持します。失敗時は `error_code`、`category`、機密情報を除去した -`message` / `hint`、`scope` を持つ型付き `error` を返します。有効な子 JSON エラーの分類と +`message` / `hint`、`scope` を持つ型付き `error` を返します。 +definitionの既定JSONはNDJSONで、本文出力を含め、1件・複数件とも `results` に入ります。 +単一文書形式とJSON envelopeは `result` を使います。検証済みの部分結果は、終了コード11の +エラーレコードに `partial_result: true` と `results` または `result` を追加し、 +結果行・終端情報・再開cursorを保持します。`status: "error"`、型付き `error`、終了コード11、 +失敗件数への加算は維持され、部分結果は不存在を確定する証拠にはなりません。 +`find --all` では `results` の最後の要素(envelopeなら `result.metadata.stream_terminal`、 +countなら `result`)の走査状態と確定性を確認し、同じクエリとDBに `next_cursor` を渡して再開します。 +`--include-raw-streams` は不要です。不正・途中切断の出力や認識可能な終端情報のない部分形式は +型付きエラーへフォールバックし、通常のcapture上限と親の出力上限も適用されます。 + +有効な子 JSON エラーの分類と 文書化されたサイズ上限・再試行情報は既定で保持されます。対象は `E028_RESPONSE_BUDGET_TOO_SMALL`、 `requested_bytes`、`effective_bytes`、`minimum_required_bytes` と既知・不確実性のフラグ/理由、 `retry`(`action`、`option`、`recommended_bytes`、`maximum_effective_bytes`、`command`)です。 diff --git a/changelog.d/unreleased/5344.fixed.md b/changelog.d/unreleased/5344.fixed.md new file mode 100644 index 000000000..b911c1c62 --- /dev/null +++ b/changelog.d/unreleased/5344.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 5344 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.Batch.cs + - src/CodeIndex/Cli/BatchChildPartialResultParser.cs + - src/CodeIndex/Cli/CliFlagSchema.cs +--- + +## English + +- **Batch summaries preserve structured partial query results and definition NDJSON (#5344)** — validated exit-11 results retain rows, terminal metadata and continuation cursors alongside their error status and failure accounting. Definition JSON uses a stable `results` array for one or multiple rows, while document formats keep `result`. Malformed captures and unrelated failures retain typed errors, and parent output limits still apply. + +## 日本語 + +- **batch summaryが構造化された部分結果とdefinitionのNDJSONを保持するようになりました (#5344)** — 検証済みの終了コード11の結果は、エラー状態と失敗件数を維持しながら、結果行・終端情報・再開cursorを保持します。definitionのJSONは1件・複数件とも安定した `results` 配列になり、単一文書形式は `result` を維持します。不正な出力や無関係な失敗は型付きエラーとなり、親の出力上限も引き続き適用されます。 diff --git a/src/CodeIndex/Cli/BatchChildPartialResultParser.cs b/src/CodeIndex/Cli/BatchChildPartialResultParser.cs new file mode 100644 index 000000000..c9966ef7e --- /dev/null +++ b/src/CodeIndex/Cli/BatchChildPartialResultParser.cs @@ -0,0 +1,102 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Diagnostics; + +namespace CodeIndex.Cli; + +// Exit 11 alone is not proof that captured stdout contains a complete result contract. +internal static class BatchChildPartialResultParser +{ + internal static JsonNode? Parse(string stdout, string command, bool ndjson) + { + if (stdout.Length > JsonEnvelopeWrapper.MaxCapturedOutputChars || string.IsNullOrWhiteSpace(stdout)) + return null; + + try + { + JsonNode? ParseNode(string json) => BoundedJson.ParseNode( + json, JsonEnvelopeWrapper.MaxCapturedOutputChars * 4, QueryCommandRunner.BatchMaxJsonDepth); + + if (ndjson) + { + var rows = new JsonArray(); + using var reader = new StringReader(stdout); + while (reader.ReadLine() is { } line) + { + if (string.IsNullOrWhiteSpace(line)) + continue; + if (ParseNode(line) is not JsonObject row || !ValidRecord(row, command)) + return null; + // A terminal followed by more output is an incomplete or mixed capture. + if (rows.Count > 0 && IsTrue(rows[^1]!, "terminal_record")) + return null; + rows.Add(row); + } + return rows.Count > 0 && IsPartialTerminal(rows[^1]) ? rows : null; + } + + if (ParseNode(stdout) is not JsonObject root || !ValidRecord(root, command)) + return null; + if (root["metadata"] is JsonObject metadata) + { + if (!ValidRecord(metadata, command) + || !IsExit11(metadata["exit_code"]) + || root["results"] is not JsonArray results + || results.Any(row => row is not JsonObject item || !ValidRecord(item, command))) + return null; + return metadata["stream_terminal"] is JsonObject terminal + && ValidRecord(terminal, command) && IsPartialTerminal(terminal) ? root : null; + } + return IsPartialTerminal(root) ? root : null; + } + catch (Exception ex) when (ex is JsonException or InvalidDataException or InvalidOperationException or ArgumentException) + { + // Node materialization also detects duplicate keys and invalid Unicode escapes. + return null; + } + } + + private static bool ValidRecord(JsonObject record, string command) + { + ValidateTree(record); + return record["error"] is null && record["error_code"] is null + && (record["status"] is null || record["status"]!.GetValue() != "error") + && (record["command"] is null + || record["command"]!.GetValue() == JsonEnvelopeWrapper.CanonicalizeCommandName(command)) + && (record["exit_code"] is null || IsExit11(record["exit_code"])); + } + + private static bool IsExit11(JsonNode? value) + => value is JsonValue number && number.TryGetValue(out var exitCode) + && exitCode == CommandExitCodes.PartialResult; + + private static bool IsPartialTerminal(JsonNode? node) + => node is JsonObject && IsTrue(node, "terminal_record") + && (IsTrue(node, "partial_result") || IsTrue(node, "interrupted")) + && (IsCount(node["returned_count"]) || IsCount(node["count"])); + + private static bool IsCount(JsonNode? node) + => node is JsonValue value && value.TryGetValue(out var count) && count >= 0; + + private static bool IsTrue(JsonNode node, string name) + => node[name] is JsonValue value && value.TryGetValue(out var flag) && flag; + + private static void ValidateTree(JsonNode? node) + { + if (node is JsonObject obj) + { + foreach (var property in obj) + ValidateTree(property.Value); + } + else if (node is JsonArray array) + { + foreach (var item in array) + ValidateTree(item); + } + else if (node is JsonValue value) + { + // Force lazy string decoding before retaining any part of the capture. + value.TryGetValue(out _); + } + } +} diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index ddbca8324..b56587ebd 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 typed results/errors, preserving safe child JSON error codes and budget/retry fields, plus a final summary", PrimaryCommands = Set("batch") }, + new() { Name = "--json-summary", Description = "Batch: emit typed results/errors, retaining validated partial rows/cursors with exit 11 and safe child error/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/QueryCommandRunner.Batch.cs b/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs index 7b3cc80f8..8b8c09598 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs @@ -281,6 +281,18 @@ private static bool WriteBatchCommandRecordJson( } else { + var partialOutputKind = JsonEnvelopeWrapper.ShouldWrap(commandName, subArgs) + ? BatchOutputKind.JsonDocument : outputKind; + if (error is null + && exitCode == CommandExitCodes.PartialResult + && partialOutputKind != BatchOutputKind.Text + && CliCommandCatalog.IsBatchReadOnlyCommand(commandName, subArgs) + && BatchChildPartialResultParser.Parse( + stdout, commandName, partialOutputKind == BatchOutputKind.Ndjson) is { } partialOutput) + { + payload["partial_result"] = true; + payload[partialOutputKind == BatchOutputKind.Ndjson ? "results" : "result"] = partialOutput; + } if (error is null && (outputKind != BatchOutputKind.Text || JsonEnvelopeWrapper.ShouldWrap(commandName, subArgs)) && CliCommandCatalog.IsBatchReadOnlyCommand(commandName, subArgs)) @@ -428,6 +440,8 @@ private static BatchOutputKind ClassifyBatchOutput(string commandName, string[] { if (commandName == "goto") return BatchOutputKind.JsonDocument; + if (commandName is "definition" or "find" && JsonEnvelopeWrapper.ShouldWrap(commandName, args)) + return BatchOutputKind.JsonDocument; var jsonRequested = false; string? jsonMode = null; @@ -495,7 +509,7 @@ private static BatchOutputKind ClassifyBatchOutput(string commandName, string[] return BatchOutputKind.JsonDocument; } - if (commandName is "search" or "references" or "callers" or "callees" or "symbols" or "files" or "validate") + if (commandName is "search" or "definition" or "references" or "callers" or "callees" or "symbols" or "files" or "validate") return BatchOutputKind.Ndjson; if (commandName == "find" && HasBatchArgument(args, "--all")) return BatchOutputKind.Ndjson; diff --git a/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs b/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs new file mode 100644 index 000000000..58b959b34 --- /dev/null +++ b/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; +using CodeIndex.Cli; + +namespace CodeIndex.Tests; + +public class BatchChildPartialResultParserTests +{ + private const string Terminal = """{"terminal_record":true,"partial_result":true,"returned_count":1,"scan_complete":false,"authoritative_rows":false,"next_cursor":"opaque-cursor"}"""; + + [Fact] + public void Parse_PreservesValidatedPartialContractsAndRejectsUnrelatedOutput_Issue5344() + { + const string row = """{"path":"src/file.cs","line":1,"content":"alpha"}"""; + var stream = row + "\n" + Terminal + "\n"; + var parsed = Assert.IsType(BatchChildPartialResultParser.Parse(stream, "find", ndjson: true)); + Assert.Equal(2, parsed.Count); + Assert.Equal("opaque-cursor", parsed[^1]!["next_cursor"]!.GetValue()); + Assert.NotNull(BatchChildPartialResultParser.Parse(Terminal, "find", ndjson: true)); + Assert.NotNull(BatchChildPartialResultParser.Parse(Terminal, "find", ndjson: false)); + var envelope = "{\"metadata\":{\"command\":\"find\",\"exit_code\":11,\"stream_terminal\":" + + Terminal + "},\"results\":[" + row + "]}"; + Assert.NotNull(BatchChildPartialResultParser.Parse(envelope, "find", ndjson: false)); + Assert.NotNull(BatchChildPartialResultParser.Parse( + stream.Replace("\"partial_result\":true", "\"interrupted\":true", StringComparison.Ordinal), "search", ndjson: true)); + + foreach (var invalid in new[] + { + "", "plain text", "null", "[]", "{}", row, Terminal[..^1], + row + "\n{", stream + row, Terminal + "\n" + Terminal, + "42\n" + Terminal, "null\n" + Terminal, "[]\n" + Terminal, + row + "\n" + Terminal.Replace("true", "false", StringComparison.Ordinal), + row + "\n" + Terminal.Replace("\"partial_result\":true", "\"partial_result\":\"true\"", StringComparison.Ordinal), + Terminal.Replace("\"returned_count\":1", "\"returned_count\":-1", StringComparison.Ordinal), + Terminal.Replace("\"returned_count\":1,", "", StringComparison.Ordinal), + row + "\n" + Terminal.Replace("\"terminal_record\":true", "\"terminal_record\":true,\"terminal_record\":false", StringComparison.Ordinal), + """{"status":"error","error_code":"E022_INDEX_PARTIAL"}""" + "\n" + Terminal, + """{"command":"definition","exit_code":11}""" + "\n" + Terminal, + """{"command":"find","exit_code":1}""" + "\n" + Terminal, + """{"text":"\uD800"}""" + "\n" + Terminal, + """{"\uDC00":1}""" + "\n" + Terminal, + "{\"nested\":" + new string('[', QueryCommandRunner.BatchMaxJsonDepth) + "0" + + new string(']', QueryCommandRunner.BatchMaxJsonDepth) + "}\n" + Terminal, + }) + Assert.Null(BatchChildPartialResultParser.Parse(invalid, "find", ndjson: true)); + + foreach (var invalid in new[] + { + stream, "{}", "[]", "{\"partial_result\":true}", + envelope.Replace("\"exit_code\":11", "\"exit_code\":0", StringComparison.Ordinal), + envelope.Replace("\"command\":\"find\"", "\"command\":\"search\"", StringComparison.Ordinal), + envelope.Replace("\"exit_code\":11,", "", StringComparison.Ordinal), + envelope.Replace("\"results\":[" + row + "]", "\"results\":null", StringComparison.Ordinal), + envelope.Replace("\"exit_code\":11", "\"exit_code\":11,\"error\":{}", StringComparison.Ordinal), + envelope.Replace("\"terminal_record\":true", "\"command\":\"search\",\"terminal_record\":true", StringComparison.Ordinal), + envelope.Replace("\"stream_terminal\"", "\"unrelated\"", StringComparison.Ordinal), + }) + Assert.Null(BatchChildPartialResultParser.Parse(invalid, "find", ndjson: false)); + + var exactFit = Terminal + new string(' ', JsonEnvelopeWrapper.MaxCapturedOutputChars - Terminal.Length); + Assert.NotNull(BatchChildPartialResultParser.Parse(exactFit, "find", ndjson: false)); + Assert.Null(BatchChildPartialResultParser.Parse(exactFit + " ", "find", ndjson: false)); + } +} diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs new file mode 100644 index 000000000..a74b578a4 --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs @@ -0,0 +1,216 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +[Collection("Console sensitive")] +public class QueryCommandRunnerBatchIssue5344Tests +{ + [Fact] + public void RunBatch_PartialFindPreservesRowsCursorAndFailureAccounting_Issue5344() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_batch_partial_5344"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, "src/matches.txt", "text", "alpha\nbeta alpha\nalpha\n"); + string[] child = ["find", "alpha", "--all", "--regex", "--json", "--line-scan-limit", "1"]; + var direct = RunDirect(child, dbPath); + Assert.Equal(CommandExitCodes.PartialResult, direct.Exit); + var expected = ParseNdjson(direct.Stdout); + var terminal = expected[^1]!; + Assert.True(terminal["partial_result"]!.GetValue()); + Assert.False(terminal["scan_complete"]!.GetValue()); + Assert.False(terminal["authoritative_rows"]!.GetValue()); + Assert.Equal("line_scan_limit", terminal["truncation_reason"]!.GetValue()); + Assert.Equal("resume_with_next_cursor", terminal["continuation_action"]!.GetValue()); + + foreach (var parallelism in new[] { "1", "3" }) + { + foreach (var raw in new[] { false, true }) + { + var input = JsonSerializer.Serialize(child) + "\n[\"languages\",\"--format\",\"count\"]\n" + + "[\"search\",\"--limit\",\"invalid\",\"--json\"]\n"; + var (exit, stdout, stderr) = CaptureConsoleWithInput(input, () => QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", parallelism, + .. raw ? new[] { "--include-raw-streams" } : Array.Empty()], JsonOptions)); + Assert.Equal(CommandExitCodes.PartialResult, exit); + Assert.Empty(stderr); + var records = ParseNdjson(stdout); + Assert.Equal(4, records.Count); + var first = records[0]!; + Assert.Equal("error", first["status"]!.GetValue()); + Assert.Equal(CommandExitCodes.PartialResult, first["exit_code"]!.GetValue()); + Assert.True(first["partial_result"]!.GetValue()); + Assert.Equal(CommandErrorCodes.IndexPartial, first["error"]!["error_code"]!.GetValue()); + Assert.True(JsonNode.DeepEquals(expected, first["results"])); + Assert.Null(first["stdout"]); + Assert.Null(first["stderr"]); + Assert.Equal(raw, first["raw_streams"] is not null); + if (raw) + Assert.Equal(direct.Stdout, first["raw_streams"]!["stdout"]!.GetValue()); + Assert.Equal("ok", records[1]!["status"]!.GetValue()); + Assert.Null(records[2]!["results"]); + Assert.Null(records[2]!["result"]); + Assert.Null(records[2]!["partial_result"]); + for (var index = 0; index < 3; index++) + Assert.Equal(index + 1, records[index]!["line"]!.GetValue()); + Assert.Equal(3, records[^1]!["commands_processed"]!.GetValue()); + Assert.Equal(2, records[^1]!["command_failures"]!.GetValue()); + Assert.Equal(stdout.Length, records[^1]!["output_chars"]!.GetValue()); + + var pages = new List { first["results"]!.DeepClone() }; + var cursor = first["results"]!.AsArray()[^1]!["next_cursor"]!.GetValue(); + for (var page = 0; cursor is not null && page < 4; page++) + { + string[] resumed = [.. child, "--cursor", cursor]; + var nextDirect = RunDirect(resumed, dbPath); + var (nextExit, nextStdout, _) = CaptureConsoleWithInput(JsonSerializer.Serialize(resumed) + "\n", + () => QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", parallelism], JsonOptions)); + Assert.Equal(nextDirect.Exit, nextExit); + var nextRecord = ParseNdjson(nextStdout)[0]!; + var next = nextRecord["result"] ?? nextRecord["results"]!; + var expectedNext = JsonNode.Parse(nextDirect.Stdout)!; + RemoveTiming(expectedNext); + RemoveTiming(next); + Assert.True(JsonNode.DeepEquals(expectedNext, next)); + var nextTerminal = next["metadata"]!["stream_terminal"]!; + if (nextExit == CommandExitCodes.PartialResult) + { + Assert.False(nextTerminal["authoritative_rows"]!.GetValue()); + Assert.True(nextRecord["partial_result"]!.GetValue()); + } + pages.Add(next["results"]!.DeepClone()); + cursor = nextTerminal["next_cursor"]?.GetValue(); + } + Assert.Null(cursor); + var matches = pages.SelectMany(page => page.AsArray()) + .Where(row => row?["terminal_record"] is null) + .Select(row => row!["line"]!.GetValue()).ToArray(); + Assert.Equal(new[] { 1, 2, 3 }, matches); + } + + var (limitedExit, limitedOutput, _) = CaptureConsoleWithInput(JsonSerializer.Serialize(child) + "\n", + () => QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", parallelism, + "--max-output-chars", QueryCommandRunner.BatchMinTotalOutputChars.ToString()], JsonOptions)); + Assert.Equal(CommandExitCodes.InvalidArgument, limitedExit); + Assert.True(limitedOutput.Length <= QueryCommandRunner.BatchMinTotalOutputChars); + var limited = ParseNdjson(limitedOutput); + Assert.Equal("batch_output_limit", limited[0]!["error"]!["category"]!.GetValue()); + Assert.Equal(CommandExitCodes.PartialResult, limited[0]!["error"]!["attempted_exit_code"]!.GetValue()); + Assert.Null(limited[0]!["results"]); + Assert.True(limited[^1]!["output_limit_reached"]!.GetValue()); + Assert.Equal(limitedOutput.Length, limited[^1]!["output_chars"]!.GetValue()); + } + } + + [Fact] + public void RunBatch_PartialFindCountEnvelopeAndZeroRowsMatchDirect_Issue5344() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_batch_partial_shapes_5344"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, "src/matches.txt", "text", "alpha\nbeta\n"); + foreach (var query in new[] { "alpha", "beta" }) + foreach (var flags in new string[][] { ["--json"], ["--json", "--count"], ["--json-envelope"], ["--json-envelope", "--count"] }) + { + string[] child = ["find", query, "--all", "--regex", "--line-scan-limit", "1", .. flags]; + var direct = RunDirect(child, dbPath); + Assert.Equal(CommandExitCodes.PartialResult, direct.Exit); + var ndjson = flags.SequenceEqual(new[] { "--json" }); + var expected = ndjson ? ParseNdjson(direct.Stdout) : JsonNode.Parse(direct.Stdout)!; + RemoveTiming(expected); + foreach (var parallelism in new[] { "1", "3" }) + { + var (exit, stdout, _) = CaptureConsoleWithInput(JsonSerializer.Serialize(child) + "\n", + () => QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", parallelism], JsonOptions)); + Assert.Equal(direct.Exit, exit); + var record = ParseNdjson(stdout)[0]!; + Assert.True(record["partial_result"]!.GetValue()); + var actual = record[ndjson ? "results" : "result"]!; + RemoveTiming(actual); + Assert.True(JsonNode.DeepEquals(expected, actual)); + } + } + } + + [Fact] + public void RunBatch_DefinitionCardinalityAndOutputVariantsMatchDirect_Issue5344() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_batch_definition_5344"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, "src/first.cs", "csharp", "class Widget { }\nclass Solo { }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/second.cs", "csharp", "namespace Other { class Widget { } }\n"); + var children = new List<(string[] Args, int Exit, JsonNode? Expected, bool Ndjson)>(); + foreach (var query in new[] { "Widget", "Solo", "NoDefinition5344" }) + foreach (var flags in new string[][] + { + ["--json"], ["--json=ndjson", "--body"], ["--format", "json"], + ["--json=array", "--body"], ["--compact", "--body"], ["--format", "compact"], + ["--json-envelope", "--body"], ["--json", "--fields", "name,path"], + ["--json", "--count"], ["--format", "lsp"], ["--format", "sarif"], + }) + { + string[] child = ["definition", query, "--limit", "10", .. flags]; + var direct = RunDirect(child, dbPath); + var ndjson = flags[0] is "--json=ndjson" || flags.SequenceEqual(new[] { "--json" }) + || flags.SequenceEqual(new[] { "--format", "json" }); + var expected = string.IsNullOrWhiteSpace(direct.Stdout) ? null + : ndjson ? ParseNdjson(direct.Stdout) : JsonNode.Parse(direct.Stdout)!; + if (direct.Exit == 0 && ndjson) + Assert.Equal(query == "Widget" ? 2 : 1, expected!.AsArray().Count); + if (expected is not null) + RemoveTiming(expected); + children.Add((child, direct.Exit, expected, ndjson)); + } + var input = string.Join('\n', children.Select(child => JsonSerializer.Serialize(child.Args))) + "\n"; + foreach (var parallelism in new[] { "1", "3" }) + { + var (_, stdout, stderr) = CaptureConsoleWithInput(input, () => QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", parallelism], JsonOptions)); + Assert.Empty(stderr); + var records = ParseNdjson(stdout); + Assert.Equal(children.Count + 1, records.Count); + for (var index = 0; index < children.Count; index++) + { + var child = children[index]; + var record = records[index]!; + Assert.Equal(index + 1, record["line"]!.GetValue()); + Assert.Equal(child.Exit, record["exit_code"]!.GetValue()); + Assert.Null(record["stdout"]); + if (child.Exit != 0) + { + Assert.Contains(child.Exit, new[] { CommandExitCodes.NotFound, CommandExitCodes.UsageError }); + Assert.NotNull(record["error"]); + Assert.Null(record["results"]); + continue; + } + var actual = record[child.Ndjson ? "results" : "result"]!; + RemoveTiming(actual); + Assert.True(JsonNode.DeepEquals(child.Expected, actual), $"{string.Join(' ', child.Args)}: {actual}"); + } + } + } + + private static (int Exit, string Stdout, string Stderr) RunDirect(string[] child, string dbPath) + { + string[] args = [.. child.Skip(1), "--db=" + dbPath]; + int Run(string[] effective) => child[0] == "find" + ? QueryCommandRunner.RunFind(effective, JsonOptions) + : QueryCommandRunner.RunDefinition(effective, JsonOptions); + return CaptureConsole(() => JsonEnvelopeWrapper.ShouldWrap(child[0], args) + ? JsonEnvelopeWrapper.RunWrapped(child[0], args, "", JsonOptions, Run) + : Run(args)); + } + + private static JsonArray ParseNdjson(string stdout) + => new(stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries).Select(line => JsonNode.Parse(line)).ToArray()); + + private static void RemoveTiming(JsonNode node) + { + if (node is JsonObject obj && obj["metadata"] is JsonObject metadata) + metadata.Remove("elapsed_ms"); + } +} From bf5b23dc79e5f57474779b9481f9146089cb2470 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 12 Sep 2026 17:48:04 +0900 Subject: [PATCH 2/4] Validate partial batch captures and scoped find (#5344) --- DEVELOPER_GUIDE.md | 29 +++++--- TESTING_GUIDE.md | 2 + USER_GUIDE.md | 15 +++- changelog.d/unreleased/5344.fixed.md | 1 + .../Cli/BatchChildPartialResultParser.cs | 72 ++++++++++++++++--- src/CodeIndex/Cli/JsonEnvelopeWrapper.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.Batch.cs | 9 ++- .../BatchChildPartialResultParserTests.cs | 27 +++++-- .../QueryCommandRunnerBatchIssue5344Tests.cs | 15 +++- 9 files changed, 141 insertions(+), 31 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index aa9ece734..d0cac510a 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1051,19 +1051,25 @@ compact output, and explicit or automatic envelopes use `result`; unsupported options and not-found failures retain their existing exit/error contracts. Exit 11 records can additionally contain `partial_result: true` and typed -`results` (NDJSON) or `result` (count/document/envelope). This requires a complete, -validated partial or interrupted terminal record, either last in the stream, in -the count document, or in matching envelope `metadata.stream_terminal`. Preserve +`results` (NDJSON) or `result` (count/document/envelope). This requires a validated +partial/interrupted terminal last in the stream or in matching envelope +`metadata.stream_terminal`, or a supported partial find count document. Reconcile +terminal counts with recognized rows and count/control records; bounded envelopes +validate rendered `result_count` while retaining inner scan counts only when their +explicit truncation metadata explains a difference. Scoped regex find with origin +filters also uses NDJSON and preserves unknown-origin partial results. Preserve all rows, terminal/cursor, truncation, and authority fields. Consumers must still check `status: "error"`, `exit_code: 11`, and the typed `error`; these records still increment `command_failures` and do not establish authoritative absence. For a partial `find --all`, resume with the retained `next_cursor` using the same query and database; continue checking the child scan/authority flags on each page. +Bounded envelopes paginate with outer `metadata.next_cursor`, while +`metadata.stream_terminal` preserves the inner scan evidence. Raw-stream diagnostics are not needed to recover these results. Parsing uses the existing 10,485,760-character capture cap and depth 32, rejects duplicate keys, invalid Unicode, mixed/truncated streams and conflicting command/exit/error identities, and never promotes arbitrary failed stdout. Other failures or partial -formats without a recognized terminal keep the typed-error fallback. Explicit +formats without a recognized partial contract keep the typed-error fallback. Explicit capture, cancellation, timeout and dispatch errors take precedence, and the parent output budget applies to the entire retained payload. @@ -5534,17 +5540,22 @@ compact 出力、明示または自動の envelope は `result` を使う。未 定義が見つからない失敗は、既存の終了コードとエラー形式を維持する。 終了コード11のレコードには、追加で `partial_result: true` と型付きの `results` -(NDJSON)または `result`(count・単一文書・envelope)を含められる。ストリームの末尾、 -count 文書、または識別情報が一致する envelope の `metadata.stream_terminal` に、 -部分結果または中断を示す完全な終端レコードがあり、検証に成功した場合に限る。 +(NDJSON)または `result`(count・単一文書・envelope)を含められる。ストリームの末尾または +識別情報が一致する envelope の `metadata.stream_terminal` にある部分・中断の終端情報、 +もしくは対応するfindの部分count文書の検証に成功した場合に限る。終端件数を認識可能な結果行と +count・制御レコードに照合する。上限付きenvelopeは描画後の `result_count` を検証し、 +明示的な切り詰め情報で差を説明できる場合だけ内側の走査件数を維持する。 +スコープ付き正規表現findもoriginフィルター指定時はNDJSONとなり、判別不能なoriginによる部分結果を保持する。 結果行、終端情報、cursor、切り詰め情報、確定性のフィールドをすべて保持する。 利用側は引き続き `status: "error"`、`exit_code: 11`、型付き `error` を確認する必要がある。 これらも `command_failures` に加算され、結果がないことの確定的な証拠にはならない。 部分的な `find --all` は、保持した `next_cursor` を同じクエリとDBに渡して再開し、 -各ページの走査状態と確定性フラグを確認する。結果の取得に生ストリームの診断は不要である。 +各ページの走査状態と確定性フラグを確認する。上限付きenvelopeのページ送りは外側の +`metadata.next_cursor` を使い、`metadata.stream_terminal` は内側の走査情報として保持する。 +結果の取得に生ストリームの診断は不要である。 解析は既存の10,485,760文字のcapture上限と深さ32を使い、重複キー、不正なUnicode、 混在・途中切断ストリーム、command・終了コード・errorの矛盾を拒否する。 -任意の失敗stdoutを結果として扱わず、認識可能な終端情報のない部分形式とその他の失敗は +任意の失敗stdoutを結果として扱わず、認識可能な部分結果の契約がない形式とその他の失敗は 型付きエラーへフォールバックする。明示的なcapture上限、取消、timeout、dispatchのエラーを 優先し、保持したペイロード全体に親の出力上限を適用する。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index babf371de..51427baee 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -498,6 +498,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result Issue #5282 coverage in `QueryCommandRunnerBatchIssue5282Tests.cs` keeps only literal `db schema` and `db integrity` forms batch-dispatchable, compares standalone, serial, and parallel typed results plus inherited and explicit database provenance, and preserves structured missing/corrupt errors, byte budgets, cancellation, and input order. Aliases, unknown arguments, mixed modes, and all mutating maintenance forms must remain fail-closed. 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. Issue #5344 pairs `QueryCommandRunnerBatchIssue5344Tests` with `BatchChildPartialResultParserTests`. Share a small capped-find fixture for direct/batch row, cursor-resume, count, envelope and zero-row parity; preserve exit 11, incomplete flags, ordered mixed-batch failure accounting, optional raw streams and parent-budget fallback. Share definition fixtures across zero/one/multiple matches, body and supported output variants, retaining unsupported-option errors. Reject malformed, duplicate, invalid-Unicode, over-depth, oversized, mixed and unrelated partial captures. Run on net8/net9 with existing batch cancellation/timeout, JSON-envelope, find and #5259 E028 regressions. Nested batch integration remains console-sensitive; pure parser tests do not mutate shared state. + The #5344 partial fixture also covers scoped regex origin filters in row/count/envelope modes. Parser controls must reject missing rows and mismatched terminal/envelope counts while accepting zero-row controls and count records; explicit bounded-envelope truncation keeps its inner scan metadata. 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. @@ -1839,6 +1840,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" #5282 の coverage は `QueryCommandRunnerBatchIssue5282Tests.cs` で、literal の `db schema` と `db integrity` だけを batch dispatch 可能に保ち、standalone / serial / parallel の型付き result と継承・明示した database provenance を比較します。構造化された missing / corrupt error、byte budget、cancellation、入力順を維持し、alias、未知の引数、mode の混在、変更を伴うすべての maintenance form を fail-closed に固定してください。 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/制御文字の除去を検証します。 Issue #5344 は `QueryCommandRunnerBatchIssue5344Tests` と `BatchChildPartialResultParserTests` を組み合わせます。小さな走査上限付きfind fixtureで、直接実行とbatchの結果行・cursor再開・count・envelope・0件結果を比較し、終了コード11、不完全性フラグ、混在batchの入力順と失敗件数、生ストリームの任意指定、親上限によるフォールバックを維持します。definitionは同じfixtureで0件・1件・複数件、本文と対応出力形式、未対応オプションのエラーを検証します。不正JSON、重複、不正Unicode、深さ・サイズ上限超過、混在出力、無関係な部分出力を拒否してください。既存のbatch取消・timeout、JSON envelope、find、#5259のE028回帰とともにnet8/net9で実行します。入れ子のbatch統合テストはconsole-sensitiveとし、純粋なparserテストは共有状態を変更しません。 + #5344の部分結果fixtureは、スコープ付き正規表現のoriginフィルターも結果行・count・envelopeで検証します。parserは行の欠落、終端・envelope件数の不一致を拒否し、0件の制御レコードとcountレコードを受け付け、明示的な上限付きenvelopeの切り詰めでは内側の走査情報を保持してください。 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 82f30e647..90cd4ffdb 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2174,9 +2174,14 @@ The record keeps `status: "error"`, its typed `error`, exit 11 and failure accou partial output does not prove absence. For `find --all`, read the final `results` item (or `result.metadata.stream_terminal` for envelopes, or `result` for counts), check its scan/authority flags and pass `next_cursor` back with the same query and DB. +For bounded envelopes, use outer `metadata.next_cursor` for pagination; +`metadata.stream_terminal` retains the inner scan evidence. No `--include-raw-streams` is needed. Malformed or incomplete captures and partial -formats without a recognized terminal retain the typed-error fallback; normal -capture and parent-output limits still apply. +formats without a recognized partial contract retain the typed-error fallback; normal +capture and parent-output limits still apply. This also covers scoped regex find +with origin filters when unknown origins make its rows or counts partial. Terminal +and envelope counts are checked against retained rows so a missing row cannot be +silently skipped by following a retained cursor. Valid child JSON errors preserve their classification and documented budget/retry fields by default, including `E028_RESPONSE_BUDGET_TOO_SMALL`, `requested_bytes`, @@ -6245,8 +6250,12 @@ definitionの既定JSONはNDJSONで、本文出力を含め、1件・複数件 失敗件数への加算は維持され、部分結果は不存在を確定する証拠にはなりません。 `find --all` では `results` の最後の要素(envelopeなら `result.metadata.stream_terminal`、 countなら `result`)の走査状態と確定性を確認し、同じクエリとDBに `next_cursor` を渡して再開します。 -`--include-raw-streams` は不要です。不正・途中切断の出力や認識可能な終端情報のない部分形式は +上限付きenvelopeのページ送りには外側の `metadata.next_cursor` を使い、 +`metadata.stream_terminal` は内側の走査情報として確認してください。 +`--include-raw-streams` は不要です。不正・途中切断の出力や認識可能な部分結果の契約がない形式は 型付きエラーへフォールバックし、通常のcapture上限と親の出力上限も適用されます。 +スコープ付き正規表現findのoriginフィルターで、判別不能なoriginにより結果行やcountが部分的になる場合も対象です。 +終端とenvelopeの件数を保持した行に照合し、cursorの再開で欠落した行を黙って飛ばさないようにします。 有効な子 JSON エラーの分類と 文書化されたサイズ上限・再試行情報は既定で保持されます。対象は `E028_RESPONSE_BUDGET_TOO_SMALL`、 diff --git a/changelog.d/unreleased/5344.fixed.md b/changelog.d/unreleased/5344.fixed.md index b911c1c62..bcc3e2510 100644 --- a/changelog.d/unreleased/5344.fixed.md +++ b/changelog.d/unreleased/5344.fixed.md @@ -6,6 +6,7 @@ affected: - src/CodeIndex/Cli/QueryCommandRunner.Batch.cs - src/CodeIndex/Cli/BatchChildPartialResultParser.cs - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/JsonEnvelopeWrapper.cs --- ## English diff --git a/src/CodeIndex/Cli/BatchChildPartialResultParser.cs b/src/CodeIndex/Cli/BatchChildPartialResultParser.cs index c9966ef7e..c1cec5ef8 100644 --- a/src/CodeIndex/Cli/BatchChildPartialResultParser.cs +++ b/src/CodeIndex/Cli/BatchChildPartialResultParser.cs @@ -32,7 +32,23 @@ internal static class BatchChildPartialResultParser return null; rows.Add(row); } - return rows.Count > 0 && IsPartialTerminal(rows[^1]) ? rows : null; + if (rows.Count == 0 || !IsPartialTerminal(rows[^1])) + return null; + var resultCount = 0; + var emptyControls = 0; + foreach (var row in rows.Take(rows.Count - 1).Cast()) + { + if (IsEmptyControl(row)) + { + emptyControls++; + continue; + } + if (!IsResultRow(row, command)) + return null; + resultCount++; + } + return emptyControls <= 1 && (emptyControls == 0 || resultCount == 0) + && TerminalCount(rows[^1]!.AsObject()) == resultCount ? rows : null; } if (ParseNode(stdout) is not JsonObject root || !ValidRecord(root, command)) @@ -42,12 +58,28 @@ internal static class BatchChildPartialResultParser if (!ValidRecord(metadata, command) || !IsExit11(metadata["exit_code"]) || root["results"] is not JsonArray results + || ReadCount(metadata["result_count"]) != results.Count || results.Any(row => row is not JsonObject item || !ValidRecord(item, command))) return null; - return metadata["stream_terminal"] is JsonObject terminal - && ValidRecord(terminal, command) && IsPartialTerminal(terminal) ? root : null; + if (metadata["stream_terminal"] is JsonObject terminal) + { + if (!ValidRecord(terminal, command) || !IsPartialTerminal(terminal)) + return null; + var logicalCount = command == "find" && results.Count == 1 && results[0] is JsonObject countRow + && !IsResultRow(countRow, command) && ReadCount(countRow["count"]).HasValue + ? ReadCount(countRow["count"]) : results.Count; + if (TerminalCount(terminal) == logicalCount) + return root; + // Bounded envelopes can project/trim rows. Their result_count + // describes the capture; stream_terminal retains the inner scan. + return IsTrue(metadata, "truncated") + && ReadCount(metadata["returned_count"]) == results.Count + && TerminalCount(terminal) > logicalCount ? root : null; + } + return results.Count == 1 && results[0] is JsonObject count + && IsPartialCount(count, command) ? root : null; } - return IsPartialTerminal(root) ? root : null; + return IsPartialCount(root, command) ? root : null; } catch (Exception ex) when (ex is JsonException or InvalidDataException or InvalidOperationException or ArgumentException) { @@ -71,12 +103,36 @@ private static bool IsExit11(JsonNode? value) && exitCode == CommandExitCodes.PartialResult; private static bool IsPartialTerminal(JsonNode? node) - => node is JsonObject && IsTrue(node, "terminal_record") + => node is JsonObject terminal && IsTrue(node, "terminal_record") && (IsTrue(node, "partial_result") || IsTrue(node, "interrupted")) - && (IsCount(node["returned_count"]) || IsCount(node["count"])); + && TerminalCount(terminal).HasValue; + + private static long? TerminalCount(JsonObject terminal) + { + var count = ReadCount(terminal.ContainsKey("returned_count") ? terminal["returned_count"] : terminal["count"]); + return terminal.ContainsKey("count") && ReadCount(terminal["count"]) != count ? null : count; + } + + private static long? ReadCount(JsonNode? node) + => node is JsonValue value && value.TryGetValue(out var count) && count >= 0 ? count : null; + + private static bool IsPartialCount(JsonObject record, string command) + => command == "find" && ReadCount(record["count"]).HasValue + && (IsPartialTerminal(record) && TerminalCount(record) == ReadCount(record["count"]) + || record["terminal_record"] is null && IsTrue(record, "partial_result") + && record["authoritative_count"] is JsonValue authority + && authority.TryGetValue(out var authoritative) && !authoritative); + + private static bool IsEmptyControl(JsonObject record) + => !IsPath(record["path"]) && !IsPath(record["file"]) + && ReadCount(record["count"]) == 0 && record["results"] is JsonArray { Count: 0 }; + + private static bool IsResultRow(JsonObject record, string command) + => (IsPath(record["path"]) || IsPath(record["file"])) + && (command != "find" || ReadCount(record["line"]) > 0 && ReadCount(record["column"]) > 0); - private static bool IsCount(JsonNode? node) - => node is JsonValue value && value.TryGetValue(out var count) && count >= 0; + private static bool IsPath(JsonNode? node) + => node is JsonValue value && value.TryGetValue(out var path) && !string.IsNullOrWhiteSpace(path); private static bool IsTrue(JsonNode node, string name) => node[name] is JsonValue value && value.TryGetValue(out var flag) && flag; diff --git a/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs b/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs index 171d5d46a..b5c6628ba 100644 --- a/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs +++ b/src/CodeIndex/Cli/JsonEnvelopeWrapper.cs @@ -345,7 +345,7 @@ private static int WriteResponseSnapshotChangedError( return CommandExitCodes.UsageError; } - private static bool HasArgument(string command, string[] args, string option) + internal static bool HasArgument(string command, string[] args, string option) => ClassifyArgumentTokens(command, args) .Any(token => token.IsOption && (string.Equals(token.Value, option, StringComparison.Ordinal) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs b/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs index 8b8c09598..bf30bea36 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Batch.cs @@ -511,7 +511,14 @@ private static BatchOutputKind ClassifyBatchOutput(string commandName, string[] if (commandName is "search" or "definition" or "references" or "callers" or "callees" or "symbols" or "files" or "validate") return BatchOutputKind.Ndjson; - if (commandName == "find" && HasBatchArgument(args, "--all")) + if (commandName == "find" + && (JsonEnvelopeWrapper.HasArgument(commandName, args, "--all") + || JsonEnvelopeWrapper.HasArgument(commandName, args, "--origin") + || JsonEnvelopeWrapper.HasArgument(commandName, args, "--exclude-origin") + || JsonEnvelopeWrapper.HasArgument(commandName, args, "--result-kind") + || JsonEnvelopeWrapper.HasArgument(commandName, args, "--exclude-comments") + || JsonEnvelopeWrapper.HasArgument(commandName, args, "--exclude-strings") + || JsonEnvelopeWrapper.HasArgument(commandName, args, "--exclude-fixtures"))) return BatchOutputKind.Ndjson; return BatchOutputKind.JsonDocument; diff --git a/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs b/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs index 58b959b34..93306bbd5 100644 --- a/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs +++ b/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs @@ -10,22 +10,31 @@ public class BatchChildPartialResultParserTests [Fact] public void Parse_PreservesValidatedPartialContractsAndRejectsUnrelatedOutput_Issue5344() { - const string row = """{"path":"src/file.cs","line":1,"content":"alpha"}"""; + const string row = """{"path":"src/file.cs","line":1,"column":1,"content":"alpha"}"""; var stream = row + "\n" + Terminal + "\n"; var parsed = Assert.IsType(BatchChildPartialResultParser.Parse(stream, "find", ndjson: true)); Assert.Equal(2, parsed.Count); Assert.Equal("opaque-cursor", parsed[^1]!["next_cursor"]!.GetValue()); - Assert.NotNull(BatchChildPartialResultParser.Parse(Terminal, "find", ndjson: true)); - Assert.NotNull(BatchChildPartialResultParser.Parse(Terminal, "find", ndjson: false)); - var envelope = "{\"metadata\":{\"command\":\"find\",\"exit_code\":11,\"stream_terminal\":" + var zeroTerminal = Terminal.Replace("\"returned_count\":1", "\"returned_count\":0", StringComparison.Ordinal); + Assert.NotNull(BatchChildPartialResultParser.Parse(zeroTerminal, "find", ndjson: true)); + Assert.NotNull(BatchChildPartialResultParser.Parse("{\"count\":0,\"results\":[]}\n" + zeroTerminal, "search", ndjson: true)); + var count = Terminal.Replace("\"returned_count\":1", "\"count\":1,\"returned_count\":1", StringComparison.Ordinal); + Assert.NotNull(BatchChildPartialResultParser.Parse(count, "find", ndjson: false)); + var envelope = "{\"metadata\":{\"command\":\"find\",\"exit_code\":11,\"result_count\":1,\"stream_terminal\":" + Terminal + "},\"results\":[" + row + "]}"; Assert.NotNull(BatchChildPartialResultParser.Parse(envelope, "find", ndjson: false)); + var trimmedEnvelope = envelope.Replace(row, "", StringComparison.Ordinal) + .Replace("\"result_count\":1", "\"result_count\":0,\"returned_count\":0,\"truncated\":true", StringComparison.Ordinal); + Assert.NotNull(BatchChildPartialResultParser.Parse(trimmedEnvelope, "find", ndjson: false)); Assert.NotNull(BatchChildPartialResultParser.Parse( stream.Replace("\"partial_result\":true", "\"interrupted\":true", StringComparison.Ordinal), "search", ndjson: true)); foreach (var invalid in new[] { - "", "plain text", "null", "[]", "{}", row, Terminal[..^1], + "", "plain text", "null", "[]", "{}", row, Terminal, Terminal[..^1], + stream.Replace("\"returned_count\":1", "\"returned_count\":2", StringComparison.Ordinal), + "{\"unrelated\":true}\n" + Terminal, + "{\"count\":0,\"results\":[]}\n" + stream, row + "\n{", stream + row, Terminal + "\n" + Terminal, "42\n" + Terminal, "null\n" + Terminal, "[]\n" + Terminal, row + "\n" + Terminal.Replace("true", "false", StringComparison.Ordinal), @@ -45,7 +54,11 @@ public void Parse_PreservesValidatedPartialContractsAndRejectsUnrelatedOutput_Is foreach (var invalid in new[] { - stream, "{}", "[]", "{\"partial_result\":true}", + stream, Terminal, "{}", "[]", "{\"partial_result\":true}", + count.Replace("\"count\":1", "\"count\":2", StringComparison.Ordinal), + envelope.Replace(row, "", StringComparison.Ordinal), + envelope.Replace("\"result_count\":1", "\"result_count\":2", StringComparison.Ordinal), + envelope.Replace("\"returned_count\":1", "\"returned_count\":2", StringComparison.Ordinal), envelope.Replace("\"exit_code\":11", "\"exit_code\":0", StringComparison.Ordinal), envelope.Replace("\"command\":\"find\"", "\"command\":\"search\"", StringComparison.Ordinal), envelope.Replace("\"exit_code\":11,", "", StringComparison.Ordinal), @@ -56,7 +69,7 @@ public void Parse_PreservesValidatedPartialContractsAndRejectsUnrelatedOutput_Is }) Assert.Null(BatchChildPartialResultParser.Parse(invalid, "find", ndjson: false)); - var exactFit = Terminal + new string(' ', JsonEnvelopeWrapper.MaxCapturedOutputChars - Terminal.Length); + var exactFit = count + new string(' ', JsonEnvelopeWrapper.MaxCapturedOutputChars - count.Length); Assert.NotNull(BatchChildPartialResultParser.Parse(exactFit, "find", ndjson: false)); Assert.Null(BatchChildPartialResultParser.Parse(exactFit + " ", "find", ndjson: false)); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs index a74b578a4..ba6faf010 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs @@ -112,10 +112,21 @@ public void RunBatch_PartialFindCountEnvelopeAndZeroRowsMatchDirect_Issue5344() using var project = TestProjectHelper.CreateTempProjectScope("cdidx_batch_partial_shapes_5344"); var dbPath = TestProjectHelper.CreateProjectDb(project.Root); TestProjectHelper.InsertIndexedFile(dbPath, "src/matches.txt", "text", "alpha\nbeta\n"); + foreach (var scope in new string[][] + { + ["--all", "--line-scan-limit", "1"], + ["--path", "src/matches.txt", "--exclude-comments"], + ["--path", "src/matches.txt", "--origin", "unknown"], + ["--path", "src/matches.txt", "--exclude-origin=unknown"], + }) foreach (var query in new[] { "alpha", "beta" }) - foreach (var flags in new string[][] { ["--json"], ["--json", "--count"], ["--json-envelope"], ["--json-envelope", "--count"] }) + foreach (var flags in new string[][] + { + ["--json"], ["--json", "--count"], ["--json-envelope"], ["--json-envelope", "--count"], + ["--json", "--fields", "path", "--max-json-bytes", "6000"], + }) { - string[] child = ["find", query, "--all", "--regex", "--line-scan-limit", "1", .. flags]; + string[] child = ["find", query, "--regex", .. scope, .. flags]; var direct = RunDirect(child, dbPath); Assert.Equal(CommandExitCodes.PartialResult, direct.Exit); var ndjson = flags.SequenceEqual(new[] { "--json" }); From db8426cab79b71209a7149f1ff222723a653e6e5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 12 Sep 2026 18:05:07 +0900 Subject: [PATCH 3/4] Preserve partial batch diagnostic records (#5344) --- DEVELOPER_GUIDE.md | 6 ++- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 4 +- changelog.d/unreleased/5344.fixed.md | 4 +- .../Cli/BatchChildPartialResultParser.cs | 5 +++ .../BatchChildPartialResultParserTests.cs | 15 +++++++ .../QueryCommandRunnerBatchIssue5344Tests.cs | 44 +++++++++++++++++++ 7 files changed, 75 insertions(+), 7 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index d0cac510a..4e43d4b13 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1054,7 +1054,8 @@ Exit 11 records can additionally contain `partial_result: true` and typed `results` (NDJSON) or `result` (count/document/envelope). This requires a validated partial/interrupted terminal last in the stream or in matching envelope `metadata.stream_terminal`, or a supported partial find count document. Reconcile -terminal counts with recognized rows and count/control records; bounded envelopes +terminal counts with recognized rows and count/control records; retain `_debug` +and `profile` diagnostic objects without counting them as result rows. Bounded envelopes validate rendered `result_count` while retaining inner scan counts only when their explicit truncation metadata explains a difference. Scoped regex find with origin filters also uses NDJSON and preserves unknown-origin partial results. Preserve @@ -5542,7 +5543,8 @@ compact 出力、明示または自動の envelope は `result` を使う。未 終了コード11のレコードには、追加で `partial_result: true` と型付きの `results` (NDJSON)または `result`(count・単一文書・envelope)を含められる。ストリームの末尾または 識別情報が一致する envelope の `metadata.stream_terminal` にある部分・中断の終端情報、 -もしくは対応するfindの部分count文書の検証に成功した場合に限る。終端件数を認識可能な結果行と +もしくは対応するfindの部分count文書の検証に成功した場合に限る。`_debug` と `profile` の +診断オブジェクトは結果行の件数に数えず保持する。終端件数を認識可能な結果行と count・制御レコードに照合する。上限付きenvelopeは描画後の `result_count` を検証し、 明示的な切り詰め情報で差を説明できる場合だけ内側の走査件数を維持する。 スコープ付き正規表現findもoriginフィルター指定時はNDJSONとなり、判別不能なoriginによる部分結果を保持する。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 51427baee..e3bb429b2 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -498,7 +498,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result Issue #5282 coverage in `QueryCommandRunnerBatchIssue5282Tests.cs` keeps only literal `db schema` and `db integrity` forms batch-dispatchable, compares standalone, serial, and parallel typed results plus inherited and explicit database provenance, and preserves structured missing/corrupt errors, byte budgets, cancellation, and input order. Aliases, unknown arguments, mixed modes, and all mutating maintenance forms must remain fail-closed. 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. Issue #5344 pairs `QueryCommandRunnerBatchIssue5344Tests` with `BatchChildPartialResultParserTests`. Share a small capped-find fixture for direct/batch row, cursor-resume, count, envelope and zero-row parity; preserve exit 11, incomplete flags, ordered mixed-batch failure accounting, optional raw streams and parent-budget fallback. Share definition fixtures across zero/one/multiple matches, body and supported output variants, retaining unsupported-option errors. Reject malformed, duplicate, invalid-Unicode, over-depth, oversized, mixed and unrelated partial captures. Run on net8/net9 with existing batch cancellation/timeout, JSON-envelope, find and #5259 E028 regressions. Nested batch integration remains console-sensitive; pure parser tests do not mutate shared state. - The #5344 partial fixture also covers scoped regex origin filters in row/count/envelope modes. Parser controls must reject missing rows and mismatched terminal/envelope counts while accepting zero-row controls and count records; explicit bounded-envelope truncation keeps its inner scan metadata. + The #5344 partial fixture also covers scoped regex origin filters in row/count/envelope modes and serial/parallel `--verbose` / `--profile` diagnostics with zero or nonzero results. Parser controls must retain diagnostic objects without counting them as rows, reject missing rows and mismatched terminal/envelope counts, and accept zero-row controls and count records; explicit bounded-envelope truncation keeps its inner scan metadata. 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. @@ -1840,7 +1840,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" #5282 の coverage は `QueryCommandRunnerBatchIssue5282Tests.cs` で、literal の `db schema` と `db integrity` だけを batch dispatch 可能に保ち、standalone / serial / parallel の型付き result と継承・明示した database provenance を比較します。構造化された missing / corrupt error、byte budget、cancellation、入力順を維持し、alias、未知の引数、mode の混在、変更を伴うすべての maintenance form を fail-closed に固定してください。 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/制御文字の除去を検証します。 Issue #5344 は `QueryCommandRunnerBatchIssue5344Tests` と `BatchChildPartialResultParserTests` を組み合わせます。小さな走査上限付きfind fixtureで、直接実行とbatchの結果行・cursor再開・count・envelope・0件結果を比較し、終了コード11、不完全性フラグ、混在batchの入力順と失敗件数、生ストリームの任意指定、親上限によるフォールバックを維持します。definitionは同じfixtureで0件・1件・複数件、本文と対応出力形式、未対応オプションのエラーを検証します。不正JSON、重複、不正Unicode、深さ・サイズ上限超過、混在出力、無関係な部分出力を拒否してください。既存のbatch取消・timeout、JSON envelope、find、#5259のE028回帰とともにnet8/net9で実行します。入れ子のbatch統合テストはconsole-sensitiveとし、純粋なparserテストは共有状態を変更しません。 - #5344の部分結果fixtureは、スコープ付き正規表現のoriginフィルターも結果行・count・envelopeで検証します。parserは行の欠落、終端・envelope件数の不一致を拒否し、0件の制御レコードとcountレコードを受け付け、明示的な上限付きenvelopeの切り詰めでは内側の走査情報を保持してください。 + #5344の部分結果fixtureは、スコープ付き正規表現のoriginフィルターを結果行・count・envelopeで検証し、逐次/並列の `--verbose` / `--profile` 診断も結果が0件・1件以上の両方で確認します。parserは診断オブジェクトを結果行の件数に数えず保持し、行の欠落、終端・envelope件数の不一致を拒否し、0件の制御レコードとcountレコードを受け付け、明示的な上限付きenvelopeの切り詰めでは内側の走査情報を保持してください。 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 90cd4ffdb..ceac2d670 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2170,6 +2170,7 @@ Definition's default JSON is NDJSON: one or multiple definitions, including body output, appear in `results`. Single-document formats and JSON envelopes use `result`. Validated partial query output adds `partial_result: true` and `results` or `result` to an exit-11 error record, retaining rows, terminal metadata and continuation cursors. +NDJSON also retains `--verbose` / `--profile` diagnostic records without counting them as result rows. The record keeps `status: "error"`, its typed `error`, exit 11 and failure accounting; partial output does not prove absence. For `find --all`, read the final `results` item (or `result.metadata.stream_terminal` for envelopes, or `result` for counts), @@ -6246,7 +6247,8 @@ raw `stdout` のまま保持します。失敗時は `error_code`、`category` definitionの既定JSONはNDJSONで、本文出力を含め、1件・複数件とも `results` に入ります。 単一文書形式とJSON envelopeは `result` を使います。検証済みの部分結果は、終了コード11の エラーレコードに `partial_result: true` と `results` または `result` を追加し、 -結果行・終端情報・再開cursorを保持します。`status: "error"`、型付き `error`、終了コード11、 +結果行・終端情報・再開cursorを保持します。NDJSONでは `--verbose` / `--profile` の +診断レコードも結果行の件数に数えず保持します。`status: "error"`、型付き `error`、終了コード11、 失敗件数への加算は維持され、部分結果は不存在を確定する証拠にはなりません。 `find --all` では `results` の最後の要素(envelopeなら `result.metadata.stream_terminal`、 countなら `result`)の走査状態と確定性を確認し、同じクエリとDBに `next_cursor` を渡して再開します。 diff --git a/changelog.d/unreleased/5344.fixed.md b/changelog.d/unreleased/5344.fixed.md index bcc3e2510..6ae09ca8d 100644 --- a/changelog.d/unreleased/5344.fixed.md +++ b/changelog.d/unreleased/5344.fixed.md @@ -11,8 +11,8 @@ affected: ## English -- **Batch summaries preserve structured partial query results and definition NDJSON (#5344)** — validated exit-11 results retain rows, terminal metadata and continuation cursors alongside their error status and failure accounting. Definition JSON uses a stable `results` array for one or multiple rows, while document formats keep `result`. Malformed captures and unrelated failures retain typed errors, and parent output limits still apply. +- **Batch summaries preserve structured partial query results and definition NDJSON (#5344)** — validated exit-11 results retain rows, terminal metadata, continuation cursors and diagnostic records alongside their error status and failure accounting. Definition JSON uses a stable `results` array for one or multiple rows, while document formats keep `result`. Malformed captures and unrelated failures retain typed errors, and parent output limits still apply. ## 日本語 -- **batch summaryが構造化された部分結果とdefinitionのNDJSONを保持するようになりました (#5344)** — 検証済みの終了コード11の結果は、エラー状態と失敗件数を維持しながら、結果行・終端情報・再開cursorを保持します。definitionのJSONは1件・複数件とも安定した `results` 配列になり、単一文書形式は `result` を維持します。不正な出力や無関係な失敗は型付きエラーとなり、親の出力上限も引き続き適用されます。 +- **batch summaryが構造化された部分結果とdefinitionのNDJSONを保持するようになりました (#5344)** — 検証済みの終了コード11の結果は、エラー状態と失敗件数を維持しながら、結果行・終端情報・再開cursor・診断レコードを保持します。definitionのJSONは1件・複数件とも安定した `results` 配列になり、単一文書形式は `result` を維持します。不正な出力や無関係な失敗は型付きエラーとなり、親の出力上限も引き続き適用されます。 diff --git a/src/CodeIndex/Cli/BatchChildPartialResultParser.cs b/src/CodeIndex/Cli/BatchChildPartialResultParser.cs index c1cec5ef8..93b3a88aa 100644 --- a/src/CodeIndex/Cli/BatchChildPartialResultParser.cs +++ b/src/CodeIndex/Cli/BatchChildPartialResultParser.cs @@ -38,6 +38,8 @@ internal static class BatchChildPartialResultParser var emptyControls = 0; foreach (var row in rows.Take(rows.Count - 1).Cast()) { + if (IsDiagnosticControl(row)) + continue; if (IsEmptyControl(row)) { emptyControls++; @@ -127,6 +129,9 @@ private static bool IsEmptyControl(JsonObject record) => !IsPath(record["path"]) && !IsPath(record["file"]) && ReadCount(record["count"]) == 0 && record["results"] is JsonArray { Count: 0 }; + private static bool IsDiagnosticControl(JsonObject record) + => record.Count == 1 && (record["_debug"] is JsonObject || record["profile"] is JsonObject); + private static bool IsResultRow(JsonObject record, string command) => (IsPath(record["path"]) || IsPath(record["file"])) && (command != "find" || ReadCount(record["line"]) > 0 && ReadCount(record["column"]) > 0); diff --git a/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs b/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs index 93306bbd5..db0084d5d 100644 --- a/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs +++ b/tests/CodeIndex.Tests/BatchChildPartialResultParserTests.cs @@ -15,8 +15,17 @@ public void Parse_PreservesValidatedPartialContractsAndRejectsUnrelatedOutput_Is var parsed = Assert.IsType(BatchChildPartialResultParser.Parse(stream, "find", ndjson: true)); Assert.Equal(2, parsed.Count); Assert.Equal("opaque-cursor", parsed[^1]!["next_cursor"]!.GetValue()); + const string debug = """{"_debug":{"sql_statement_count":1,"elapsed_ms":0.1,"rows_scanned":1,"phases":[{"name":"sql_1","elapsed_ms":0.1,"rows_scanned":1}]}}"""; + const string profile = """{"profile":{"phases":[{"name":"sql_1","elapsed_ms":0.1,"rows_scanned":1}],"query_plan":[],"queries":[],"sql_text_limit_chars":200}}"""; + var diagnostics = debug + "\n" + profile + "\n"; + var diagnosticStream = row + "\n" + diagnostics + Terminal; + var diagnosticRows = Assert.IsType(BatchChildPartialResultParser.Parse(diagnosticStream, "find", ndjson: true)); + Assert.Equal(4, diagnosticRows.Count); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(debug), diagnosticRows[1])); + Assert.True(JsonNode.DeepEquals(JsonNode.Parse(profile), diagnosticRows[2])); var zeroTerminal = Terminal.Replace("\"returned_count\":1", "\"returned_count\":0", StringComparison.Ordinal); Assert.NotNull(BatchChildPartialResultParser.Parse(zeroTerminal, "find", ndjson: true)); + Assert.NotNull(BatchChildPartialResultParser.Parse(diagnostics + zeroTerminal, "find", ndjson: true)); Assert.NotNull(BatchChildPartialResultParser.Parse("{\"count\":0,\"results\":[]}\n" + zeroTerminal, "search", ndjson: true)); var count = Terminal.Replace("\"returned_count\":1", "\"count\":1,\"returned_count\":1", StringComparison.Ordinal); Assert.NotNull(BatchChildPartialResultParser.Parse(count, "find", ndjson: false)); @@ -33,6 +42,12 @@ public void Parse_PreservesValidatedPartialContractsAndRejectsUnrelatedOutput_Is { "", "plain text", "null", "[]", "{}", row, Terminal, Terminal[..^1], stream.Replace("\"returned_count\":1", "\"returned_count\":2", StringComparison.Ordinal), + diagnostics + Terminal, + diagnosticStream.Replace("\"returned_count\":1", "\"returned_count\":2", StringComparison.Ordinal), + row + "\n{\"_debug\":true}\n" + Terminal, + row + "\n{\"profile\":[]}\n" + Terminal, + row + "\n{\"profile\":null}\n" + Terminal, + row + "\n{\"_debug\":{},\"unrelated\":true}\n" + Terminal, "{\"unrelated\":true}\n" + Terminal, "{\"count\":0,\"results\":[]}\n" + stream, row + "\n{", stream + row, Terminal + "\n" + Terminal, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs index ba6faf010..c48015344 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs @@ -91,6 +91,50 @@ public void RunBatch_PartialFindPreservesRowsCursorAndFailureAccounting_Issue534 Assert.Equal(new[] { 1, 2, 3 }, matches); } + foreach (var query in new[] { "alpha", "beta" }) + foreach (var diagnosticFlags in new string[][] { ["--verbose"], ["--profile"], ["--verbose", "--profile"] }) + { + string[] diagnosticChild = ["find", query, .. child.Skip(2), .. diagnosticFlags]; + var diagnosticDirect = RunDirect(diagnosticChild, dbPath); + Assert.Equal(CommandExitCodes.PartialResult, diagnosticDirect.Exit); + var expectedDiagnostics = ParseNdjson(diagnosticDirect.Stdout); + foreach (var flag in diagnosticFlags) + Assert.Single(expectedDiagnostics.Where(row => row?[flag == "--verbose" ? "_debug" : "profile"] is JsonObject)); + var (exit, stdout, _) = CaptureConsoleWithInput(JsonSerializer.Serialize(diagnosticChild) + "\n", + () => QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", parallelism], JsonOptions)); + Assert.Equal(CommandExitCodes.PartialResult, exit); + var records = ParseNdjson(stdout); + var record = records[0]!; + Assert.Equal("error", record["status"]!.GetValue()); + Assert.True(record["partial_result"]!.GetValue()); + var actualDiagnostics = Assert.IsType(record["results"]); + // Batch context reuse changes SQL counts and timings, but not result rows or terminals. + foreach (var flag in diagnosticFlags) + { + var key = flag == "--verbose" ? "_debug" : "profile"; + var control = Assert.Single(actualDiagnostics.Where(row => row?[key] is JsonObject))!; + Assert.Single(control.AsObject()); + var diagnostic = control[key]!; + var phases = Assert.IsType(diagnostic["phases"]); + if (flag == "--verbose") + Assert.Equal(phases.Count, diagnostic["sql_statement_count"]!.GetValue()); + else + { + Assert.IsType(diagnostic["query_plan"]); + Assert.IsType(diagnostic["queries"]); + } + } + static JsonArray WithoutDiagnostics(JsonArray items) => new(items + .Where(item => item?["_debug"] is null && item?["profile"] is null) + .Select(item => item!.DeepClone()).ToArray()); + Assert.True(JsonNode.DeepEquals(WithoutDiagnostics(expectedDiagnostics), WithoutDiagnostics(actualDiagnostics)), + $"{string.Join(' ', diagnosticChild)}: {actualDiagnostics}"); + Assert.Null(record["raw_streams"]); + Assert.Null(record["stdout"]); + Assert.Equal(1, records[^1]!["command_failures"]!.GetValue()); + } + var (limitedExit, limitedOutput, _) = CaptureConsoleWithInput(JsonSerializer.Serialize(child) + "\n", () => QueryCommandRunner.RunBatch( ["--db", dbPath, "--json-summary", "--parallel", parallelism, From 61a807347513b4be72a1ac43669af7a4c576b4b4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 12 Sep 2026 18:54:23 +0900 Subject: [PATCH 4/4] Fix batch regression test whitespace for CI (#5344) --- .../QueryCommandRunnerBatchIssue5344Tests.cs | 156 +++++++++--------- 1 file changed, 78 insertions(+), 78 deletions(-) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs index c48015344..26bea17f5 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerBatchIssue5344Tests.cs @@ -92,48 +92,48 @@ public void RunBatch_PartialFindPreservesRowsCursorAndFailureAccounting_Issue534 } foreach (var query in new[] { "alpha", "beta" }) - foreach (var diagnosticFlags in new string[][] { ["--verbose"], ["--profile"], ["--verbose", "--profile"] }) - { - string[] diagnosticChild = ["find", query, .. child.Skip(2), .. diagnosticFlags]; - var diagnosticDirect = RunDirect(diagnosticChild, dbPath); - Assert.Equal(CommandExitCodes.PartialResult, diagnosticDirect.Exit); - var expectedDiagnostics = ParseNdjson(diagnosticDirect.Stdout); - foreach (var flag in diagnosticFlags) - Assert.Single(expectedDiagnostics.Where(row => row?[flag == "--verbose" ? "_debug" : "profile"] is JsonObject)); - var (exit, stdout, _) = CaptureConsoleWithInput(JsonSerializer.Serialize(diagnosticChild) + "\n", - () => QueryCommandRunner.RunBatch( - ["--db", dbPath, "--json-summary", "--parallel", parallelism], JsonOptions)); - Assert.Equal(CommandExitCodes.PartialResult, exit); - var records = ParseNdjson(stdout); - var record = records[0]!; - Assert.Equal("error", record["status"]!.GetValue()); - Assert.True(record["partial_result"]!.GetValue()); - var actualDiagnostics = Assert.IsType(record["results"]); - // Batch context reuse changes SQL counts and timings, but not result rows or terminals. - foreach (var flag in diagnosticFlags) + foreach (var diagnosticFlags in new string[][] { ["--verbose"], ["--profile"], ["--verbose", "--profile"] }) { - var key = flag == "--verbose" ? "_debug" : "profile"; - var control = Assert.Single(actualDiagnostics.Where(row => row?[key] is JsonObject))!; - Assert.Single(control.AsObject()); - var diagnostic = control[key]!; - var phases = Assert.IsType(diagnostic["phases"]); - if (flag == "--verbose") - Assert.Equal(phases.Count, diagnostic["sql_statement_count"]!.GetValue()); - else + string[] diagnosticChild = ["find", query, .. child.Skip(2), .. diagnosticFlags]; + var diagnosticDirect = RunDirect(diagnosticChild, dbPath); + Assert.Equal(CommandExitCodes.PartialResult, diagnosticDirect.Exit); + var expectedDiagnostics = ParseNdjson(diagnosticDirect.Stdout); + foreach (var flag in diagnosticFlags) + Assert.Single(expectedDiagnostics.Where(row => row?[flag == "--verbose" ? "_debug" : "profile"] is JsonObject)); + var (exit, stdout, _) = CaptureConsoleWithInput(JsonSerializer.Serialize(diagnosticChild) + "\n", + () => QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", parallelism], JsonOptions)); + Assert.Equal(CommandExitCodes.PartialResult, exit); + var records = ParseNdjson(stdout); + var record = records[0]!; + Assert.Equal("error", record["status"]!.GetValue()); + Assert.True(record["partial_result"]!.GetValue()); + var actualDiagnostics = Assert.IsType(record["results"]); + // Batch context reuse changes SQL counts and timings, but not result rows or terminals. + foreach (var flag in diagnosticFlags) { - Assert.IsType(diagnostic["query_plan"]); - Assert.IsType(diagnostic["queries"]); + var key = flag == "--verbose" ? "_debug" : "profile"; + var control = Assert.Single(actualDiagnostics.Where(row => row?[key] is JsonObject))!; + Assert.Single(control.AsObject()); + var diagnostic = control[key]!; + var phases = Assert.IsType(diagnostic["phases"]); + if (flag == "--verbose") + Assert.Equal(phases.Count, diagnostic["sql_statement_count"]!.GetValue()); + else + { + Assert.IsType(diagnostic["query_plan"]); + Assert.IsType(diagnostic["queries"]); + } } + static JsonArray WithoutDiagnostics(JsonArray items) => new(items + .Where(item => item?["_debug"] is null && item?["profile"] is null) + .Select(item => item!.DeepClone()).ToArray()); + Assert.True(JsonNode.DeepEquals(WithoutDiagnostics(expectedDiagnostics), WithoutDiagnostics(actualDiagnostics)), + $"{string.Join(' ', diagnosticChild)}: {actualDiagnostics}"); + Assert.Null(record["raw_streams"]); + Assert.Null(record["stdout"]); + Assert.Equal(1, records[^1]!["command_failures"]!.GetValue()); } - static JsonArray WithoutDiagnostics(JsonArray items) => new(items - .Where(item => item?["_debug"] is null && item?["profile"] is null) - .Select(item => item!.DeepClone()).ToArray()); - Assert.True(JsonNode.DeepEquals(WithoutDiagnostics(expectedDiagnostics), WithoutDiagnostics(actualDiagnostics)), - $"{string.Join(' ', diagnosticChild)}: {actualDiagnostics}"); - Assert.Null(record["raw_streams"]); - Assert.Null(record["stdout"]); - Assert.Equal(1, records[^1]!["command_failures"]!.GetValue()); - } var (limitedExit, limitedOutput, _) = CaptureConsoleWithInput(JsonSerializer.Serialize(child) + "\n", () => QueryCommandRunner.RunBatch( @@ -163,32 +163,32 @@ public void RunBatch_PartialFindCountEnvelopeAndZeroRowsMatchDirect_Issue5344() ["--path", "src/matches.txt", "--origin", "unknown"], ["--path", "src/matches.txt", "--exclude-origin=unknown"], }) - foreach (var query in new[] { "alpha", "beta" }) - foreach (var flags in new string[][] - { + foreach (var query in new[] { "alpha", "beta" }) + foreach (var flags in new string[][] + { ["--json"], ["--json", "--count"], ["--json-envelope"], ["--json-envelope", "--count"], ["--json", "--fields", "path", "--max-json-bytes", "6000"], - }) - { - string[] child = ["find", query, "--regex", .. scope, .. flags]; - var direct = RunDirect(child, dbPath); - Assert.Equal(CommandExitCodes.PartialResult, direct.Exit); - var ndjson = flags.SequenceEqual(new[] { "--json" }); - var expected = ndjson ? ParseNdjson(direct.Stdout) : JsonNode.Parse(direct.Stdout)!; - RemoveTiming(expected); - foreach (var parallelism in new[] { "1", "3" }) - { - var (exit, stdout, _) = CaptureConsoleWithInput(JsonSerializer.Serialize(child) + "\n", - () => QueryCommandRunner.RunBatch( - ["--db", dbPath, "--json-summary", "--parallel", parallelism], JsonOptions)); - Assert.Equal(direct.Exit, exit); - var record = ParseNdjson(stdout)[0]!; - Assert.True(record["partial_result"]!.GetValue()); - var actual = record[ndjson ? "results" : "result"]!; - RemoveTiming(actual); - Assert.True(JsonNode.DeepEquals(expected, actual)); - } - } + }) + { + string[] child = ["find", query, "--regex", .. scope, .. flags]; + var direct = RunDirect(child, dbPath); + Assert.Equal(CommandExitCodes.PartialResult, direct.Exit); + var ndjson = flags.SequenceEqual(new[] { "--json" }); + var expected = ndjson ? ParseNdjson(direct.Stdout) : JsonNode.Parse(direct.Stdout)!; + RemoveTiming(expected); + foreach (var parallelism in new[] { "1", "3" }) + { + var (exit, stdout, _) = CaptureConsoleWithInput(JsonSerializer.Serialize(child) + "\n", + () => QueryCommandRunner.RunBatch( + ["--db", dbPath, "--json-summary", "--parallel", parallelism], JsonOptions)); + Assert.Equal(direct.Exit, exit); + var record = ParseNdjson(stdout)[0]!; + Assert.True(record["partial_result"]!.GetValue()); + var actual = record[ndjson ? "results" : "result"]!; + RemoveTiming(actual); + Assert.True(JsonNode.DeepEquals(expected, actual)); + } + } } [Fact] @@ -200,26 +200,26 @@ public void RunBatch_DefinitionCardinalityAndOutputVariantsMatchDirect_Issue5344 TestProjectHelper.InsertIndexedFile(dbPath, "src/second.cs", "csharp", "namespace Other { class Widget { } }\n"); var children = new List<(string[] Args, int Exit, JsonNode? Expected, bool Ndjson)>(); foreach (var query in new[] { "Widget", "Solo", "NoDefinition5344" }) - foreach (var flags in new string[][] - { + foreach (var flags in new string[][] + { ["--json"], ["--json=ndjson", "--body"], ["--format", "json"], ["--json=array", "--body"], ["--compact", "--body"], ["--format", "compact"], ["--json-envelope", "--body"], ["--json", "--fields", "name,path"], ["--json", "--count"], ["--format", "lsp"], ["--format", "sarif"], - }) - { - string[] child = ["definition", query, "--limit", "10", .. flags]; - var direct = RunDirect(child, dbPath); - var ndjson = flags[0] is "--json=ndjson" || flags.SequenceEqual(new[] { "--json" }) - || flags.SequenceEqual(new[] { "--format", "json" }); - var expected = string.IsNullOrWhiteSpace(direct.Stdout) ? null - : ndjson ? ParseNdjson(direct.Stdout) : JsonNode.Parse(direct.Stdout)!; - if (direct.Exit == 0 && ndjson) - Assert.Equal(query == "Widget" ? 2 : 1, expected!.AsArray().Count); - if (expected is not null) - RemoveTiming(expected); - children.Add((child, direct.Exit, expected, ndjson)); - } + }) + { + string[] child = ["definition", query, "--limit", "10", .. flags]; + var direct = RunDirect(child, dbPath); + var ndjson = flags[0] is "--json=ndjson" || flags.SequenceEqual(new[] { "--json" }) + || flags.SequenceEqual(new[] { "--format", "json" }); + var expected = string.IsNullOrWhiteSpace(direct.Stdout) ? null + : ndjson ? ParseNdjson(direct.Stdout) : JsonNode.Parse(direct.Stdout)!; + if (direct.Exit == 0 && ndjson) + Assert.Equal(query == "Widget" ? 2 : 1, expected!.AsArray().Count); + if (expected is not null) + RemoveTiming(expected); + children.Add((child, direct.Exit, expected, ndjson)); + } var input = string.Join('\n', children.Select(child => JsonSerializer.Serialize(child.Args))) + "\n"; foreach (var parallelism in new[] { "1", "3" }) {