From e8ba41325727f18c5b1c4317cb69bdead39be4e6 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 17:19:45 +0900 Subject: [PATCH 1/4] Fix count-mode output validation (#4878) --- TESTING_GUIDE.md | 2 + USER_GUIDE.md | 15 ++ changelog.d/unreleased/4878.fixed.md | 21 ++ .../ProgramRunner.OutputFormatValidation.cs | 27 ++- ...eryCommandRunner.ArgumentParser.General.cs | 10 +- ...eryCommandRunner.ArgumentParser.Results.cs | 2 +- .../Cli/QueryCommandRunner.ArgumentParser.cs | 23 ++ tests/CodeIndex.Tests/ProgramRunnerTests.cs | 201 ++++++++++++++++++ 8 files changed, 290 insertions(+), 11 deletions(-) create mode 100644 changelog.d/unreleased/4878.fixed.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 74c1f2af2..8d9e93568 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -185,6 +185,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs` keep command help, runtime value validation, and each generated shell's option/value catalog aligned with `CliFlagSchema`, and check every Fish command scope against the shared per-command completion projection. When an accepted value or alias changes, assert its registry normalization plus command usage, runtime validation, and Bash / Zsh / Fish / PowerShell completion visibility; add the value to the schema instead of weakening this parity contract. `ProgramRunnerTests.cs` enumerates `CliCommandCatalog.CommandSubcommands` and requires every valid nested verb to resolve to verb-specific usage with an example. It also pins destructive index confirmation and aggregate dependency-filter help, plus the read-only GitHub duplicate-preflight boundary for suggestion exports. Installer cancellation coverage must wait until the PID file contains a complete positive integer before cancelling, using a bounded dedicated waiter instead of a fixed-delay timer so parallel load cannot expose a redirection-created empty file. + Count-mode output coverage in `ProgramRunnerTests.cs` reuses one seeded search database across human text, JSON object, JSON envelope, incompatible format/stream, option-order, alias, and repeated-flag cases. Keep bare `--count` script output, `--format count` JSON output, and the rightmost repeated `--format` rule distinct while asserting that explicit incompatible formats never fall through to a count object. Global-tool-log home-shorthand coverage resolves the normalized first candidate through the test-only no-write-probe seam, so sandbox permissions cannot turn an expansion assertion into a writability-fallback assertion. Grouped-search limit coverage must seed more matches and files than the returned page, then assert query-wide matched/group/file totals separately from grouped, emitted, and omitted row counts so a bounded page cannot report itself as complete. `goto --all` coverage shares one 63-definition fixture across the default bare LSP array and the plain `--json` argument path; it must assert complete deterministic output without a limit and retain a separate explicit-limit assertion in the same setup. @@ -1109,6 +1110,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` は command help、runtime value validation、各生成 shell の option / value 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。受理値または alias を変更するときは、registry normalization に加えて command usage、runtime validation、Bash / Zsh / Fish / PowerShell completion への露出を検証し、この parity 契約を弱めず schema に値を追加してください。 `ProgramRunnerTests.cs` は `CliCommandCatalog.CommandSubcommands` を列挙し、すべての有効な nested verb が例を含む verb 固有 usage に解決されることを必須とします。さらに destructive な index confirmation、aggregate dependency filter help、suggestion export の read-only GitHub duplicate-preflight 境界を固定します。installer cancellation coverage は固定時間タイマーではなく上限付きの専用 waiter を使い、PID ファイルが完全な正の整数を含むまで待ってから cancel してください。これにより並列負荷下でも redirection によって作成された空ファイルを読みません。 + `ProgramRunnerTests.cs` の count-mode output coverage は、human text、JSON object、JSON envelope、非互換 format / stream、option 順序、alias、flag 反復の各 case で 1 つの seeded search database を再利用します。bare `--count` の script 出力、`--format count` の JSON 出力、反復した `--format` の右端優先規則を区別して維持しつつ、明示した非互換 format が count object へ fall through しないことを検証してください。 global tool log の home shorthand coverage は、test 専用の write probe なし seam から正規化済みの先頭 candidate を解決します。これにより sandbox permission の影響で、展開の assertion が writability fallback の assertion に変わることを防ぎます。 grouped search の limit coverage では、返却 page より多い match と file を seed し、query 全体の match/group/file 総数と grouped、emitted、omitted row 数を別々に検証して、上限付き page が完了済みと報告できないようにしてください。 `goto --all` の coverage は、既定の bare LSP array と plain `--json` 引数経路で 1 つの 63 定義 fixture を共有し、limit 未指定時の完全かつ決定的な出力と、同じ setup 内での明示 limit の維持を検証してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 326243213..6ca8817eb 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -356,6 +356,14 @@ Output modifiers are validated as one contract: `--json` is rejected with non-JSON formats such as `csv`, `tsv`, and `qf`, and `--pretty` is rejected with `--json=ndjson` instead of being silently ignored. Use `--json=array --pretty` when an indented search result is required. +Count mode follows the same contract. Bare `--count` preserves the +newline-terminated numeric value used by scripts, while `--count --json`, +`--format count`, and count output wrapped by `--json-envelope` use one JSON +count object. Explicit `--json=array` / `--json=ndjson` streams are incompatible +with count mode, and `--count` accepts only `--format text`, `json`, or `count`; +formats such as `compact`, `grouped`, `csv`, `tsv`, `lsp`, `qf`, and `sarif` +return a usage error instead of being silently replaced. When `--format` is +repeated, its existing rightmost-value-wins rule still applies. `search --named-query =` can be repeated to run an ad hoc grouped batch with the same filters and snippet bounds. Named batches emit one grouped JSON document, and `--format compact` keeps the per-result @@ -3619,6 +3627,13 @@ output modifier は 1 つの contract として検証されます。`--json` と などの非 JSON format の組み合わせ、および `--pretty` と `--json=ndjson` の組み合わせは、 黙って無視せず usage error として拒否します。indent 済みの search result が必要な場合は `--json=array --pretty` を使ってください。 +count mode も同じ contract に従います。bare `--count` は script が利用する改行終端の +数値を維持し、`--count --json`、`--format count`、`--json-envelope` で包んだ count +出力は 1 つの JSON count object を使います。明示的な `--json=array` / +`--json=ndjson` stream は count mode と両立せず、`--count` と組み合わせられる +`--format` は `text`、`json`、`count` だけです。`compact`、`grouped`、`csv`、`tsv`、 +`lsp`、`qf`、`sarif` などは黙って count output に置き換えず usage error を返します。 +`--format` を繰り返した場合は、既存どおり右端の値を優先します。 `search --named-query =` は繰り返し指定でき、同じ filter と snippet 上限で ad hoc な grouped batch を実行します。名前付き batch は 1 つの grouped JSON document を 出力し、`--format compact` でも各 result の `CompactSearchResult` snippet / highlight diff --git a/changelog.d/unreleased/4878.fixed.md b/changelog.d/unreleased/4878.fixed.md new file mode 100644 index 000000000..5b84062c3 --- /dev/null +++ b/changelog.d/unreleased/4878.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 4878 +affected: + - src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs + - src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs + - tests/CodeIndex.Tests/ProgramRunnerTests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Count-mode output formats are now validated consistently (#4878)** — `--count` and `--format count` now share output-mode validation, explicit JSON array/NDJSON streams and incompatible formats fail with a usage error instead of silently replacing the requested shape, and repeated `--format` options retain the documented rightmost-value-wins behavior. + +## 日本語 + +- **count mode の出力形式を一貫して検証するようになりました (#4878)** — `--count` と `--format count` が同じ output-mode 検証を使い、明示的な JSON array / NDJSON stream や非互換 format は要求した形式を黙って置き換えず usage error となり、反復した `--format` は既存の右端優先規則を維持します。 diff --git a/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs b/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs index 84b57b11d..3e963a0d1 100644 --- a/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs +++ b/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs @@ -26,6 +26,7 @@ private static bool TryValidateOutputFormatOptions( var jsonRequested = false; var prettyRequested = false; var resultsOnlyRequested = false; + var countFlagRequested = false; var usesSingleDocumentJsonMode = false; var hasExplicitPrettyJsonOutput = HasExplicitPrettyJsonOutputSelection(args); @@ -36,6 +37,9 @@ private static bool TryValidateOutputFormatOptions( break; var tokenRole = GetQueryCommandTokenRole(args, i); + if (tokenRole != QueryCommandTokenRole.CommandOptionValue && arg == "--count") + countFlagRequested = true; + if (tokenRole != QueryCommandTokenRole.CommandOptionValue && (arg == "--count" || arg == "--summary-only" @@ -96,9 +100,23 @@ private static bool TryValidateOutputFormatOptions( } } + var countModeRequested = countFlagRequested + || string.Equals(outputFormat, "count", StringComparison.Ordinal); + if (countModeRequested) + usesSingleDocumentJsonMode = true; + if (ShouldDeferOutputCombinationValidation(args, commandIndex, commandName, outputFormat, jsonStreamMode)) return true; + if (countFlagRequested + && outputFormat is not null and not "text" and not "json" and not "count") + { + error = $"--count cannot be combined with --format {outputFormat} because count mode supports only text, json, or count output."; + hint = $"remove --count to keep --format {outputFormat}, or use --format text, json, or count for count output."; + usage = ConsoleUi.GetUsageLine(commandName) ?? $"cdidx {commandName} --help"; + return false; + } + var commandUsesImplicitNdjson = string.Equals(commandName, "search", StringComparison.Ordinal) || string.Equals(commandName, "files", StringComparison.Ordinal) || string.Equals(commandName, "symbols", StringComparison.Ordinal); @@ -126,14 +144,15 @@ private static bool TryValidateOutputFormatOptions( return false; } + var schemaOutputFormat = countModeRequested ? "count" : outputFormat; if (jsonStreamMode != null - && outputFormat != null - && CliOutputFormatCapabilities.TryGet(outputFormat, out var streamCapability) + && schemaOutputFormat != null + && CliOutputFormatCapabilities.TryGet(schemaOutputFormat, out var streamCapability) && !streamCapability.SupportsJsonStreamMode) { var streamOption = jsonStreamOption ?? $"--json={jsonStreamMode}"; - error = $"{streamOption} cannot be combined with --format {outputFormat} because that format defines its own output schema."; - hint = $"remove {streamOption} to keep --format {outputFormat}, or use --format json."; + error = $"{streamOption} cannot be combined with --format {schemaOutputFormat} because that format defines its own output schema."; + hint = $"remove {streamOption} to keep --format {schemaOutputFormat}, or use --format json."; usage = ConsoleUi.GetUsageLine(commandName) ?? $"cdidx {commandName} --help"; return false; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs index a56813095..a06ef345b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs @@ -155,18 +155,16 @@ private bool TryParseGeneralOption(string normalizedArg, string currentArg, stri outputFormatExplicit = true; if (parsedOutputFormat == OutputFormatCompact) compact = true; - if (parsedOutputFormat == OutputFormatCount) - countOnly = true; - if (parsedOutputFormat != OutputFormatText && + outputFormatImpliesStructuredOutput = + parsedOutputFormat != OutputFormatText && parsedOutputFormat != OutputFormatDot && - parsedOutputFormat != OutputFormatGraphMl) - json = true; + parsedOutputFormat != OutputFormatGraphMl; } else if (allowIssueDraftsFormat && string.Equals(formatValue, OutputFormatIssueDrafts, StringComparison.OrdinalIgnoreCase)) { outputFormat = OutputFormatIssueDrafts; outputFormatExplicit = true; - json = true; + outputFormatImpliesStructuredOutput = true; } else { diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs index 850183be4..a62e1e99a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.Results.cs @@ -45,7 +45,7 @@ private bool TryParseResultOption(string normalizedArg, string currentArg, strin AddParseError(bodyLinesError!); break; case "--count": - countOnly = true; + countFlagRequested = true; break; case "--group-partials": groupPartials = true; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index c9618da46..78accb06b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -40,6 +40,7 @@ private sealed partial class QueryArgumentParser private int? bodyStartLine; private int? bodyLines; private bool countOnly; + private bool countFlagRequested; private bool groupPartials; private bool all; private bool strictNotFound; @@ -126,6 +127,7 @@ private sealed partial class QueryArgumentParser private bool statusLogPath; private string outputFormat = OutputFormatText; private bool outputFormatExplicit; + private bool outputFormatImpliesStructuredOutput; private bool statusConfig; private bool? redactPaths; private bool limitExplicit; @@ -210,6 +212,7 @@ internal QueryArgumentParser( internal QueryCommandOptions Parse(string[] args) { ParseRawArguments(args); + NormalizeOutputMode(); if (unusedActionable) { @@ -243,6 +246,26 @@ internal QueryCommandOptions Parse(string[] args) return BuildOptions(dbResolution, resolvedDbPath); } + private void NormalizeOutputMode() + { + countOnly = countFlagRequested || outputFormat == OutputFormatCount; + if (outputFormatImpliesStructuredOutput) + json = true; + + if (countFlagRequested + && outputFormatExplicit + && outputFormat is not OutputFormatText and not OutputFormatJson and not OutputFormatCount) + { + AddParseError( + $"Error: --count cannot be combined with --format {outputFormat} because count mode supports only text, json, or count output."); + } + else if (countOnly && jsonOutputFormatExplicit) + { + AddParseError( + $"Error: --json={jsonOutputFormat} cannot be combined with --format count because that format defines its own output schema."); + } + } + private void ResolveProjectFilters(string resolvedDbPath) { if (parseErrors != null || projectFilters.Count == 0) diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index ac6cd7fe5..342c33b0f 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -674,6 +674,207 @@ public void RunSearch_NdjsonWithOwnSchemaFormat_ReturnsUsageError_Issue4562(stri Assert.Contains("Usage: cdidx search", stderr, StringComparison.Ordinal); } + [Fact] + public void RunSearch_CountModeAliases_PreserveSupportedSerializationShapes_Issue4878() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_count_mode_aliases"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/App.cs", + "csharp", + "class App { void Needle() {} }\n"); + + string[][] humanCountCases = + [ + ["--count"], + ["--format", "text", "--count"], + ["--count", "--format", "text"], + ]; + foreach (var suffix in humanCountCases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "Needle", "--db", dbPath, .. suffix], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("1" + Environment.NewLine, stdout); + Assert.Empty(stderr); + } + + string[][] jsonCountCases = + [ + ["--count", "--json"], + ["--json", "--count"], + ["--format", "count"], + ["--format", "count", "--json"], + ["--json", "--format=count"], + ["--count", "--format", "json"], + ["--format", "json", "--count"], + ]; + foreach (var suffix in jsonCountCases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "Needle", "--db", dbPath, .. suffix], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + Assert.Equal(1, document.RootElement.GetProperty("count").GetInt32()); + } + + string[][] envelopeCases = + [ + ["--count", "--json-envelope"], + ["--format", "count", "--json-envelope"], + ]; + foreach (var suffix in envelopeCases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "Needle", "--db", dbPath, .. suffix], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + var result = Assert.Single(document.RootElement.GetProperty("results").EnumerateArray()); + Assert.Equal(1, result.GetProperty("count").GetInt32()); + } + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_CountModeRejectsExplicitIncompatibleFormatsInEitherOrder_Issue4878() + { + foreach (var format in new[] { "compact", "grouped", "csv", "tsv", "lsp", "qf", "sarif" }) + { + string[][] cases = + [ + ["search", "Needle", "--count", "--format", format], + ["search", "Needle", $"--format={format}", "--count"], + ]; + + foreach (var args in cases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + args, + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Empty(stdout); + Assert.Contains( + $"--count cannot be combined with --format {format}", + stderr, + StringComparison.Ordinal); + Assert.Contains("Usage: cdidx search", stderr, StringComparison.Ordinal); + } + } + } + + [Fact] + public void RunSearch_CountModeRejectsExplicitJsonStreamsForBothAliases_Issue4878() + { + (string[] Args, string StreamOption)[] cases = + [ + (["search", "Needle", "--count", "--json=array"], "--json=array"), + (["search", "Needle", "--json=array", "--count"], "--json=array"), + (["search", "Needle", "--format", "count", "--json=array"], "--json=array"), + (["search", "Needle", "--json=ndjson", "--format=count"], "--json=ndjson"), + ]; + + foreach (var (args, streamOption) in cases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + args, + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Empty(stdout); + Assert.Contains( + $"{streamOption} cannot be combined with --format count", + stderr, + StringComparison.Ordinal); + Assert.Contains("Usage: cdidx search", stderr, StringComparison.Ordinal); + } + } + + [Fact] + public void RunSearchDirect_CountModeRejectsExplicitJsonStreamsForBothAliases_Issue4878() + { + string[][] cases = + [ + ["Needle", "--count", "--json=array"], + ["Needle", "--json=ndjson", "--count"], + ["Needle", "--format", "count", "--json=array"], + ["Needle", "--json=ndjson", "--format=count"], + ]; + + foreach (var args in cases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + args, + new JsonSerializerOptions(JsonSerializerDefaults.Web))); + var streamOption = args.Single(arg => arg.StartsWith("--json=", StringComparison.Ordinal)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Empty(stdout); + Assert.Contains( + $"{streamOption} cannot be combined with --format count", + stderr, + StringComparison.Ordinal); + } + } + + [Fact] + public void RunSearch_RepeatedCountAndFormatFlagsUseStableAliasAndRightmostValueRules_Issue4878() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_count_mode_repeated"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/App.cs", + "csharp", + "class App { void Needle() {} }\n"); + + var (countExitCode, countStdout, countStderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "Needle", "--db", dbPath, "--count", "--count", "--json"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.Success, countExitCode); + Assert.Empty(countStderr); + using (var countDocument = JsonDocument.Parse(countStdout)) + Assert.Equal(1, countDocument.RootElement.GetProperty("count").GetInt32()); + + var (csvExitCode, csvStdout, csvStderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "Needle", "--db", dbPath, "--format", "count", "--format", "csv"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.Success, csvExitCode); + Assert.StartsWith("file,line,column,label,query,recipe,query_name,lang,visibility,enclosing_symbol_name,enclosing_symbol_kind,match_lines", csvStdout, StringComparison.Ordinal); + Assert.Contains("rightmost CLI value 'csv' takes precedence", csvStderr, StringComparison.Ordinal); + Assert.DoesNotContain("\"count\"", csvStdout, StringComparison.Ordinal); + + var (jsonExitCode, jsonStdout, jsonStderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "Needle", "--db", dbPath, "--format", "text", "--format", "count"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.Success, jsonExitCode); + Assert.Contains("rightmost CLI value 'count' takes precedence", jsonStderr, StringComparison.Ordinal); + using var jsonDocument = JsonDocument.Parse(jsonStdout); + Assert.Equal(1, jsonDocument.RootElement.GetProperty("count").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Theory] [InlineData("files", false)] [InlineData("files", true)] From d03e4cf71040a8f03d94ec28f4cbc996bc1de6e1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 18:56:43 +0900 Subject: [PATCH 2/4] Reject results-only count streams (#4878) --- TESTING_GUIDE.md | 4 ++-- USER_GUIDE.md | 12 +++++++----- changelog.d/unreleased/4878.fixed.md | 4 ++-- .../Cli/ProgramRunner.OutputFormatValidation.cs | 7 +++++++ .../Cli/QueryCommandRunner.ArgumentParser.cs | 5 +++++ tests/CodeIndex.Tests/ProgramRunnerTests.cs | 12 +++++++++++- 6 files changed, 34 insertions(+), 10 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 15547810a..7f664e23e 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -185,7 +185,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding C# `unused` partial-family coverage must reuse one multi-file fixture across regular JSON, compact, `--by-bucket`, and `--actionable`; include top-level and nested partial types, genuinely unused private members, an unrelated same-named family, a containing-type generic-arity collision, and a family-external occurrence in a matched peer file so semantic membership cannot regress into name-only, flattened-qualified-name, or whole-file evidence sharing. `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs` keep command help, runtime value validation, and each generated shell's option/value catalog aligned with `CliFlagSchema`, and check every Fish command scope against the shared per-command completion projection. When an accepted value or alias changes, assert its registry normalization plus command usage, runtime validation, and Bash / Zsh / Fish / PowerShell completion visibility; add the value to the schema instead of weakening this parity contract. `ProgramRunnerTests.cs` enumerates `CliCommandCatalog.CommandSubcommands` and requires every valid nested verb to resolve to verb-specific usage with an example. It also pins destructive index confirmation and aggregate dependency-filter help, plus the read-only GitHub duplicate-preflight boundary for suggestion exports. Installer cancellation coverage must wait until the PID file contains a complete positive integer before cancelling, using a bounded dedicated waiter instead of a fixed-delay timer so parallel load cannot expose a redirection-created empty file. - Count-mode output coverage in `ProgramRunnerTests.cs` reuses one seeded search database across human text, JSON object, JSON envelope, incompatible format/stream, option-order, alias, and repeated-flag cases. Keep bare `--count` script output, `--format count` JSON output, and the rightmost repeated `--format` rule distinct while asserting that explicit incompatible formats never fall through to a count object. + Count-mode output coverage in `ProgramRunnerTests.cs` reuses one seeded search database across human text, JSON object, JSON envelope, incompatible format/stream (including `--results-only`), option-order, alias, and repeated-flag cases. Keep bare `--count` script output, `--format count` JSON output, and the rightmost repeated `--format` rule distinct while asserting that explicit incompatible formats never fall through to a count object. Global-tool-log home-shorthand coverage resolves the normalized first candidate through the test-only no-write-probe seam, so sandbox permissions cannot turn an expansion assertion into a writability-fallback assertion. Grouped-search limit coverage must seed more matches and files than the returned page, then assert query-wide matched/group/file totals separately from grouped, emitted, and omitted row counts so a bounded page cannot report itself as complete. `goto --all` coverage shares one 63-definition fixture across the default bare LSP array and the plain `--json` argument path; it must assert complete deterministic output without a limit and retain a separate explicit-limit assertion in the same setup. @@ -1115,7 +1115,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" C# `unused` の partial-family coverage では、通常の JSON、compact、`--by-bucket`、`--actionable` で1つの multi-file fixture を共有してください。top-level / nested partial type、本当に未使用の private member、無関係な同名 family、containing type の generic-arity collision、matched peer file 内の family 外 occurrence を含め、semantic membership が name-only、平坦化された qualified name、または file 全体の evidence 共有へ戻らないことを固定します。 `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` は command help、runtime value validation、各生成 shell の option / value 全カタログを `CliFlagSchema` と同期させ、Fish の全 command scope を共有の command 別 completion 射影と照合します。受理値または alias を変更するときは、registry normalization に加えて command usage、runtime validation、Bash / Zsh / Fish / PowerShell completion への露出を検証し、この parity 契約を弱めず schema に値を追加してください。 `ProgramRunnerTests.cs` は `CliCommandCatalog.CommandSubcommands` を列挙し、すべての有効な nested verb が例を含む verb 固有 usage に解決されることを必須とします。さらに destructive な index confirmation、aggregate dependency filter help、suggestion export の read-only GitHub duplicate-preflight 境界を固定します。installer cancellation coverage は固定時間タイマーではなく上限付きの専用 waiter を使い、PID ファイルが完全な正の整数を含むまで待ってから cancel してください。これにより並列負荷下でも redirection によって作成された空ファイルを読みません。 - `ProgramRunnerTests.cs` の count-mode output coverage は、human text、JSON object、JSON envelope、非互換 format / stream、option 順序、alias、flag 反復の各 case で 1 つの seeded search database を再利用します。bare `--count` の script 出力、`--format count` の JSON 出力、反復した `--format` の右端優先規則を区別して維持しつつ、明示した非互換 format が count object へ fall through しないことを検証してください。 + `ProgramRunnerTests.cs` の count-mode output coverage は、human text、JSON object、JSON envelope、`--results-only` を含む非互換 format / stream、option 順序、alias、flag 反復の各 case で 1 つの seeded search database を再利用します。bare `--count` の script 出力、`--format count` の JSON 出力、反復した `--format` の右端優先規則を区別して維持しつつ、明示した非互換 format が count object へ fall through しないことを検証してください。 global tool log の home shorthand coverage は、test 専用の write probe なし seam から正規化済みの先頭 candidate を解決します。これにより sandbox permission の影響で、展開の assertion が writability fallback の assertion に変わることを防ぎます。 grouped search の limit coverage では、返却 page より多い match と file を seed し、query 全体の match/group/file 総数と grouped、emitted、omitted row 数を別々に検証して、上限付き page が完了済みと報告できないようにしてください。 `goto --all` の coverage は、既定の bare LSP array と plain `--json` 引数経路で 1 つの 63 定義 fixture を共有し、limit 未指定時の完全かつ決定的な出力と、同じ setup 内での明示 limit の維持を検証してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index b3879c40d..7c85da81b 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -359,8 +359,9 @@ when an indented search result is required. Count mode follows the same contract. Bare `--count` preserves the newline-terminated numeric value used by scripts, while `--count --json`, `--format count`, and count output wrapped by `--json-envelope` use one JSON -count object. Explicit `--json=array` / `--json=ndjson` streams are incompatible -with count mode, and `--count` accepts only `--format text`, `json`, or `count`; +count object. Explicit `--results-only`, `--json=array`, and `--json=ndjson` +streams are incompatible with count mode, and `--count` accepts only +`--format text`, `json`, or `count`; formats such as `compact`, `grouped`, `csv`, `tsv`, `lsp`, `qf`, and `sarif` return a usage error instead of being silently replaced. When `--format` is repeated, its existing rightmost-value-wins rule still applies. @@ -3635,9 +3636,10 @@ output modifier は 1 つの contract として検証されます。`--json` と `--json=array --pretty` を使ってください。 count mode も同じ contract に従います。bare `--count` は script が利用する改行終端の 数値を維持し、`--count --json`、`--format count`、`--json-envelope` で包んだ count -出力は 1 つの JSON count object を使います。明示的な `--json=array` / -`--json=ndjson` stream は count mode と両立せず、`--count` と組み合わせられる -`--format` は `text`、`json`、`count` だけです。`compact`、`grouped`、`csv`、`tsv`、 +出力は 1 つの JSON count object を使います。明示的な `--results-only`、 +`--json=array`、`--json=ndjson` stream は count mode と両立せず、`--count` と +組み合わせられる `--format` は `text`、`json`、`count` だけです。`compact`、 +`grouped`、`csv`、`tsv`、 `lsp`、`qf`、`sarif` などは黙って count output に置き換えず usage error を返します。 `--format` を繰り返した場合は、既存どおり右端の値を優先します。 `search --named-query =` は繰り返し指定でき、同じ filter と snippet 上限で diff --git a/changelog.d/unreleased/4878.fixed.md b/changelog.d/unreleased/4878.fixed.md index 5b84062c3..8c4bdea87 100644 --- a/changelog.d/unreleased/4878.fixed.md +++ b/changelog.d/unreleased/4878.fixed.md @@ -14,8 +14,8 @@ affected: ## English -- **Count-mode output formats are now validated consistently (#4878)** — `--count` and `--format count` now share output-mode validation, explicit JSON array/NDJSON streams and incompatible formats fail with a usage error instead of silently replacing the requested shape, and repeated `--format` options retain the documented rightmost-value-wins behavior. +- **Count-mode output formats are now validated consistently (#4878)** — `--count` and `--format count` now share output-mode validation, explicit results-only/JSON array/NDJSON streams and incompatible formats fail with a usage error instead of silently replacing the requested shape, and repeated `--format` options retain the documented rightmost-value-wins behavior. ## 日本語 -- **count mode の出力形式を一貫して検証するようになりました (#4878)** — `--count` と `--format count` が同じ output-mode 検証を使い、明示的な JSON array / NDJSON stream や非互換 format は要求した形式を黙って置き換えず usage error となり、反復した `--format` は既存の右端優先規則を維持します。 +- **count mode の出力形式を一貫して検証するようになりました (#4878)** — `--count` と `--format count` が同じ output-mode 検証を使い、明示的な results-only / JSON array / NDJSON stream や非互換 format は要求した形式を黙って置き換えず usage error となり、反復した `--format` は既存の右端優先規則を維持します。 diff --git a/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs b/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs index 3e963a0d1..000f8187c 100644 --- a/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs +++ b/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs @@ -103,7 +103,14 @@ private static bool TryValidateOutputFormatOptions( var countModeRequested = countFlagRequested || string.Equals(outputFormat, "count", StringComparison.Ordinal); if (countModeRequested) + { usesSingleDocumentJsonMode = true; + if (resultsOnlyRequested) + { + jsonStreamMode = "ndjson"; + jsonStreamOption = "--results-only"; + } + } if (ShouldDeferOutputCombinationValidation(args, commandIndex, commandName, outputFormat, jsonStreamMode)) return true; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index 78accb06b..354928c31 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -259,6 +259,11 @@ private void NormalizeOutputMode() AddParseError( $"Error: --count cannot be combined with --format {outputFormat} because count mode supports only text, json, or count output."); } + else if (countOnly && resultsOnly) + { + AddParseError( + "Error: --results-only cannot be combined with --format count because that format defines its own output schema."); + } else if (countOnly && jsonOutputFormatExplicit) { AddParseError( diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 342c33b0f..93678e072 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -787,6 +787,10 @@ public void RunSearch_CountModeRejectsExplicitJsonStreamsForBothAliases_Issue487 (["search", "Needle", "--json=array", "--count"], "--json=array"), (["search", "Needle", "--format", "count", "--json=array"], "--json=array"), (["search", "Needle", "--json=ndjson", "--format=count"], "--json=ndjson"), + (["search", "Needle", "--count", "--results-only"], "--results-only"), + (["search", "Needle", "--results-only", "--count"], "--results-only"), + (["search", "Needle", "--format", "count", "--results-only"], "--results-only"), + (["search", "Needle", "--results-only", "--format=count"], "--results-only"), ]; foreach (var (args, streamOption) in cases) @@ -814,6 +818,10 @@ public void RunSearchDirect_CountModeRejectsExplicitJsonStreamsForBothAliases_Is ["Needle", "--json=ndjson", "--count"], ["Needle", "--format", "count", "--json=array"], ["Needle", "--json=ndjson", "--format=count"], + ["Needle", "--count", "--results-only"], + ["Needle", "--results-only", "--count"], + ["Needle", "--format", "count", "--results-only"], + ["Needle", "--results-only", "--format=count"], ]; foreach (var args in cases) @@ -821,7 +829,9 @@ public void RunSearchDirect_CountModeRejectsExplicitJsonStreamsForBothAliases_Is var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( args, new JsonSerializerOptions(JsonSerializerDefaults.Web))); - var streamOption = args.Single(arg => arg.StartsWith("--json=", StringComparison.Ordinal)); + var streamOption = args.Single(arg => + arg == "--results-only" + || arg.StartsWith("--json=", StringComparison.Ordinal)); Assert.Equal(CommandExitCodes.UsageError, exitCode); Assert.Empty(stdout); From 04286c19130aa53a8bcd7e43a9e4c22ce9caec5b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 19:56:12 +0900 Subject: [PATCH 3/4] Preserve explicit count output contracts (#4878) --- .../ProgramRunner.OutputFormatValidation.cs | 20 ++++++++++ ...eryCommandRunner.ArgumentParser.General.cs | 2 + .../Cli/QueryCommandRunner.ArgumentParser.cs | 3 ++ tests/CodeIndex.Tests/ProgramRunnerTests.cs | 40 +++++++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs b/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs index 000f8187c..263e7a937 100644 --- a/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs +++ b/src/CodeIndex/Cli/ProgramRunner.OutputFormatValidation.cs @@ -25,6 +25,7 @@ private static bool TryValidateOutputFormatOptions( string? jsonStreamOption = null; var jsonRequested = false; var prettyRequested = false; + var compactRequested = false; var resultsOnlyRequested = false; var countFlagRequested = false; var usesSingleDocumentJsonMode = false; @@ -84,6 +85,11 @@ private static bool TryValidateOutputFormatOptions( } continue; } + if (tokenRole != QueryCommandTokenRole.CommandOptionValue && arg == "--compact") + { + compactRequested = true; + continue; + } if (arg.StartsWith("--format=", StringComparison.Ordinal)) { if (tokenRole == QueryCommandTokenRole.CommandOptionValue) @@ -115,6 +121,12 @@ private static bool TryValidateOutputFormatOptions( if (ShouldDeferOutputCombinationValidation(args, commandIndex, commandName, outputFormat, jsonStreamMode)) return true; + // Audit owns a structured JSON usage-error contract. Once count aliases are + // normalized, let its command runner report count-mode conflicts so --json + // callers keep receiving a machine-readable error on stdout. + if (countModeRequested && string.Equals(commandName, "audit", StringComparison.Ordinal)) + return true; + if (countFlagRequested && outputFormat is not null and not "text" and not "json" and not "count") { @@ -124,6 +136,14 @@ private static bool TryValidateOutputFormatOptions( return false; } + if (countModeRequested && compactRequested) + { + error = "Bounded response controls cannot be combined with --count."; + hint = "remove --compact to keep count output, or remove --count/--format count to keep compact output."; + usage = ConsoleUi.GetUsageLine(commandName) ?? $"cdidx {commandName} --help"; + return false; + } + var commandUsesImplicitNdjson = string.Equals(commandName, "search", StringComparison.Ordinal) || string.Equals(commandName, "files", StringComparison.Ordinal) || string.Equals(commandName, "symbols", StringComparison.Ordinal); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs index a06ef345b..5c5fc8637 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs @@ -152,6 +152,7 @@ private bool TryParseGeneralOption(string normalizedArg, string currentArg, stri if (TryParseOutputFormat(formatValue!, out var parsedOutputFormat)) { outputFormat = parsedOutputFormat; + explicitOutputFormat = parsedOutputFormat; outputFormatExplicit = true; if (parsedOutputFormat == OutputFormatCompact) compact = true; @@ -163,6 +164,7 @@ private bool TryParseGeneralOption(string normalizedArg, string currentArg, stri else if (allowIssueDraftsFormat && string.Equals(formatValue, OutputFormatIssueDrafts, StringComparison.OrdinalIgnoreCase)) { outputFormat = OutputFormatIssueDrafts; + explicitOutputFormat = OutputFormatIssueDrafts; outputFormatExplicit = true; outputFormatImpliesStructuredOutput = true; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index 354928c31..fbdb20903 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -126,6 +126,7 @@ private sealed partial class QueryArgumentParser private string? statusExplainField; private bool statusLogPath; private string outputFormat = OutputFormatText; + private string? explicitOutputFormat; private bool outputFormatExplicit; private bool outputFormatImpliesStructuredOutput; private bool statusConfig; @@ -248,6 +249,8 @@ internal QueryCommandOptions Parse(string[] args) private void NormalizeOutputMode() { + if (explicitOutputFormat != null) + outputFormat = explicitOutputFormat; countOnly = countFlagRequested || outputFormat == OutputFormatCount; if (outputFormatImpliesStructuredOutput) json = true; diff --git a/tests/CodeIndex.Tests/ProgramRunnerTests.cs b/tests/CodeIndex.Tests/ProgramRunnerTests.cs index 93678e072..5e9691bd1 100644 --- a/tests/CodeIndex.Tests/ProgramRunnerTests.cs +++ b/tests/CodeIndex.Tests/ProgramRunnerTests.cs @@ -878,6 +878,26 @@ public void RunSearch_RepeatedCountAndFormatFlagsUseStableAliasAndRightmostValue Assert.Contains("rightmost CLI value 'count' takes precedence", jsonStderr, StringComparison.Ordinal); using var jsonDocument = JsonDocument.Parse(jsonStdout); Assert.Equal(1, jsonDocument.RootElement.GetProperty("count").GetInt32()); + + var (projectionExitCode, projectionStdout, projectionStderr) = CaptureConsole(() => + QueryCommandRunner.RunSearch( + ["Needle", "--db", dbPath, "--format", "count", "--search-fields", "path"], + new JsonSerializerOptions(JsonSerializerDefaults.Web))); + Assert.Equal(CommandExitCodes.Success, projectionExitCode); + Assert.Empty(projectionStderr); + using var projectionDocument = JsonDocument.Parse(projectionStdout); + Assert.Equal(1, projectionDocument.RootElement.GetProperty("count").GetInt32()); + + var (modifierExitCode, modifierStdout, modifierStderr) = CaptureConsole(() => + ProgramRunner.Run( + ["hotspots", "--format", "count", "--compact"], + appVersion: "1.10.0")); + Assert.Equal(CommandExitCodes.UsageError, modifierExitCode); + Assert.Empty(modifierStdout); + Assert.Contains( + "Bounded response controls cannot be combined with --count.", + modifierStderr, + StringComparison.Ordinal); } finally { @@ -885,6 +905,26 @@ public void RunSearch_RepeatedCountAndFormatFlagsUseStableAliasAndRightmostValue } } + [Fact] + public void RunAudit_CountConflictWithJsonRetainsStructuredUsageError_Issue4878() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["audit", "risky-code", "--count", "--format", "sarif", "--json"], + new JsonSerializerOptions(JsonSerializerDefaults.Web), + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + Assert.Equal("error", document.RootElement.GetProperty("status").GetString()); + Assert.Equal("audit", document.RootElement.GetProperty("command").GetString()); + Assert.Equal(CommandErrorCodes.UsageError, document.RootElement.GetProperty("error_code").GetString()); + Assert.Contains( + "--count cannot be combined with --format sarif", + document.RootElement.GetProperty("message").GetString(), + StringComparison.Ordinal); + } + [Theory] [InlineData("files", false)] [InlineData("files", true)] From 0a11cae1afa522e41239da48e799cf0ff172c74b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 20:59:28 +0900 Subject: [PATCH 4/4] Preserve JSON selector precedence (#4878) --- .../Cli/QueryCommandRunner.ArgumentParser.General.cs | 4 ++-- src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs index 5c5fc8637..3b890cb5e 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.General.cs @@ -152,7 +152,7 @@ private bool TryParseGeneralOption(string normalizedArg, string currentArg, stri if (TryParseOutputFormat(formatValue!, out var parsedOutputFormat)) { outputFormat = parsedOutputFormat; - explicitOutputFormat = parsedOutputFormat; + countOutputFormatExplicit = parsedOutputFormat == OutputFormatCount; outputFormatExplicit = true; if (parsedOutputFormat == OutputFormatCompact) compact = true; @@ -164,7 +164,7 @@ private bool TryParseGeneralOption(string normalizedArg, string currentArg, stri else if (allowIssueDraftsFormat && string.Equals(formatValue, OutputFormatIssueDrafts, StringComparison.OrdinalIgnoreCase)) { outputFormat = OutputFormatIssueDrafts; - explicitOutputFormat = OutputFormatIssueDrafts; + countOutputFormatExplicit = false; outputFormatExplicit = true; outputFormatImpliesStructuredOutput = true; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs index fbdb20903..952bd1205 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.ArgumentParser.cs @@ -126,7 +126,7 @@ private sealed partial class QueryArgumentParser private string? statusExplainField; private bool statusLogPath; private string outputFormat = OutputFormatText; - private string? explicitOutputFormat; + private bool countOutputFormatExplicit; private bool outputFormatExplicit; private bool outputFormatImpliesStructuredOutput; private bool statusConfig; @@ -249,8 +249,8 @@ internal QueryCommandOptions Parse(string[] args) private void NormalizeOutputMode() { - if (explicitOutputFormat != null) - outputFormat = explicitOutputFormat; + if (countOutputFormatExplicit) + outputFormat = OutputFormatCount; countOnly = countFlagRequested || outputFormat == OutputFormatCount; if (outputFormatImpliesStructuredOutput) json = true;