From ae7142ff216c1b35dbf064691b7260ab66b292d1 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:22:01 +0900 Subject: [PATCH 1/3] Fix format aliases for map symbols inspect (#3446) --- changelog.d/unreleased/3446.fixed.md | 20 ++++++ src/CodeIndex/Cli/CliFlagSchema.cs | 4 +- src/CodeIndex/Cli/ConsoleUi.cs | 6 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 64 ++++++++++++++----- tests/CodeIndex.Tests/ConsoleUiTests.cs | 8 +-- .../QueryCommandRunnerInspectTests.cs | 36 +++++++++++ .../QueryCommandRunnerMapTests.cs | 31 +++++++++ .../QueryCommandRunnerSymbolTests.cs | 51 +++++++++++++++ 8 files changed, 196 insertions(+), 24 deletions(-) create mode 100644 changelog.d/unreleased/3446.fixed.md diff --git a/changelog.d/unreleased/3446.fixed.md b/changelog.d/unreleased/3446.fixed.md new file mode 100644 index 0000000000..5ca7d882ec --- /dev/null +++ b/changelog.d/unreleased/3446.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 3446 +affected: + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +--- + +## English + +- **Format aliases now work consistently on map, symbols, and inspect (#3446)** — `map` and `inspect` accept `--format json` / `--format compact`, `symbols` accepts `--format json` / `--format count`, and unsupported format combinations now return command-specific hints. + +## 日本語 + +- **map、symbols、inspect の format alias を揃えました (#3446)** — `map` と `inspect` は `--format json` / `--format compact` を受け付け、`symbols` は `--format json` / `--format count` を受け付けます。未対応の format 組み合わせではコマンド別のヒントを返します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index db8519235c..d3468ab335 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -199,7 +199,7 @@ internal static class CliFlagSchema private static readonly string[] FormatCommands = [ - "search", "definition", "references", "callers", "callees", "find", "validate", "deps", "suggestions", + "search", "definition", "references", "callers", "callees", "symbols", "find", "map", "inspect", "validate", "deps", "suggestions", ]; private static readonly string[] ProfileCommands = @@ -226,7 +226,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--json", Description = "JSON output; search/files/validate also accept --json=array for a single JSON array", Commands = Set(JsonCommands) }, new() { Name = "--pretty", Description = "Pretty-print JSON output with indentation", Commands = Set(JsonCommands), TopLevel = true }, new() { Name = "--compact", Description = "AI-oriented compact JSON with capped list sections and truncation metadata", Commands = Set(CompactJsonCommands) }, - new() { Name = "--format", ValuePlaceholder = "", Description = "Standard output format for token budgets, editor integrations, and CI; search recipes and suggestions export also accept issue-drafts", Commands = Set(FormatCommands) }, + new() { Name = "--format", ValuePlaceholder = "", Description = "Standard output format for token budgets, editor integrations, and CI; supported values vary by command, and search recipes/suggestions export also accept issue-drafts", Commands = Set(FormatCommands) }, new() { Name = "--quiet", ShortName = "-q", Description = "Suppress informational stderr output; errors still print", Commands = Set(AllCommands.ToArray()), TopLevel = true }, new() { Name = "--silent", Description = "Alias for --quiet", Commands = Set(AllCommands.ToArray()), TopLevel = true }, new() { Name = "--color", ValuePlaceholder = "", Description = "Color output mode", Commands = Set(), TopLevel = true }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 8e4d22deba..7c9a9d91f7 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -93,12 +93,12 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("references", "cdidx references |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("callers", "cdidx callers |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("callees", "cdidx callees |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), - ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]"), + ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]"), ("files", "cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), ("find", "cdidx find --path [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]"), ("excerpt", "cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]"), - ("map", "cdidx map [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]"), - ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines ] [--max-line-width ] [--exact|--exact-name]"), + ("map", "cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]"), + ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines ] [--max-line-width ] [--exact|--exact-name]"), ("outline", "cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ]"), ("status", "cdidx status [--db ] [--json] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config] [--check-updates]"), ("workspace", "cdidx workspace [name] [--json]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 5bf0eeecdd..f2643d2ee2 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -282,6 +282,24 @@ private sealed record StatusReadinessField( private const string OutputFormatGraphMl = "graphml"; private const string OutputFormatJsonGraph = "json-graph"; private const string OutputFormatEdgeList = "edgelist"; + private static readonly HashSet RepoMapOutputFormats = new(StringComparer.Ordinal) + { + OutputFormatText, + OutputFormatJson, + OutputFormatCompact, + }; + private static readonly HashSet SymbolOutputFormats = new(StringComparer.Ordinal) + { + OutputFormatText, + OutputFormatJson, + OutputFormatCount, + }; + private static readonly HashSet InspectOutputFormats = new(StringComparer.Ordinal) + { + OutputFormatText, + OutputFormatJson, + OutputFormatCompact, + }; private static readonly HashSet InlineValueOptions = new( ValueTakingOptions.Concat(["--json", "--log-format", "--log-retain-count", "--log-max-size-mb"]), @@ -567,14 +585,6 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Remove the positional query, or run a plain `cdidx search ` without --recipe."); return CommandExitCodes.UsageError; } - if (options.CountOnly) - { - WriteUsageError( - "--count is not supported with --recipe.", - GetUsageLineOrThrow("search"), - "Use `cdidx search --recipe --json` for per-query result counts."); - return CommandExitCodes.UsageError; - } if (options.Prefix) { WriteUsageError( @@ -591,6 +601,14 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Use `--json` for grouped recipe results or `--format issue-drafts` for draft exports."); return CommandExitCodes.UsageError; } + if (options.CountOnly) + { + WriteUsageError( + "--count is not supported with --recipe.", + GetUsageLineOrThrow("search"), + "Use `cdidx search --recipe --json` for per-query result counts."); + return CommandExitCodes.UsageError; + } if (options.JsonOutputFormat == JsonOutputFormatArray) { WriteUsageError( @@ -2459,6 +2477,8 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions validateDefaultMaxLineWidth: false); if (TryWriteUnsupportedOptionError("symbols", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("symbols"), options.Query)) return CommandExitCodes.UsageError; + if (TryWriteUnsupportedOutputFormat("symbols", options, SymbolOutputFormats, "Use `--format json` for symbol rows or `--format count` for symbol totals; compact symbol rows are not currently defined.")) + return CommandExitCodes.UsageError; if (TryWriteInvalidKindFilterError(options, "symbols", KnownSymbolKindFilters)) return CommandExitCodes.InvalidArgument; if (TryWriteParseError(options, "symbols")) @@ -3201,14 +3221,8 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - if (!TryExtractDepsFormat(cmdArgs, out var depsFormat, out var parseArgs, out var depsFormatError)) - { - Console.Error.WriteLine(depsFormatError); - return CommandExitCodes.UsageError; - } - var options = ParseArgs( - parseArgs, + cmdArgs, jsonDefault: false, validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false); @@ -3216,6 +3230,8 @@ public static int RunMap(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "map")) return CommandExitCodes.UsageError; + if (TryWriteUnsupportedOutputFormat("map", options, RepoMapOutputFormats, "Use `--format json` or `--format compact` for map output; use `cdidx files --count` when you need only a file count.")) + return CommandExitCodes.UsageError; if (TryWriteUnexpectedPositionals("map", options)) return CommandExitCodes.UsageError; @@ -3616,6 +3632,8 @@ public static int RunInspect(string[] cmdArgs, JsonSerializerOptions jsonOptions return CommandExitCodes.UsageError; if (TryWriteParseError(options, "inspect")) return CommandExitCodes.UsageError; + if (TryWriteUnsupportedOutputFormat("inspect", options, InspectOutputFormats, "Use `--format json` or `--format compact` for inspect bundles; count output is not meaningful for one inspect bundle.")) + return CommandExitCodes.UsageError; if (!TryResolveNameExactMode(options, "inspect", out var exact, out var exactError)) { Console.Error.WriteLine(exactError); @@ -6624,6 +6642,10 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) if (TryParseOutputFormat(formatValue!, out var parsedOutputFormat)) { outputFormat = parsedOutputFormat; + if (parsedOutputFormat == OutputFormatCompact) + compact = true; + if (parsedOutputFormat == OutputFormatCount) + countOnly = true; if (parsedOutputFormat != OutputFormatText && parsedOutputFormat != OutputFormatDot && parsedOutputFormat != OutputFormatGraphMl) @@ -8613,6 +8635,18 @@ private static int ComputeReferenceKindColumnWidth(IEnumerable rows, Func< private static void WriteUsageError(string message, string usage, string hint) => CommandErrorWriter.Write(message, hint, usage); + private static bool TryWriteUnsupportedOutputFormat(string commandName, QueryCommandOptions options, IReadOnlySet supportedFormats, string hint) + { + if (supportedFormats.Contains(options.OutputFormat)) + return false; + + WriteUsageError( + $"--format {options.OutputFormat} is not supported by {commandName}.", + GetUsageLineOrThrow(commandName), + hint); + return true; + } + // Reject queries that were supplied but resolve to empty / whitespace-only text so the user gets // a distinct error instead of the generic " requires a query argument" message that fires // when the positional was actually missing. The null case is left to the existing missing-query diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 73efeb7c00..ed5a1f1e3a 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -118,7 +118,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]", output); Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); - Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines ] [--max-line-width ] [--exact|--exact-name]", output); + Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines ] [--max-line-width ] [--exact|--exact-name]", output); Assert.Contains("--snippet-lines search/find snippet length (1-20, default: search 8; find 1)", output); Assert.Contains("--snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)", output); Assert.Contains("--max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: 512)", output); @@ -150,8 +150,8 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]", output); Assert.Contains("--focus-column find/excerpt: focus a specific 1-based column", output); Assert.Contains("--focus-line find/excerpt: focus a specific line", output); - Assert.Contains("cdidx map [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]", output); - Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); + Assert.Contains("cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]", output); + Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); Assert.Contains("cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]", output); Assert.Contains("Note: if a query itself starts with '-', pass it with --query or -- ", output); @@ -270,7 +270,7 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() var output = CaptureFullUsageOutput(showBanner: false); Assert.Contains("cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]", output); - Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); + Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); Assert.Contains("cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); Assert.Contains("cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--bucket ] [--min-confidence ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index ff3f90653a..c53a53bfbe 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -184,6 +184,42 @@ public void RunInspect_ParseBodyRange_ImplyBodyAndValidateValues_Issue3394() Assert.Null(options.ParseError); } + [Fact] + public void RunInspect_FormatCompact_ActsLikeCompactJson_Issue3446() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_inspect_format_compact"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/Target.cs", + "csharp", + """ + public class Target + { + public void Compute() { } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunInspect( + ["Target", "--db", dbPath, "--format", "compact"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(json.GetProperty("compact").GetBoolean()); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit, json.GetProperty("compact_limit").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunInspect_FieldsJson_EmitsOnlySelectedTopLevelGroups_Issue3056() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs index 0d37ec1736..150777be3b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerMapTests.cs @@ -40,6 +40,37 @@ public void RunMap_ParseCompact_ImpliesJsonAndPreservesExplicitLimit_Issue3009() Assert.Null(options.ParseError); } + [Fact] + public void RunMap_FormatCompact_ActsLikeCompactJson_Issue3446() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_runner_map_format_compact"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/App.cs", + "csharp", + "namespace Demo; public class App { public void Run() { } }\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunMap( + ["--db", dbPath, "--format", "compact"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(json.GetProperty("compact").GetBoolean()); + Assert.Equal(QueryCommandRunner.DefaultCompactSectionLimit, json.GetProperty("compact_limit").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunMap_ParseInvalidMinEntrypointConfidence_TruncatesOversizedValue() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index 4c0c174c9b..8194dc8b95 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs @@ -8,6 +8,57 @@ namespace CodeIndex.Tests; public partial class QueryCommandRunnerTests { + [Fact] + public void RunSymbols_FormatCount_ActsLikeCountJson_Issue3446() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_symbols_format_count"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/App.cs", + "csharp", + """ + namespace Demo; + + public class App + { + public void Run() { } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSymbols( + ["App", "--db", dbPath, "--format", "count"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.True(json.GetProperty("count").GetInt32() > 0); + Assert.Equal(1, json.GetProperty("files").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSymbols_FormatCompact_ReturnsTargetedHint_Issue3446() + { + var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunSymbols( + ["--format", "compact"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("--format compact is not supported by symbols.", stderr); + Assert.Contains("Usage: cdidx symbols", stderr); + Assert.Contains("--format json", stderr); + } + [Fact] public void RunDefinition_JsonBodyIncludesTruncationMetadata_Issue3131() { From 0a627efd1c1577b90e2ed2aaa252626a1a8375f0 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:04:56 +0900 Subject: [PATCH 2/3] Add bounded find all scan stats (#3560) --- DEVELOPER_GUIDE.md | 2 + USER_GUIDE.md | 4 + changelog.d/unreleased/3560.added.md | 21 +++ src/CodeIndex/Cli/CliFlagSchema.cs | 4 +- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 81 +++++++-- .../Database/DbReader.FilesStatus.cs | 91 ++++++++-- src/CodeIndex/Mcp/McpToolHandlers.cs | 2 +- src/CodeIndex/Models/QueryResults.cs | 26 +++ tests/CodeIndex.Tests/ConsoleUiTests.cs | 2 +- .../QueryCommandRunnerSearchTests.cs | 162 +++++++++++++++++- 11 files changed, 367 insertions(+), 30 deletions(-) create mode 100644 changelog.d/unreleased/3560.added.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index bdbe9ecab5..9d2d75761e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -959,6 +959,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int | `--json-envelope` commands | Applies to `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `excerpt`, `map`, `inspect`, `outline`, `status`, `validate`, `languages`, `impact`, `deps`, `unused`, and `hotspots`. | | `--json-envelope` shape | Wraps the per-line `--json` stream into a single `{"metadata": {...}, "results": [...]}` document. `metadata` carries `api_version`, `command`, `cdidx_version`, `elapsed_ms`, `db_path`, `result_count`, `exit_code`, and, when applicable, `query_normalized` and `indexed_at_head_sha`. | | Envelope migration | `--json-envelope` implies `--json`, so callers do not need to pass both. The default output remains the legacy NDJSON / array form for one release; the envelope will become the default in the next major release, when the flat form becomes opt-in via `--json-flat`. | +| `find --all` scan summary | `find` requires either repeatable `--path ` filters or explicit `--all`. `--all` scans indexed files repo-wide with safety caps and cannot be combined with `--path`. JSON count output includes `candidate_files`, `files_scanned`, `lines_scanned`, `scan_truncated`, `scan_cap_reached`, optional `scan_truncation_reason`, and the active `candidate_file_limit` / `line_scan_limit`; human count output writes the same scan summary to stderr. | #### JSON output API version contract @@ -3099,6 +3100,7 @@ AI エージェント向け検索ルールのテンプレートについては | `--json-envelope` 対象 command | `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`find`、`excerpt`、`map`、`inspect`、`outline`、`status`、`validate`、`languages`、`impact`、`deps`、`unused`、`hotspots`。 | | `--json-envelope` shape | per-line `--json` stream を単一の `{"metadata": {...}, "results": [...]}` document に包みます。`metadata` は `api_version`、`command`、`cdidx_version`、`elapsed_ms`、`db_path`、`result_count`、`exit_code`、該当時は `query_normalized` と `indexed_at_head_sha` を持ちます。 | | envelope migration | `--json-envelope` は `--json` を imply するため、caller は両方を指定する必要がありません。既定 output は 1 release の間 legacy NDJSON / array form のままです。次の major release では envelope が既定になり、flat form は `--json-flat` による opt-in になります。 | +| `find --all` scan summary | `find` は repeatable な `--path ` か明示的な `--all` のどちらかを要求します。`--all` は safety cap 付きで index 済みファイルを repo-wide に走査し、`--path` とは併用できません。JSON count output は `candidate_files`、`files_scanned`、`lines_scanned`、`scan_truncated`、`scan_cap_reached`、任意の `scan_truncation_reason`、有効な `candidate_file_limit` / `line_scan_limit` を含みます。human count output は同じ scan summary を stderr に出します。 | #### JSON 出力 API バージョン契約 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 30ed7283e6..87600c0ea0 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1086,9 +1086,11 @@ cdidx excerpt src/CodeIndex/Cli/GitHelper.cs --start 19 --end 28 --before 3 --af ```bash cdidx find "graph table" --path src/CodeIndex/Cli/QueryCommandRunner.cs cdidx find "Graph Table" --path src/CodeIndex/Cli/QueryCommandRunner.cs --exact --before 1 --after 1 --json +cdidx find "guard" --all --count --json ``` `find` fills the gap between repo-wide `search` and line-number-based `excerpt`: when you already know the target file, it returns matching line numbers, columns, and short surrounding context from the indexed file without falling back to raw-text tools. The query text is capped at 1,000 characters, matching `search`. +Use `--path ` for a bounded file set, or pass `--all` to opt in to a repo-wide indexed-file scan with safety caps. `--all` and `--path` are mutually exclusive. Count JSON includes scan summary fields such as `candidate_files`, `files_scanned`, `lines_scanned`, `scan_truncated`, `scan_cap_reached`, `candidate_file_limit`, and `line_scan_limit`; human count output writes the scan summary to stderr. ### List files @@ -3442,9 +3444,11 @@ cdidx excerpt src/CodeIndex/Cli/GitHelper.cs --start 19 --end 28 --before 3 --af ```bash cdidx find "graph table" --path src/CodeIndex/Cli/QueryCommandRunner.cs cdidx find "Graph Table" --path src/CodeIndex/Cli/QueryCommandRunner.cs --exact --before 1 --after 1 --json +cdidx find "guard" --all --count --json ``` `find` は、リポジトリ全体を対象にする `search` と、行番号が必要な `excerpt` の間を埋めるコマンドです。対象ファイルが既に分かっているときに、raw text ツールへ戻らずに、インデックス済みファイルから一致行番号・列番号・短い前後文脈を返します。query text は `search` と同じく 1,000 文字までです。 +対象を絞る場合は `--path ` を使い、repo-wide の index 済みファイル走査が必要な場合だけ `--all` を明示します。`--all` と `--path` は併用できません。count JSON には `candidate_files`、`files_scanned`、`lines_scanned`、`scan_truncated`、`scan_cap_reached`、`candidate_file_limit`、`line_scan_limit` などの scan summary field が入り、human count output では同じ scan summary が stderr に出ます。 ### ファイル一覧 diff --git a/changelog.d/unreleased/3560.added.md b/changelog.d/unreleased/3560.added.md new file mode 100644 index 0000000000..401f0e3b2d --- /dev/null +++ b/changelog.d/unreleased/3560.added.md @@ -0,0 +1,21 @@ +--- +category: added +issues: + - 3560 +affected: + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Database/DbReader.FilesStatus.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Models/QueryResults.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +--- + +## English + +- **Explicit repository-wide find scans are now bounded and observable (#3560)** — `find --all` searches every indexed file with candidate-file and line-scan caps, and `find --count` now reports scan summaries in JSON and human output. + +## 日本語 + +- **リポジトリ全体の find scan を明示的かつ観測可能にしました (#3560)** — `find --all` は候補ファイル数と走査行数の上限付きで全インデックス済みファイルを検索し、`find --count` は JSON と human 出力で scan summary を返します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index d3468ab335..8c88c62bcd 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -127,7 +127,7 @@ internal static class CliFlagSchema private static readonly string[] RankByCommands = ["callers", "callees"]; private static readonly string[] ByBucketCommands = ["unused"]; private static readonly string[] UnusedFilterCommands = ["unused"]; - private static readonly string[] AllResultCommands = ["goto"]; + private static readonly string[] AllResultCommands = ["goto", "find"]; private static readonly string[] SinceCommands = ["search", "definition", "symbols", "files", "suggestions"]; private static readonly string[] ByteFormatCommands = ["files", "map"]; @@ -261,7 +261,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--by-bucket", Description = "Unused: include per-bucket grouped result arrays in JSON output", Commands = Set(ByBucketCommands) }, new() { Name = "--bucket", ValuePlaceholder = "", Description = "Unused: return only one confidence bucket", Commands = Set(UnusedFilterCommands) }, new() { Name = "--min-confidence", ValuePlaceholder = "", Description = "Unused: return symbols at or above this confidence", Commands = Set(UnusedFilterCommands) }, - new() { Name = "--all", Description = "goto: return all matching LSP locations instead of requiring a single target", Commands = Set(AllResultCommands) }, + new() { Name = "--all", Description = "goto: return all matching LSP locations; find: search all indexed files instead of requiring --path", Commands = Set(AllResultCommands) }, new() { Name = "--rank-by", ValuePlaceholder = "", Description = "Rank callers/callees by weighted structural score, raw count, or kind bucket", Commands = Set(RankByCommands) }, new() { Name = "--raw-kinds", Description = "Show raw reference kinds instead of logical graph kinds", Commands = Set(RawKindsCommands) }, new() { Name = "--count", Description = "Count only", Commands = Set(CountCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 7c9a9d91f7..83376a99ec 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -95,7 +95,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("callees", "cdidx callees |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--rank-by ] [--raw-kinds] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), ("symbols", "cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]"), ("files", "cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]"), - ("find", "cdidx find --path [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]"), + ("find", "cdidx find (--path |--all) [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]"), ("excerpt", "cdidx excerpt --start [--end ] [--before ] [--after ] [--max-line-width ] [--focus-line ] [--focus-column ] [--focus-length ] [--db ] [--json] [--verbose]"), ("map", "cdidx map [--db ] [--json] [--format ] [--pretty] [--compact] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--bytes] [--sections ] [--depth ] [--min-entrypoint-confidence <0.0..1.0>]"), ("inspect", "cdidx inspect |--query |-- [--db ] [--json] [--format ] [--pretty] [--compact] [--fields ] [--body-only] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--body-start ] [--body-lines ] [--max-line-width ] [--exact|--exact-name]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index f2643d2ee2..1bae257bd8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -26,6 +26,8 @@ public static class QueryCommandRunner internal const int DefaultDependencyCycleGraphLimit = 1_000; internal const int MaxWorkspaceDependencyDatabaseCount = 8; internal const int MaxWorkspaceDependencyDatabasePairCount = MaxWorkspaceDependencyDatabaseCount * (MaxWorkspaceDependencyDatabaseCount - 1); + internal const int FindAllCandidateFileLimit = 4096; + internal const int FindAllLineScanLimit = 250_000; internal const int BatchMaxLineChars = 1024 * 1024; internal const int BatchMaxArgumentCount = 256; internal const int BatchMaxArgumentChars = 8192; @@ -304,7 +306,7 @@ private sealed record StatusReadinessField( new( ValueTakingOptions.Concat(["--json", "--log-format", "--log-retain-count", "--log-max-size-mb"]), StringComparer.Ordinal); - private const string FindUsage = "Usage: cdidx find --path [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]\n cdidx find --query --path [...]\n cdidx find [options] -- "; + private const string FindUsage = "Usage: cdidx find (--path |--all) [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--exclude-path ] [--exclude-tests] [--before ] [--after ] [--snippet-lines ] [--focus-line ] [--focus-column ] [--max-line-width ] [--exact] [--regex] [--count]\n cdidx find --query (--path |--all) [...]\n cdidx find [options] -- "; public static int RunBatch(string[] cmdArgs, JsonSerializerOptions jsonOptions) { @@ -2922,21 +2924,32 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; } - if (options.PathPatterns.Count == 0) + if (options.PathPatterns.Count == 0 && !options.All) { - Console.Error.WriteLine("Error: find requires at least one --path to scope the search to known files"); + Console.Error.WriteLine("Error: find requires at least one --path or explicit --all to scope the search"); + Console.Error.WriteLine("Hint: use --path for a bounded file set, or --all to scan all indexed files with safety caps."); + Console.Error.WriteLine(FindUsage); + return CommandExitCodes.UsageError; + } + if (options.PathPatterns.Count > 0 && options.All) + { + Console.Error.WriteLine("Error: find accepts either --path or --all, not both"); + Console.Error.WriteLine("Hint: remove --all when using explicit path filters, or remove --path to scan all indexed files with caps."); Console.Error.WriteLine(FindUsage); return CommandExitCodes.UsageError; } return WithDb(options, jsonOptions, reader => { + var pathPatterns = options.All ? null : options.PathPatterns; + var candidateFileLimit = options.All ? FindAllCandidateFileLimit : (int?)null; + var lineLimit = options.All ? FindAllLineScanLimit : (int?)null; if (options.CountOnly) { - QueryCountResult counts; + FindCountResult counts; try { - counts = reader.CountFindInFiles(options.Query, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, options.Exact, options.FocusLine, options.FocusColumn, options.Regex); + counts = reader.CountFindInFiles(options.Query, options.Lang, pathPatterns, options.ExcludePaths, options.ExcludeTests, options.Exact, options.FocusLine, options.FocusColumn, options.Regex, candidateFileLimit, lineLimit); } catch (Exception ex) when (options.Regex && (ex is ArgumentException || ex is RegexMatchTimeoutException)) { @@ -2951,26 +2964,41 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) { payload["file_count"] = 0; }); + AddFindScanJsonFields(payload, counts.Scan); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else { Console.WriteLine("0"); + WriteFindScanSummary(counts.Scan); } return CommandExitCodes.Success; } - Console.WriteLine(options.Json - ? JsonSerializer.Serialize(new QueryFindCountJsonResult(counts.Count, counts.FileCount, counts.FileCount), CliJsonSerializerContextFactory.Create(jsonOptions).QueryFindCountJsonResult) - : $"{counts.Count}"); + if (options.Json) + { + var payload = new JsonObject + { + ["count"] = counts.Count, + ["files"] = counts.FileCount, + ["file_count"] = counts.FileCount, + }; + AddFindScanJsonFields(payload, counts.Scan); + Console.WriteLine(payload.ToJsonString(jsonOptions)); + } + else + { + Console.WriteLine($"{counts.Count}"); + WriteFindScanSummary(counts.Scan); + } return CommandExitCodes.Success; } var (contextBefore, contextAfter, snippetLines) = ResolveFindContext(options, preparedFindArgs); - List results; + FindResults findResults; try { - results = reader.FindInFiles(options.Query, options.Limit, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, contextBefore, contextAfter, options.Exact, options.MaxLineWidth, options.FocusLine, options.FocusColumn, options.Regex); + findResults = reader.FindInFiles(options.Query, options.Limit, options.Lang, pathPatterns, options.ExcludePaths, options.ExcludeTests, contextBefore, contextAfter, options.Exact, options.MaxLineWidth, options.FocusLine, options.FocusColumn, options.Regex, candidateFileLimit, lineLimit); } catch (ArgumentException ex) when (options.Regex) { @@ -2982,9 +3010,10 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine($"Error: invalid regular expression: {ex.Message}"); return CommandExitCodes.UsageError; } + var results = findResults.Results; if (results.Count == 0) { - var candidateFileCount = reader.CountFindCandidateFiles(options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests); + var candidateFileCount = findResults.Scan.CandidateFiles; if (options.Json) { if (TryWriteEmptyFormattedResult(options, jsonOptions)) @@ -3002,6 +3031,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) payload["regex"] = options.Regex; payload["file_count"] = candidateFileCount; }); + AddFindScanJsonFields(payload, findResults.Scan); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -3055,6 +3085,7 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) } var fileCount = results.Select(r => r.Path).Distinct().Count(); Console.Error.WriteLine($"({results.Count} matches in {fileCount} files)"); + WriteFindScanSummary(findResults.Scan); } return CommandExitCodes.Success; }); @@ -6380,6 +6411,7 @@ public static QueryCommandOptions ParseArgs( int? bodyStartLine = null; int? bodyLines = null; bool countOnly = false; + bool all = false; bool strictNotFound = false; int? startLine = null; int? endLine = null; @@ -6911,6 +6943,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) case "--by-bucket": break; case "--all": + all = true; break; case "--no-dedup": noDedup = true; @@ -7370,6 +7403,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) ExcludeTests = excludeTests, IncludeGenerated = includeGenerated, CountOnly = countOnly, + All = all, StrictNotFound = strictNotFound, Strict = strict, Since = since, @@ -8647,6 +8681,30 @@ private static bool TryWriteUnsupportedOutputFormat(string commandName, QueryCom return true; } + private static void AddFindScanJsonFields(JsonObject payload, FindScanSummary scan) + { + payload["candidate_files"] = scan.CandidateFiles; + payload["files_scanned"] = scan.FilesScanned; + payload["lines_scanned"] = scan.LinesScanned; + payload["scan_truncated"] = scan.Truncated; + payload["scan_cap_reached"] = scan.CapReached; + payload["scan_timed_out"] = scan.TimedOut; + if (scan.TruncationReason != null) + payload["scan_truncation_reason"] = scan.TruncationReason; + if (scan.CandidateFileLimit.HasValue) + payload["candidate_file_limit"] = scan.CandidateFileLimit.Value; + if (scan.LineLimit.HasValue) + payload["line_scan_limit"] = scan.LineLimit.Value; + } + + private static void WriteFindScanSummary(FindScanSummary scan) + { + var summary = $"scanned {scan.FilesScanned}/{scan.CandidateFiles} candidate files, {ConsoleUi.Counted(scan.LinesScanned, "line")}"; + if (scan.Truncated) + summary += scan.TruncationReason == null ? "; truncated" : $"; truncated by {scan.TruncationReason}"; + Console.Error.WriteLine($"({summary})"); + } + // Reject queries that were supplied but resolve to empty / whitespace-only text so the user gets // a distinct error instead of the generic " requires a query argument" message that fires // when the positional was actually missing. The null case is left to the existing missing-query @@ -10527,6 +10585,7 @@ public sealed class QueryCommandOptions public bool ExcludeTests { get; init; } public bool IncludeGenerated { get; init; } public bool CountOnly { get; init; } + public bool All { get; init; } public bool StrictNotFound { get; init; } public bool Strict { get; init; } public DateTime? Since { get; init; } diff --git a/src/CodeIndex/Database/DbReader.FilesStatus.cs b/src/CodeIndex/Database/DbReader.FilesStatus.cs index b90208a627..9f3d5ca88a 100644 --- a/src/CodeIndex/Database/DbReader.FilesStatus.cs +++ b/src/CodeIndex/Database/DbReader.FilesStatus.cs @@ -8,10 +8,10 @@ namespace CodeIndex.Database; public partial class DbReader { - public List FindInFiles(string query, int limit, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, int before = 0, int after = 0, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? focusLine = null, int? focusColumn = null, bool regex = false) + public FindResults FindInFiles(string query, int limit, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, int before = 0, int after = 0, bool exact = false, int maxLineWidth = LineWidthFormatter.DefaultMaxLineWidth, int? focusLine = null, int? focusColumn = null, bool regex = false, int? maxCandidateFiles = null, int? maxLinesScanned = null) { - if (string.IsNullOrWhiteSpace(query) || limit <= 0 || pathPatterns == null || pathPatterns.Count == 0) - return []; + if (string.IsNullOrWhiteSpace(query) || limit <= 0) + return new FindResults([], new FindScanSummary(0, 0, 0)); before = Math.Max(0, before); after = Math.Max(0, after); @@ -32,17 +32,29 @@ public List FindInFiles(string query, int limit, string? lang = fileCmd.Parameters.AddWithValue("@lang", lang); AddPathFilterParameters(fileCmd, pathPatterns, excludePathPatterns); + var candidateFiles = CountFindCandidateFiles(lang, pathPatterns, excludePathPatterns, excludeTests); + var filesScanned = 0; + var linesScanned = 0; + var truncated = false; + string? truncationReason = null; var results = new List(); using var fileReader = fileCmd.ExecuteTrackedReader(); while (fileReader.TrackedRead()) { if (results.Count >= limit) break; + if (maxCandidateFiles.HasValue && filesScanned >= maxCandidateFiles.Value) + { + truncated = true; + truncationReason ??= "candidate_file_limit"; + break; + } var fileId = fileReader.GetInt64(0); var path = fileReader.GetString(1); var fileLang = GetNullableString(fileReader, 2); var totalLines = fileReader.GetInt32(3); + filesScanned++; if (totalLines <= 0) continue; @@ -51,11 +63,20 @@ public List FindInFiles(string query, int limit, string? lang = var snippetWindow = new Queue(); var snippetLinesByNumber = new Dictionary(); var acceptedMatches = results.Count; + var stopScanning = false; foreach (var indexedLine in EnumerateIndexedFileLines(fileId)) { if (indexedLine.Number > totalLines) break; + if (maxLinesScanned.HasValue && linesScanned >= maxLinesScanned.Value) + { + truncated = true; + truncationReason ??= "line_scan_limit"; + stopScanning = true; + break; + } + linesScanned++; AddLineToFindWindow(indexedLine, snippetWindow, snippetLinesByNumber); @@ -105,16 +126,27 @@ public List FindInFiles(string query, int limit, string? lang = results, maxLineWidth, int.MaxValue); + if (stopScanning) + break; } - return results; + var capReached = truncated; + return new FindResults( + results, + new FindScanSummary( + candidateFiles, + filesScanned, + linesScanned, + truncated, + capReached, + TimedOut: false, + truncationReason, + maxCandidateFiles, + maxLinesScanned)); } public int CountFindCandidateFiles(string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false) { - if (pathPatterns == null || pathPatterns.Count == 0) - return 0; - using var fileCmd = _conn.CreateCommand(); var sql = "SELECT COUNT(*) FROM files f WHERE 1=1"; if (lang != null) @@ -128,10 +160,10 @@ public int CountFindCandidateFiles(string? lang = null, IReadOnlyList? p return Convert.ToInt32(fileCmd.ExecuteScalar(), CultureInfo.InvariantCulture); } - public QueryCountResult CountFindInFiles(string query, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int? focusLine = null, int? focusColumn = null, bool regex = false) + public FindCountResult CountFindInFiles(string query, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool exact = false, int? focusLine = null, int? focusColumn = null, bool regex = false, int? maxCandidateFiles = null, int? maxLinesScanned = null) { - if (string.IsNullOrWhiteSpace(query) || pathPatterns == null || pathPatterns.Count == 0) - return new QueryCountResult(0, 0); + if (string.IsNullOrWhiteSpace(query)) + return new FindCountResult(0, 0, new FindScanSummary(0, 0, 0)); var comparison = exact ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; var regexMatcher = regex @@ -148,23 +180,45 @@ public QueryCountResult CountFindInFiles(string query, string? lang = null, IRea fileCmd.Parameters.AddWithValue("@lang", lang); AddPathFilterParameters(fileCmd, pathPatterns, excludePathPatterns); + var candidateFiles = CountFindCandidateFiles(lang, pathPatterns, excludePathPatterns, excludeTests); + var filesScanned = 0; + var linesScanned = 0; + var truncated = false; + string? truncationReason = null; var count = 0; var fileCount = 0; using var fileReader = fileCmd.ExecuteTrackedReader(); while (fileReader.TrackedRead()) { + if (maxCandidateFiles.HasValue && filesScanned >= maxCandidateFiles.Value) + { + truncated = true; + truncationReason ??= "candidate_file_limit"; + break; + } + var fileId = fileReader.GetInt64(0); var fileLang = GetNullableString(fileReader, 2); var totalLines = fileReader.GetInt32(3); + filesScanned++; if (totalLines <= 0) continue; var searchQuery = exact && !regex ? ExactSourceSearchNormalizer.Normalize(query, fileLang) : query; var fileMatches = 0; + var stopScanning = false; foreach (var indexedLine in EnumerateIndexedFileLines(fileId)) { if (indexedLine.Number > totalLines) break; + if (maxLinesScanned.HasValue && linesScanned >= maxLinesScanned.Value) + { + truncated = true; + truncationReason ??= "line_scan_limit"; + stopScanning = true; + break; + } + linesScanned++; if (focusLine.HasValue && indexedLine.Number != focusLine.Value) continue; @@ -187,9 +241,24 @@ public QueryCountResult CountFindInFiles(string query, string? lang = null, IRea count += fileMatches; fileCount++; } + if (stopScanning) + break; } - return new QueryCountResult(count, fileCount); + var capReached = truncated; + return new FindCountResult( + count, + fileCount, + new FindScanSummary( + candidateFiles, + filesScanned, + linesScanned, + truncated, + capReached, + TimedOut: false, + truncationReason, + maxCandidateFiles, + maxLinesScanned)); } private readonly record struct IndexedLine(int Number, string Text); diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 33498999d0..eabb2e2387 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2824,7 +2824,7 @@ private JsonNode ExecuteFindInFile(JsonNode? id, JsonNode? args) List results; try { - results = reader.FindInFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, before, after, exact, maxLineWidth, focusLine, focusColumn, regex); + results = reader.FindInFiles(query, limit, lang, pathPatterns, excludePaths, excludeTests, before, after, exact, maxLineWidth, focusLine, focusColumn, regex).Results; } catch (Exception ex) when (regex && (ex is ArgumentException || ex is RegexMatchTimeoutException)) { diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 59050b1715..b78f3bc3c3 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -43,6 +43,32 @@ public class SearchResult public readonly record struct QueryCountResult(int Count, int FileCount, bool IncludesSql = false); +public readonly record struct FindScanSummary( + int CandidateFiles, + int FilesScanned, + int LinesScanned, + bool Truncated = false, + bool CapReached = false, + bool TimedOut = false, + string? TruncationReason = null, + int? CandidateFileLimit = null, + int? LineLimit = null); + +public readonly record struct FindCountResult(int Count, int FileCount, FindScanSummary Scan); + +public readonly record struct FindResults(List Results, FindScanSummary Scan) : IReadOnlyList +{ + public int Count => Results.Count; + + public FileFindResult this[int index] => Results[index]; + + public List.Enumerator GetEnumerator() => Results.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => Results.GetEnumerator(); + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => Results.GetEnumerator(); +} + public readonly record struct HotspotCountResult(int Count, int FileCount, int DefinitionSiteTotal = 0); public enum SearchGuardRole diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index ed5a1f1e3a..7e9a2ebfde 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -122,7 +122,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("--snippet-lines search/find snippet length (1-20, default: search 8; find 1)", output); Assert.Contains("--snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)", output); Assert.Contains("--max-line-width search/references/callers/callees/find/excerpt/impact/inspect only: clamp very long single-line snippet/context/excerpt payloads (`0` disables clamping; default: 512)", output); - Assert.Contains("cdidx find --path ", output); + Assert.Contains("cdidx find (--path |--all)", output); Assert.Contains("--fts Use raw FTS5 query syntax for search (content:term, NEAR(a b, 5), OR, NOT, groups, prefix*, \"phrase\"; search query max 1000 chars; raw FTS parser max 2000 chars, 64 boolean ops, 16 NEAR ops", output); Assert.Contains("--exact Backward-compatible shorthand.", output); Assert.Contains(" Prefer --exact-substring for search,", output); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index be20ee9f03..d7ba4f839a 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -3716,7 +3716,163 @@ public void RunFind_RequiresPathScope() _jsonOptions)); Assert.Equal(CommandExitCodes.UsageError, exitCode); - Assert.Contains("requires at least one --path", stderr); + Assert.Contains("requires at least one --path or explicit --all", stderr); + } + + [Fact] + public void RunFind_AllAndPathScopeFailsClosed_Issue3560() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind( + ["guard", "--path", "src/*.cs", "--all"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("find accepts either --path or --all, not both", stderr); + } + + [Fact] + public void RunFind_AllScopeCountJsonIncludesScanSummary_Issue3560() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_find_all_count_json_3560"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.txt", + "text", + "alpha\nbeta\n"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "docs/readme.txt", + "text", + "gamma\nalpha\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind( + ["alpha", "--db", dbPath, "--all", "--json", "--count"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(2, json.GetProperty("count").GetInt32()); + Assert.Equal(2, json.GetProperty("files").GetInt32()); + Assert.Equal(2, json.GetProperty("file_count").GetInt32()); + Assert.Equal(2, json.GetProperty("candidate_files").GetInt32()); + Assert.Equal(2, json.GetProperty("files_scanned").GetInt32()); + Assert.Equal(4, json.GetProperty("lines_scanned").GetInt32()); + Assert.False(json.GetProperty("scan_truncated").GetBoolean()); + Assert.False(json.GetProperty("scan_cap_reached").GetBoolean()); + Assert.False(json.GetProperty("scan_timed_out").GetBoolean()); + Assert.Equal(QueryCommandRunner.FindAllCandidateFileLimit, json.GetProperty("candidate_file_limit").GetInt32()); + Assert.Equal(QueryCommandRunner.FindAllLineScanLimit, json.GetProperty("line_scan_limit").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunFind_AllScopeHumanCountIncludesScanSummary_Issue3560() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_find_all_count_human_3560"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.txt", + "text", + "alpha\nbeta\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind( + ["alpha", "--db", dbPath, "--all", "--count"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("1", stdout.Trim()); + Assert.Contains("scanned 1/1 candidate files, 2 lines", stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunFind_AllScopeRegexCountJsonIncludesScanSummary_Issue3560() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_find_all_regex_count_3560"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.txt", + "text", + "alpha\nbeta\n"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "docs/readme.txt", + "text", + "gamma\nalpha\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind( + ["alpha|gamma", "--db", dbPath, "--all", "--regex", "--json", "--count"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(3, json.GetProperty("count").GetInt32()); + Assert.Equal(2, json.GetProperty("files").GetInt32()); + Assert.Equal(2, json.GetProperty("candidate_files").GetInt32()); + Assert.Equal(2, json.GetProperty("files_scanned").GetInt32()); + Assert.Equal(4, json.GetProperty("lines_scanned").GetInt32()); + Assert.False(json.GetProperty("scan_truncated").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void CountFindInFiles_LineCapReportsTruncation_Issue3560() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_find_line_cap_3560"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.txt", + "text", + "alpha\nalpha\n"); + + using var db = new DbContext(dbPath); + var reader = new DbReader(db.Connection); + var counts = reader.CountFindInFiles("alpha", maxLinesScanned: 1); + + Assert.Equal(1, counts.Count); + Assert.Equal(1, counts.FileCount); + Assert.Equal(1, counts.Scan.LinesScanned); + Assert.True(counts.Scan.Truncated); + Assert.True(counts.Scan.CapReached); + Assert.False(counts.Scan.TimedOut); + Assert.Equal("line_scan_limit", counts.Scan.TruncationReason); + Assert.Equal(1, counts.Scan.LineLimit); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } } [Fact] @@ -3851,7 +4007,7 @@ public void RunFind_AllowsDashedLiteralViaQueryFlag() _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); + Assert.Contains("scanned 1/1 candidate files, 1 line", stderr); Assert.Equal("1", stdout.Trim()); } finally @@ -3878,7 +4034,7 @@ public void RunFind_AllowsDashedLiteralViaDoubleDash() _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); + Assert.Contains("scanned 1/1 candidate files, 1 line", stderr); Assert.Equal("1", stdout.Trim()); } finally From 56543b7f45f62b3c9718fbeb0264db7e6df9c957 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:15:15 +0900 Subject: [PATCH 3/3] Add count JSON readiness envelopes (#3566) --- DEVELOPER_GUIDE.md | 2 + USER_GUIDE.md | 21 +- changelog.d/unreleased/3566.fixed.md | 17 ++ src/CodeIndex/Cli/QueryCommandRunner.cs | 189 ++++++++++++++---- .../QueryCommandRunnerGraphTests.cs | 88 ++++++++ .../QueryCommandRunnerSearchTests.cs | 32 +++ .../QueryCommandRunnerTests.cs | 10 + 7 files changed, 311 insertions(+), 48 deletions(-) create mode 100644 changelog.d/unreleased/3566.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 9d2d75761e..02d3bd0c54 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -955,6 +955,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int |---|---| | Human-readable default | Query commands (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `excerpt`, `map`, `inspect`, `suggestions`) default to **human-readable output**. | | `--json` | Emits JSON lines output, one JSON object per line, designed for easy parsing by AI agents. | +| `--count --json` envelope | Count-only JSON for `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, and `impact` is a single automation-oriented object. It always includes `count`, applied `query_context`, freshness metadata (`indexed_file_count`, `indexed_at`, `freshness_available`), and trust flags `degraded` / `authoritative_count`; commands with matched-file totals also include `files` and compatibility alias `file_count`. `authoritative_count=false` means a readiness or graph/exact trust signal made the count non-authoritative, while the freshness fields describe the indexed snapshot used for the count. | | `search --json` sentinel | Appends a final `{"done":true,"count":N,"interrupted":false}` sentinel after result rows, including zero-result responses, so stream consumers can distinguish a clean end from a truncated/interrupted stream. | | `--json-envelope` commands | Applies to `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `excerpt`, `map`, `inspect`, `outline`, `status`, `validate`, `languages`, `impact`, `deps`, `unused`, and `hotspots`. | | `--json-envelope` shape | Wraps the per-line `--json` stream into a single `{"metadata": {...}, "results": [...]}` document. `metadata` carries `api_version`, `command`, `cdidx_version`, `elapsed_ms`, `db_path`, `result_count`, `exit_code`, and, when applicable, `query_normalized` and `indexed_at_head_sha`. | @@ -3096,6 +3097,7 @@ AI エージェント向け検索ルールのテンプレートについては |---|---| | human-readable default | query command(`search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`excerpt`、`map`、`inspect`、`suggestions`)は既定で**人間向け出力**です。 | | `--json` | JSON lines output(1 行 1 JSON object)に切り替えます。AI agent が容易に parse できるよう設計されています。 | +| `--count --json` envelope | `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`find`、`impact` の count-only JSON は単一の自動化向け object です。常に `count`、適用済み `query_context`、freshness metadata(`indexed_file_count`、`indexed_at`、`freshness_available`)、trust flag の `degraded` / `authoritative_count` を含みます。matched-file total を持つ command は `files` と互換 alias の `file_count` も含みます。`authoritative_count=false` は readiness または graph/exact trust signal により count が authoritative ではないことを示し、freshness field は count に使った index snapshot を説明します。 | | `search --json` sentinel | result row の後に、0 件応答も含めて最後の `{"done":true,"count":N,"interrupted":false}` sentinel を追加します。stream consumer は clean end と truncated / interrupted stream を区別できます。 | | `--json-envelope` 対象 command | `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`find`、`excerpt`、`map`、`inspect`、`outline`、`status`、`validate`、`languages`、`impact`、`deps`、`unused`、`hotspots`。 | | `--json-envelope` shape | per-line `--json` stream を単一の `{"metadata": {...}, "results": [...]}` document に包みます。`metadata` は `api_version`、`command`、`cdidx_version`、`elapsed_ms`、`db_path`、`result_count`、`exit_code`、該当時は `query_normalized` と `indexed_at_head_sha` を持ちます。 | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 87600c0ea0..6159dc1b99 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -319,6 +319,14 @@ source line hits the body byte cap, continuation still advances to the following source line because body paging is line-based. `inspect --json` also includes `body_mode` metadata so clients can see whether body content was requested, whether it is present, and which follow-up flags to use. +Count-only JSON (`--count --json` or `--format count` where supported) is a +single object with `count`, applied `query_context`, freshness metadata +(`indexed_file_count`, `indexed_at`, `freshness_available`), and trust flags +`degraded` / `authoritative_count`. Commands that count matched files also +include `files`; the older `file_count` field remains as a compatibility alias +with the same value and is not scheduled for removal before the next major +release. New consumers should read `files` and treat `authoritative_count=false` +as a signal to inspect the accompanying readiness or graph/exact trust fields. ```bash cdidx search authenticate --json # ndjson stream, one result per line @@ -329,11 +337,6 @@ cdidx inspect Compute --body-only # definitions with body_content only cdidx inspect Compute --body --body-start 40 --body-lines 40 ``` -For `cdidx find --count --json`, `files` is the canonical matched-file count. -The older `file_count` field remains as a deprecated compatibility alias with -the same value and is not scheduled for removal before the next major release; -new consumers should read `files`. - ## Editor and index portability Use `cdidx export ctags` when an editor wants the traditional ctags file format @@ -2669,6 +2672,14 @@ shorthand です。definition body が返却 slice より長い場合は `--body-lines` で page size を指定できます。`inspect --json` には `body_mode` metadata も含まれるため、body content が要求済みか、存在するか、次に使う flag が何かを client 側で判断できます。 +count-only JSON(対応 command の `--count --json` または `--format count`)は、 +`count`、適用済み `query_context`、freshness metadata(`indexed_file_count`、 +`indexed_at`、`freshness_available`)、trust flag の `degraded` / +`authoritative_count` を持つ単一 object です。matched file を数える command は +`files` も含みます。古い `file_count` field は同じ値の互換 alias として残っており、 +少なくとも次の major release までは削除予定はありません。新しい consumer は +`files` を読み、`authoritative_count=false` の場合は同じ payload の readiness または +graph/exact trust field を確認してください。 ```bash cdidx search authenticate --json # ndjson stream、1 行 1 result diff --git a/changelog.d/unreleased/3566.fixed.md b/changelog.d/unreleased/3566.fixed.md new file mode 100644 index 0000000000..8128736dd0 --- /dev/null +++ b/changelog.d/unreleased/3566.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 3566 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - tests/CodeIndex.Tests/QueryCommandRunnerTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs +--- + +## English + +- **Count-only JSON responses now include a stable automation envelope (#3566)** — `search`, `symbols`, `definition`, `files`, `references`, `callers`, `callees`, `impact`, and `find` count JSON include applied query context, freshness metadata, file-count aliases where relevant, and `authoritative_count`/`degraded` readiness signals. + +## 日本語 + +- **count-only JSON に自動化向け envelope を追加しました (#3566)** — `search`、`symbols`、`definition`、`files`、`references`、`callers`、`callees`、`impact`、`find` の count JSON が、適用済み query context、freshness メタデータ、必要な file count alias、`authoritative_count` / `degraded` の readiness signal を含むようになりました。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 1bae257bd8..ce72b6b28f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -668,7 +668,15 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) { if (options.Json) { - Console.WriteLine(BuildJsonZeroResultPayload(reader, jsonOptions, includeFiles: true, query: options.Query, ftsQueryDiagnostics: queryDiagnostics, queryOptions: options, exactSubstringHint: exactSubstringHint).ToJsonString(jsonOptions)); + Console.WriteLine(BuildCountJsonPayload( + reader, + jsonOptions, + count: 0, + files: 0, + query: options.Query, + queryOptions: options, + ftsQueryDiagnostics: queryDiagnostics, + exactSubstringHint: exactSubstringHint).ToJsonString(jsonOptions)); } else { @@ -680,7 +688,15 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (options.Json) { - Console.WriteLine(JsonSerializer.Serialize(new QueryCountFilesJsonResult(counts.Count, counts.FileCount, options.Query), CliJsonSerializerContextFactory.Create(jsonOptions).QueryCountFilesJsonResult)); + Console.WriteLine(BuildCountJsonPayload( + reader, + jsonOptions, + counts.Count, + counts.FileCount, + query: options.Query, + queryOptions: options, + ftsQueryDiagnostics: queryDiagnostics, + exactSubstringHint: exactSubstringHint).ToJsonString(jsonOptions)); } else { @@ -1675,20 +1691,14 @@ public static int RunDefinition(string[] cmdArgs, JsonSerializerOptions jsonOpti if (counts.Count == 0) { Console.WriteLine(options.Json - ? BuildJsonZeroResultPayload(reader, jsonOptions, includeFiles: true, exactZeroHint: exactZeroHintForCount, exactSignal: exact ? exactSignalForCount : null, queryOptions: options).ToJsonString(jsonOptions) + ? BuildCountJsonPayload(reader, jsonOptions, count: 0, files: 0, query: options.Query, exactZeroHint: exactZeroHintForCount, exactSignal: exact ? exactSignalForCount : null, queryOptions: options).ToJsonString(jsonOptions) : "0"); return CommandExitCodes.Success; } if (options.Json) { - var payload = new JsonObject - { - ["count"] = counts.Count, - ["files"] = counts.FileCount, - }; - if (exact) - AddExactJsonFields(payload, exactSignalForCount); + var payload = BuildCountJsonPayload(reader, jsonOptions, counts.Count, counts.FileCount, query: options.Query, exactSignal: exact ? exactSignalForCount : null, queryOptions: options); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -2543,20 +2553,14 @@ public static int RunSymbols(string[] cmdArgs, JsonSerializerOptions jsonOptions if (counts.Count == 0) { Console.WriteLine(options.Json - ? BuildJsonZeroResultPayload(reader, jsonOptions, includeFiles: true, exactZeroHint: exactZeroHintForCount, exactSignal: hasExactPredicateForCount ? exactSignalForCount : null, queryOptions: options).ToJsonString(jsonOptions) + ? BuildCountJsonPayload(reader, jsonOptions, count: 0, files: 0, query: options.Query, exactZeroHint: exactZeroHintForCount, exactSignal: hasExactPredicateForCount ? exactSignalForCount : null, queryOptions: options).ToJsonString(jsonOptions) : "0"); return CommandExitCodes.Success; } if (options.Json) { - var payload = new JsonObject - { - ["count"] = counts.Count, - ["files"] = counts.FileCount, - }; - if (hasExactPredicateForCount) - AddExactJsonFields(payload, exactSignalForCount); + var payload = BuildCountJsonPayload(reader, jsonOptions, counts.Count, counts.FileCount, query: options.Query, exactSignal: hasExactPredicateForCount ? exactSignalForCount : null, queryOptions: options); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -2651,13 +2655,13 @@ public static int RunFiles(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (counts.Count == 0) { Console.WriteLine(options.Json - ? BuildJsonZeroResultPayload(reader, jsonOptions).ToJsonString(jsonOptions) + ? BuildCountJsonPayload(reader, jsonOptions, count: 0, files: 0, query: options.Query, queryOptions: options).ToJsonString(jsonOptions) : "0"); return CommandExitCodes.Success; } Console.WriteLine(options.Json - ? JsonSerializer.Serialize(new QueryCountJsonResult(counts.Count), CliJsonSerializerContextFactory.Create(jsonOptions).QueryCountJsonResult) + ? BuildCountJsonPayload(reader, jsonOptions, counts.Count, counts.Count, query: options.Query, queryOptions: options).ToJsonString(jsonOptions) : $"{counts.Count}"); return CommandExitCodes.Success; } @@ -2960,11 +2964,14 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) { if (options.Json) { - var payload = BuildJsonZeroResultPayload(reader, jsonOptions, includeFiles: true, queryOptions: options, extraFields: static payload => - { - payload["file_count"] = 0; - }); - AddFindScanJsonFields(payload, counts.Scan); + var payload = BuildCountJsonPayload( + reader, + jsonOptions, + count: 0, + files: 0, + query: options.Query, + queryOptions: options, + extraFields: payload => AddFindScanJsonFields(payload, counts.Scan)); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -2977,13 +2984,14 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (options.Json) { - var payload = new JsonObject - { - ["count"] = counts.Count, - ["files"] = counts.FileCount, - ["file_count"] = counts.FileCount, - }; - AddFindScanJsonFields(payload, counts.Scan); + var payload = BuildCountJsonPayload( + reader, + jsonOptions, + counts.Count, + counts.FileCount, + query: options.Query, + queryOptions: options, + extraFields: payload => AddFindScanJsonFields(payload, counts.Scan)); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -4616,6 +4624,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) ["query"] = options.Query, ["resolved_name"] = analysis.ResolvedName, ["count"] = 0, + ["files"] = 0, ["file_count"] = 0, ["confirmed_count"] = 0, ["confirmed_file_count"] = 0, @@ -4640,8 +4649,8 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (!analysis.GraphTableAvailable) payload["note"] = "symbol_references table is missing in this index (legacy or read-only DB). Zero result is degraded, not authoritative."; AddSqlGraphContractJsonFields(payload, sqlGraphSignal); - AddFreshnessHint(payload, reader); AddImpactOptionWarnings(payload, options); + AddCountEnvelopeJsonFields(payload, reader, jsonOptions, options); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -4715,6 +4724,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) ["query"] = options.Query, ["resolved_name"] = analysis.ResolvedName, ["count"] = visibleCount, + ["files"] = visibleFileCount, ["file_count"] = visibleFileCount, ["confirmed_count"] = confirmedCount, ["confirmed_file_count"] = confirmedFileCount, @@ -4729,6 +4739,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) payload["truncated_reason"] = analysis.TruncatedReason; AddSqlGraphContractJsonFields(payload, sqlGraphSignal); AddImpactOptionWarnings(payload, options); + AddCountEnvelopeJsonFields(payload, reader, jsonOptions, options); Console.WriteLine(payload.ToJsonString(jsonOptions)); } else @@ -8907,14 +8918,24 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso query["since"] = options.Since.Value; if (options.CountOnly) query["count"] = true; + if (options.All) + query["all"] = true; if (options.RawFts) query["fts"] = true; + if (options.Regex) + query["regex"] = true; if (options.Exact) query["exact"] = true; if (options.Prefix) query["prefix"] = true; if (options.NoDedup) query["dedup"] = false; + if (options.RawKinds) + query["raw_kinds"] = true; + if (options.FocusLine.HasValue) + query["focus_line"] = options.FocusLine.Value; + if (options.FocusColumn.HasValue) + query["focus_column"] = options.FocusColumn.Value; if (options.ContextBefore > 0) query["before"] = options.ContextBefore; if (options.ContextAfter > 0) @@ -8975,6 +8996,87 @@ private static void AddFreshnessHint(JsonObject payload, DbReader reader) payload["freshness_degraded_reason"] = freshness.FreshnessDegradedReason; } + private static JsonObject BuildCountJsonPayload( + DbReader reader, + JsonSerializerOptions jsonOptions, + int count, + int? files = null, + string? query = null, + QueryCommandOptions? queryOptions = null, + bool? graphTableAvailable = null, + bool degraded = false, + ExactQuerySignal? exactSignal = null, + ExactZeroHintResult? exactZeroHint = null, + FtsQueryDiagnostics? ftsQueryDiagnostics = null, + SearchQueryHint? exactSubstringHint = null, + Action? extraFields = null, + bool deferAuthority = false) + { + var payload = new JsonObject + { + ["count"] = count, + }; + if (files.HasValue) + { + payload["files"] = files.Value; + payload["file_count"] = files.Value; + } + if (query != null) + payload["query"] = query; + if (graphTableAvailable.HasValue) + payload["graph_table_available"] = graphTableAvailable.Value; + if (degraded) + payload["degraded"] = true; + if (exactSignal.HasValue) + AddExactJsonFields(payload, exactSignal.Value); + if (exactZeroHint != null) + payload["exact_zero_hint"] = JsonSerializer.SerializeToNode(exactZeroHint, CliJsonSerializerContextFactory.Create(jsonOptions).ExactZeroHintResult); + if (ftsQueryDiagnostics is { HasDegradation: true }) + { + payload["query_degraded_reason"] = ftsQueryDiagnostics.QueryDegradedReason; + payload["tokens_dropped"] = JsonSerializer.SerializeToNode(ftsQueryDiagnostics.TokensDropped.ToList(), CliJsonSerializerContextFactory.Create(jsonOptions).ListString); + } + if (exactSubstringHint != null) + payload["exact_substring_hint"] = BuildSearchQueryHintJson(exactSubstringHint); + extraFields?.Invoke(payload); + AddCountEnvelopeJsonFields(payload, reader, jsonOptions, queryOptions, deferAuthority); + return payload; + } + + private static void AddCountEnvelopeJsonFields(JsonObject payload, DbReader reader, JsonSerializerOptions jsonOptions, QueryCommandOptions? queryOptions, bool deferAuthority = false) + { + if (queryOptions != null) + payload["query_context"] = BuildQueryContextJson(queryOptions, jsonOptions); + AddFreshnessHint(payload, reader); + if (!deferAuthority) + AddCountAuthorityJsonFields(payload); + } + + private static void AddCountAuthorityJsonFields(JsonObject payload) + { + var degraded = + JsonBool(payload, "degraded") == true + || JsonBool(payload, "graph_table_available") == false + || JsonBool(payload, "exact_index_available") == false + || JsonBool(payload, "sql_graph_contract_ready") == false + || JsonBool(payload, "graph_degraded") == true + || JsonBool(payload, "scan_truncated") == true + || JsonBool(payload, "scan_cap_reached") == true + || JsonBool(payload, "scan_timed_out") == true + || JsonBool(payload, "truncated") == true; + payload["degraded"] = degraded; + payload["authoritative_count"] = !degraded; + } + + private static bool? JsonBool(JsonObject payload, string name) + { + return payload.TryGetPropertyValue(name, out var node) + && node is JsonValue value + && value.TryGetValue(out var boolValue) + ? boolValue + : null; + } + private static JsonObject BuildJsonZeroResultPayload( DbReader reader, JsonSerializerOptions jsonOptions, @@ -9956,22 +10058,23 @@ private static void WriteGraphCountResult(DbReader reader, int count, int files, return; } - var payload = new JsonObject - { - ["count"] = count, - ["files"] = files, - ["graph_table_available"] = graphAvailable, - }; - if (!graphAvailable) - payload["degraded"] = true; + var payload = BuildCountJsonPayload( + reader, + jsonOptions, + count, + files, + query: options.Query, + queryOptions: options, + graphTableAvailable: graphAvailable, + degraded: !graphAvailable, + deferAuthority: true); AddGraphSupportOverrideFields(payload, graphSupportOverride); if (options.Exact || options.ExactName) AddExactGraphJsonFields(payload, exactSignal); if (exactZeroHint != null) payload["exact_zero_hint"] = JsonSerializer.SerializeToNode(exactZeroHint, CliJsonSerializerContextFactory.Create(jsonOptions).ExactZeroHintResult); extraFields?.Invoke(payload); - if (count == 0) - AddFreshnessHint(payload, reader); + AddCountAuthorityJsonFields(payload); Console.WriteLine(payload.ToJsonString(jsonOptions)); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs index ba4ea5d4ea..d5b9c4d7a6 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerGraphTests.cs @@ -517,6 +517,42 @@ public void RunReferences_JsonZeroResults_WithMissingGraphTable_ReturnsDegradedP } } + [Fact] + public void RunReferences_CountOnlyJson_WithMissingGraphTable_ReturnsNonAuthoritativeEnvelope_Issue3566() + { + var (projectRoot, readOnlyUri) = CreateReadOnlyMissingGraphTableDb("cdidx_references_count_json_missing_graph_3566"); + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunReferences( + ["Run", "--db", readOnlyUri, "--json", "--exact", "--count", "--lang", "csharp"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var queryContext = json.GetProperty("query_context"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(0, json.GetProperty("count").GetInt32()); + Assert.Equal(0, json.GetProperty("files").GetInt32()); + Assert.Equal(0, json.GetProperty("file_count").GetInt32()); + Assert.False(json.GetProperty("graph_table_available").GetBoolean()); + Assert.True(json.GetProperty("degraded").GetBoolean()); + Assert.False(json.GetProperty("authoritative_count").GetBoolean()); + Assert.False(json.GetProperty("exact_index_available").GetBoolean()); + Assert.True(json.GetProperty("freshness_available").GetBoolean()); + Assert.True(json.GetProperty("indexed_file_count").GetInt32() > 0); + Assert.Equal("Run", queryContext.GetProperty("text").GetString()); + Assert.Equal("csharp", queryContext.GetProperty("lang").GetString()); + Assert.True(queryContext.GetProperty("count").GetBoolean()); + Assert.True(queryContext.GetProperty("exact").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunReferences_ExactJson_CSharpInterpolatedRawStringPreservesCallSite() { @@ -1607,7 +1643,11 @@ public void RunImpact_CountOnlyJson_StaleSqlGraphContractIncludesDegradedState() Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); Assert.Equal(1, json.GetProperty("count").GetInt32()); + Assert.Equal(1, json.GetProperty("files").GetInt32()); + Assert.Equal(1, json.GetProperty("file_count").GetInt32()); Assert.False(json.GetProperty("sql_graph_contract_ready").GetBoolean()); + Assert.True(json.GetProperty("degraded").GetBoolean()); + Assert.False(json.GetProperty("authoritative_count").GetBoolean()); Assert.Contains("sql_graph_contract_ready=false", json.GetProperty("sql_graph_contract_degraded_reason").GetString()); } finally @@ -12012,11 +12052,59 @@ public void Run(FolderDiffService service) Assert.Equal(string.Empty, stderr); Assert.Equal("file_dependency_hints", json.GetProperty("impact_mode").GetString()); Assert.Equal(1, json.GetProperty("count").GetInt32()); + Assert.Equal(1, json.GetProperty("files").GetInt32()); Assert.Equal(1, json.GetProperty("file_count").GetInt32()); Assert.Equal(0, json.GetProperty("confirmed_count").GetInt32()); Assert.Equal(0, json.GetProperty("confirmed_file_count").GetInt32()); Assert.Equal(1, json.GetProperty("hint_count").GetInt32()); Assert.Equal(1, json.GetProperty("hint_file_count").GetInt32()); + Assert.False(json.GetProperty("degraded").GetBoolean()); + Assert.True(json.GetProperty("authoritative_count").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunImpact_CountOnlyJson_UserLimitTruncationIsNonAuthoritative_Issue3566() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_impact_count_truncated_authority_3566"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/lib.py", "python", + """ + def target(): + return 0 + """); + for (int i = 0; i < 6; i++) + { + TestProjectHelper.InsertIndexedFile(dbPath, $"src/caller_{i:D2}.py", "python", + $$""" + def caller_{{i:D2}}(): + return target() + """); + } + MarkGraphAndFoldReady(dbPath); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( + ["target", "--db", dbPath, "--json", "--count", "--limit", "2"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(2, json.GetProperty("count").GetInt32()); + Assert.Equal(2, json.GetProperty("files").GetInt32()); + Assert.Equal(2, json.GetProperty("file_count").GetInt32()); + Assert.True(json.GetProperty("truncated").GetBoolean()); + Assert.Equal("user_limit", json.GetProperty("truncated_reason").GetString()); + Assert.True(json.GetProperty("degraded").GetBoolean()); + Assert.False(json.GetProperty("authoritative_count").GetBoolean()); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index d7ba4f839a..d29dab1792 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -3875,6 +3875,38 @@ public void CountFindInFiles_LineCapReportsTruncation_Issue3560() } } + [Fact] + public void RunFind_AllScopeCountJsonLineCapIsNonAuthoritative_Issue3566() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_find_all_line_cap_authority_3566"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var content = string.Concat(Enumerable.Repeat("alpha\n", QueryCommandRunner.FindAllLineScanLimit + 1)); + TestProjectHelper.InsertIndexedFile(dbPath, "src/large.txt", "text", content); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind( + ["alpha", "--db", dbPath, "--all", "--json", "--count"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(QueryCommandRunner.FindAllLineScanLimit, json.GetProperty("count").GetInt32()); + Assert.True(json.GetProperty("scan_truncated").GetBoolean()); + Assert.True(json.GetProperty("scan_cap_reached").GetBoolean()); + Assert.Equal("line_scan_limit", json.GetProperty("scan_truncation_reason").GetString()); + Assert.True(json.GetProperty("degraded").GetBoolean()); + Assert.False(json.GetProperty("authoritative_count").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunFind_PathGlobsMatchExpectedFiles() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index cf878f044b..a750c6514e 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -4058,6 +4058,13 @@ public void CountOnlyJson_IgnoresLimitAndReturnsTrueTotal(string command, bool u Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Equal(string.Empty, stderr); Assert.Equal(25, json.GetProperty("count").GetInt32()); + Assert.False(json.GetProperty("degraded").GetBoolean()); + Assert.True(json.GetProperty("authoritative_count").GetBoolean()); + Assert.True(json.GetProperty("freshness_available").GetBoolean()); + Assert.True(json.GetProperty("indexed_file_count").GetInt32() > 0); + var queryContext = json.GetProperty("query_context"); + Assert.True(queryContext.GetProperty("count").GetBoolean()); + Assert.Equal(useExplicitLimit ? 5 : 20, queryContext.GetProperty("limit").GetInt32()); switch (command) { @@ -4068,12 +4075,15 @@ public void CountOnlyJson_IgnoresLimitAndReturnsTrueTotal(string command, bool u case "callers": case "callees": Assert.Equal(25, json.GetProperty("files").GetInt32()); + Assert.Equal(25, json.GetProperty("file_count").GetInt32()); break; case "find": Assert.Equal(1, json.GetProperty("files").GetInt32()); Assert.Equal(1, json.GetProperty("file_count").GetInt32()); break; case "files": + Assert.Equal(25, json.GetProperty("files").GetInt32()); + Assert.Equal(25, json.GetProperty("file_count").GetInt32()); break; default: throw new ArgumentOutOfRangeException(nameof(command), command, null);