From e43a7ec2e49f05bf2f75c7956e2912af1ba0bf6c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:30:25 +0900 Subject: [PATCH 1/6] Add named search batches (#3481) --- USER_GUIDE.md | 15 +- changelog.d/unreleased/3481.added.md | 20 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 1 + src/CodeIndex/Cli/ConsoleUi.cs | 5 +- src/CodeIndex/Cli/JsonOutputContracts.cs | 2 + src/CodeIndex/Cli/QueryCommandRunner.cs | 227 +++++++++++++++++- src/CodeIndex/Cli/SearchAuditRecipes.cs | 13 + .../QueryCommandRunnerSearchTests.cs | 53 +++- 8 files changed, 321 insertions(+), 15 deletions(-) create mode 100644 changelog.d/unreleased/3481.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4c49a86911..0752ce0136 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -305,6 +305,11 @@ can use `jq -s '.'` or pass `--json=array` to `search` to emit the result set as one JSON array. Add `--pretty` with `--json` to indent single-document JSON responses; for `search`, use `--json=array --pretty` when the result set itself should be indented because default `search --json` stays newline-delimited. +`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 +`CompactSearchResult` snippet/highlight context instead of reducing rows to +file/line pairs. For AI-oriented bounded payloads, `map`, `inspect`, and `outline` accept `--compact`. It implies JSON output, caps list sections to 5 items by default (or the explicit `--limit` / `--top` value), and adds `compact`, @@ -867,6 +872,7 @@ cdidx search "File.ReadAllText" --exact-substring --reject-before "Length" --gua cdidx search "FileMode.Create" --exact-substring --require-after "File.Move" --guard-window 12 # require a nearby follow-up action cdidx search --list-recipes # show reusable audit recipes cdidx search --recipe risky-code --json # run a curated audit query set and return grouped JSON +cdidx search --named-query pack="dotnet pack" --named-query push="nuget push" --format compact # named ad hoc batch with compact snippets cdidx search --recipe risky-code --format issue-drafts --open-issues open-issues.json # issue draft JSON with duplicate preflight cdidx search "--open-reports" --path README.md --count # quoted literal that starts with -- cdidx search --query "--path" --path README.md # search for an option-looking literal @@ -1013,7 +1019,7 @@ When `definition --body` is combined with `--json`, `body_content` is capped to `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, and `find` also share repeatable `--path ` glob-style path filters (multiple values are OR'd together), repeatable `--exclude-path `, and `--exclude-tests`. Use `*` and `?` to match path segments, and plain text still behaves like a substring filter when you do not include wildcards. Search results prefer source files over tests and docs, and `search` boosts files whose symbol names or paths match the query exactly. -`search --json` and MCP `search` return compact match-centered snippets instead of whole chunks. Each result includes `chunk_start_line`, `chunk_end_line`, `snippet_start_line`, `snippet_end_line`, `snippet`, `match_lines`, `highlights`, `context_before`, `context_after`, `truncated_line_count`, `dropped_match_line_count`, and `truncation_context`, plus optional `enclosing_symbol_name`, `enclosing_symbol_kind`, `enclosing_symbol_start_line`, `enclosing_symbol_end_line`, and `enclosing_container_name` when the match line is inside an indexed symbol. Use `--snippet-lines ` to shrink or widen the excerpt window (default: 8, max: 20), and `--max-line-width ` to clamp each line around the strongest match when a minified / transpiled file would otherwise return a single huge line (default: 512, max: 4096; `0` disables clamping). `--snippet-focus ` controls that long-line focus; `quality` is the default, `leftmost` keeps the legacy earliest-match behavior, and `proximity` favors dense multi-token clusters. Clamped lines are marked with `...(+N)...` in the snippet and expose `highlights[].truncated` / `highlights[].original_line_length` in JSON / MCP output. +`search --json`, `search --format compact`, named search batches, and MCP `search` return compact match-centered snippets instead of whole chunks. Each result includes `chunk_start_line`, `chunk_end_line`, `snippet_start_line`, `snippet_end_line`, `snippet`, `match_lines`, `highlights`, `context_before`, `context_after`, `truncated_line_count`, `dropped_match_line_count`, and `truncation_context`, plus optional `enclosing_symbol_name`, `enclosing_symbol_kind`, `enclosing_symbol_start_line`, `enclosing_symbol_end_line`, and `enclosing_container_name` when the match line is inside an indexed symbol. Use `--snippet-lines ` to shrink or widen the excerpt window (default: 8, max: 20), and `--max-line-width ` to clamp each line around the strongest match when a minified / transpiled file would otherwise return a single huge line (default: 512, max: 4096; `0` disables clamping). `--snippet-focus ` controls that long-line focus; `quality` is the default, `leftmost` keeps the legacy earliest-match behavior, and `proximity` favors dense multi-token clusters. Clamped lines are marked with `...(+N)...` in the snippet and expose `highlights[].truncated` / `highlights[].original_line_length` in JSON / MCP output. ### Resolve a definition @@ -2604,6 +2610,10 @@ newline-delimited JSON (ndjson) として出力し、最後に `{"done":true,... 単一 document の JSON 応答をインデント付きで出力します。`search` の result set を 整形したい場合は、既定の `search --json` が newline-delimited のまま保たれるため `--json=array --pretty` を使います。 +`search --named-query =` は繰り返し指定でき、同じ filter と snippet 上限で +ad hoc な grouped batch を実行します。名前付き batch は 1 つの grouped JSON document を +出力し、`--format compact` でも各 result の `CompactSearchResult` snippet / highlight +context を維持し、file/line だけの行には縮約しません。 AI 向けに上限付き payload が必要な場合、`map`、`inspect`、`outline` は `--compact` に対応しています。これは JSON 出力を暗黙に有効化し、list section を 既定 5 件(明示した `--limit` / `--top` があればその値)に cap し、 @@ -3171,6 +3181,7 @@ cdidx search "File.ReadAllText" --exact-substring --reject-before "Length" --gua cdidx search "FileMode.Create" --exact-substring --require-after "File.Move" --guard-window 12 # 近傍の後続処理を要求 cdidx search --list-recipes # 再利用可能な audit recipe を表示 cdidx search --recipe risky-code --json # curated audit query set を実行し、grouped JSON を返す +cdidx search --named-query pack="dotnet pack" --named-query push="nuget push" --format compact # 名前付き ad hoc batch と compact snippet cdidx search --recipe risky-code --format issue-drafts --open-issues open-issues.json # duplicate preflight 付き issue draft JSON cdidx search "--open-reports" --path README.md --count # `--` で始まる引用済みリテラル cdidx search --query "--path" --path README.md # オプションに見えるリテラルを検索 @@ -3310,7 +3321,7 @@ function CreateUser src/Services/UserService.cs: `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files` は共通で繰り返し指定できる `--path ` の glob 形式パスフィルタ(複数値は OR で結合)、繰り返し指定できる `--exclude-path `、`--exclude-tests` に対応しています。`*` と `?` でパスパターンを指定でき、ワイルドカードを含めない場合は従来どおり部分文字列として扱われます。検索結果は tests や docs より source を優先し、`search` はシンボル名やパスがクエリと正確に一致するファイルを上に出します。 -`search --json` と MCP の `search` は、チャンク全文ではなく一致中心の軽量スニペットを返します。各結果には `chunk_start_line`、`chunk_end_line`、`snippet_start_line`、`snippet_end_line`、`snippet`、`match_lines`、`highlights`、`context_before`、`context_after`、`truncated_line_count`、`dropped_match_line_count`、`truncation_context` が含まれ、マッチ行がインデックス済みシンボル範囲内にある場合は `enclosing_symbol_name`、`enclosing_symbol_kind`、`enclosing_symbol_start_line`、`enclosing_symbol_end_line`、`enclosing_container_name` も含まれます。抜粋の長さは `--snippet-lines ` で調整でき(デフォルト: 8、最大: 20)、minified / transpiled で 1 行が極端に長いファイルでは `--max-line-width ` を使って各行を最も強い一致周辺へクランプできます(`0` でクランプ解除、デフォルト: 512、最大: 4096)。長い行の焦点は `--snippet-focus ` で制御でき、`quality` がデフォルト、`leftmost` は従来の最左一致、`proximity` は近接した複数トークンを優先します。クランプされた行はスニペット内に `...(+N)...` マーカーが入り、JSON / MCP 出力では `highlights[].truncated` / `highlights[].original_line_length` でも検出できます。 +`search --json`、`search --format compact`、名前付き search batch、MCP の `search` は、チャンク全文ではなく一致中心の軽量スニペットを返します。各結果には `chunk_start_line`、`chunk_end_line`、`snippet_start_line`、`snippet_end_line`、`snippet`、`match_lines`、`highlights`、`context_before`、`context_after`、`truncated_line_count`、`dropped_match_line_count`、`truncation_context` が含まれ、マッチ行がインデックス済みシンボル範囲内にある場合は `enclosing_symbol_name`、`enclosing_symbol_kind`、`enclosing_symbol_start_line`、`enclosing_symbol_end_line`、`enclosing_container_name` も含まれます。抜粋の長さは `--snippet-lines ` で調整でき(デフォルト: 8、最大: 20)、minified / transpiled で 1 行が極端に長いファイルでは `--max-line-width ` を使って各行を最も強い一致周辺へクランプできます(`0` でクランプ解除、デフォルト: 512、最大: 4096)。長い行の焦点は `--snippet-focus ` で制御でき、`quality` がデフォルト、`leftmost` は従来の最左一致、`proximity` は近接した複数トークンを優先します。クランプされた行はスニペット内に `...(+N)...` マーカーが入り、JSON / MCP 出力では `highlights[].truncated` / `highlights[].original_line_length` でも検出できます。 ### 定義を引く diff --git a/changelog.d/unreleased/3481.added.md b/changelog.d/unreleased/3481.added.md new file mode 100644 index 0000000000..618daeb776 --- /dev/null +++ b/changelog.d/unreleased/3481.added.md @@ -0,0 +1,20 @@ +--- +category: added +issues: + - 3481 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md +--- + +## English + +- **Search supports named ad hoc batches with compact snippets (#3481)** — `cdidx search --named-query =` can now be repeated to run related searches with grouped results, and `search --format compact` preserves bounded snippet, match line, highlight, and truncation context. + +## 日本語 + +- **search が compact snippet 付きの名前付き ad hoc batch に対応しました (#3481)** — `cdidx search --named-query =` を繰り返して関連検索を grouped result として実行できるようになり、`search --format compact` は bounded snippet、match line、highlight、truncation context を保持します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 9593377625..677f2d4009 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -277,6 +277,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--query", ValuePlaceholder = "", Description = "Literal query", Commands = Set(QueryCommands) }, new() { Name = "--recipe", ValuePlaceholder = "", Description = "Search: run a built-in audit recipe query set", Commands = Set("search") }, new() { Name = "--list-recipes", Description = "Search: list built-in audit recipes", Commands = Set("search") }, + new() { Name = "--named-query", ValuePlaceholder = "=", Description = "Search: add one named ad hoc batch query", Commands = Set("search") }, new() { Name = "--open-issues", ValuePlaceholder = "", Description = "Preflight issue drafts against open issue JSON", Commands = Set("search", "suggestions") }, new() { Name = "--status", ValuePlaceholder = "", Description = "Suggestions: filter by suggestion status", Commands = Set("suggestions") }, new() { Name = "--category", ValuePlaceholder = "", Description = "Suggestions: filter by category", Commands = Set("suggestions") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index ba31188742..811bd646ea 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -87,7 +87,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("index-commits", "cdidx index --commits [commit-ref ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-changed-between", "cdidx index --changed-between [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-files", "cdidx index --files [path ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), - ("search", "cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--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 ]"), + ("search", "cdidx search |--query |-- |--recipe |--list-recipes|--named-query = [--named-query = ...] [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--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 ]"), ("definition", "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 ]"), ("goto", "cdidx goto |--query |-- [--db ] [--json] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--all]"), ("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]"), @@ -1032,6 +1032,7 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(" --lang Filter by language (aliases: bat, cmd, cshtml, razor, ts, tsx, cts, mts)"); Console.WriteLine(" --path Restrict matches to glob-style path patterns (* and ?)"); WriteHelpLine($" --query Pass a query literal, useful when the query starts with '-' (`search`/`find` max {QueryLimits.MaxQueryLength} chars)"); + WriteHelpLine(" --named-query = search only: add a named ad hoc batch query; repeat to run related searches with grouped compact results"); Console.WriteLine(" --exclude-path Exclude glob-style path patterns (* and ?) (repeatable)"); Console.WriteLine(" --exclude-tests Exclude likely test files"); Console.WriteLine(" --include-generated Include generated files in query results"); @@ -1094,6 +1095,8 @@ private static void PrintExamples() Console.WriteLine(" cdidx search \"authenticate\" Full-text search"); Console.WriteLine(" cdidx search \"auth*\" Prefix shorthand in literal-safe mode"); Console.WriteLine(" cdidx search --query --path --path README.md Search for a literal option token"); + Console.WriteLine(" cdidx search --named-query pack=\"dotnet pack\" --named-query push=\"nuget push\" --format compact"); + Console.WriteLine(" Run named ad hoc searches with compact snippets"); Console.WriteLine(" cdidx search \"Run();\" --exact-substring Case-sensitive exact substring search"); Console.WriteLine(" cdidx search \"File.ReadAllText\" --exact-substring --reject-before \"Length\" --guard-window 8"); Console.WriteLine(" Find calls without a nearby preceding size guard"); diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index bb5066b1ee..d261c309b6 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -470,6 +470,8 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(SearchGuardEvidence))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(SearchQueryHint))] +[JsonSerializable(typeof(SearchNamedBatchQueryResultJsonResult))] +[JsonSerializable(typeof(SearchNamedBatchRunJsonResult))] [JsonSerializable(typeof(SearchRecipeListItemJsonResult))] [JsonSerializable(typeof(SearchRecipeListJsonResult))] [JsonSerializable(typeof(SearchRecipeQueryListItemJsonResult))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 3b0a8f6536..512ccf23ef 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -22,6 +22,7 @@ public static class QueryCommandRunner internal const int DefaultQueryLimit = 20; internal const int DefaultMapLimit = 10; internal const int DefaultCompactSectionLimit = 5; + private const int MaxNamedSearchQueryNameLength = 128; internal const int DefaultImpactLimit = 50; internal const int DefaultDependencyCycleGraphLimit = 1_000; internal const int MaxWorkspaceDependencyDatabaseCount = 8; @@ -511,10 +512,10 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) } if (options.ListRecipes) { - if (options.Query != null || options.RecipeName != null || options.ExtraNames.Count > 0) + if (options.Query != null || options.RecipeName != null || options.NamedSearchQueries.Count > 0 || options.ExtraNames.Count > 0) { WriteUsageError( - "--list-recipes cannot be combined with a query, --recipe, or extra positional arguments.", + "--list-recipes cannot be combined with a query, --recipe, --named-query, or extra positional arguments.", GetUsageLineOrThrow("search"), "Run `cdidx search --list-recipes` to list built-in audit recipes."); return CommandExitCodes.UsageError; @@ -546,6 +547,51 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Run `cdidx search --list-recipes` to choose a recipe, then rerun with `--recipe --format issue-drafts`."); return CommandExitCodes.UsageError; } + if (options.NamedSearchQueries.Count > 0) + { + if (options.Query != null || options.RecipeName != null || options.ExtraNames.Count > 0) + { + WriteUsageError( + "--named-query cannot be combined with a positional query, --query, --recipe, or extra positional arguments.", + GetUsageLineOrThrow("search"), + "Pass one or more `--named-query =` values, or run a plain `cdidx search `."); + return CommandExitCodes.UsageError; + } + if (options.OpenIssuesPath != null) + { + WriteUsageError( + "--open-issues can only be used with `cdidx search --recipe --format issue-drafts`.", + GetUsageLineOrThrow("search"), + "Remove --open-issues for ad hoc named batches."); + return CommandExitCodes.UsageError; + } + if (options.CountOnly) + { + WriteUsageError( + "--count is not supported with --named-query.", + GetUsageLineOrThrow("search"), + "Use `cdidx search --named-query = --json` for per-query counts."); + return CommandExitCodes.UsageError; + } + if (options.OutputFormat is not OutputFormatText and not OutputFormatJson and not OutputFormatCompact) + { + WriteUsageError( + "--format count/csv/tsv/lsp/qf/sarif/issue-drafts is not supported with --named-query.", + GetUsageLineOrThrow("search"), + "Use plain text output, `--json`, or `--format compact` for grouped ad hoc results."); + return CommandExitCodes.UsageError; + } + if (options.JsonOutputFormat == JsonOutputFormatArray) + { + WriteUsageError( + "--json=array is not supported with --named-query because named batch output is grouped by query.", + GetUsageLineOrThrow("search"), + "Use plain `--json` for the grouped named-query object."); + return CommandExitCodes.UsageError; + } + + return RunSearchNamedBatch(options, jsonOptions, exact); + } if (options.RecipeName != null) { if (options.Query != null || options.ExtraNames.Count > 0) @@ -720,6 +766,13 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (options.Json) { + var compactResults = displayRows.Select(row => row.Compact).ToArray(); + AttachExactSubstringHint(compactResults, exactSubstringHint); + if (options.OutputFormat == OutputFormatCompact) + { + WriteCompactSearchResults(compactResults, jsonOptions); + return CommandExitCodes.Success; + } if (TryWriteFormattedLocations( options, displayRows.SelectMany(row => ToSearchFormattedLocations(row, options.Query, exact)), @@ -740,12 +793,6 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) WriteSarif(displayRows.SelectMany(row => ToSearchSarifItems(row, options.Query, exact)), jsonOptions); return CommandExitCodes.Success; } - var compactResults = displayRows.Select(row => row.Compact).ToArray(); - if (exactSubstringHint != null) - { - foreach (var result in compactResults) - result.ExactSubstringHint = exactSubstringHint; - } if (options.JsonOutputFormat == JsonOutputFormatArray) { Console.WriteLine(JsonSerializer.Serialize( @@ -784,6 +831,44 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) }); } + private static int RunSearchNamedBatch(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) + { + return WithDb(options, jsonOptions, reader => + { + var queryResults = CollectSearchNamedBatchQueryResults(reader, options, userExact, out var total); + + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new SearchNamedBatchRunJsonResult( + JsonOutputContract.ApiVersion, + queryResults.Count, + total, + queryResults), + CliJsonSerializerContextFactory.Create(jsonOptions).SearchNamedBatchRunJsonResult)); + return CommandExitCodes.Success; + } + + Console.WriteLine("Named search batch"); + Console.WriteLine(); + foreach (var queryResult in queryResults) + { + Console.WriteLine($"[{queryResult.Name}] {queryResult.Query}"); + Console.WriteLine($"results: {queryResult.Count}"); + foreach (var result in queryResult.Results) + { + Console.WriteLine($"{result.Path}:{result.ChunkStartLine}-{result.ChunkEndLine}"); + foreach (var line in result.Snippet.Split('\n', StringSplitOptions.None)) + Console.WriteLine($" {line}"); + } + Console.WriteLine(); + } + + Console.Error.WriteLine($"({total} named-query results across {queryResults.Count} queries)"); + return CommandExitCodes.Success; + }); + } + private static int WriteSearchRecipeList(QueryCommandOptions options, JsonSerializerOptions jsonOptions) { var recipes = SearchAuditRecipes.All @@ -953,6 +1038,47 @@ private static List CollectSearchRecipeQueryR return queryResults; } + private static List CollectSearchNamedBatchQueryResults( + DbReader reader, + QueryCommandOptions options, + bool userExact, + out int total) + { + var queryResults = new List(); + total = 0; + foreach (var namedQuery in options.NamedSearchQueries) + { + var results = reader.Search( + namedQuery.Query, + options.Limit, + options.Lang, + options.RawFts, + options.PathPatterns, + options.ExcludePaths, + options.ExcludeTests, + !options.NoDedup, + options.Since, + userExact, + options.Prefix, + !options.NoVisibilityRank, + guardFilters: options.GuardFilters, + guardWindow: options.GuardWindow); + var rows = BuildSearchDisplayRows(results, options, userExact, namedQuery.Query); + AttachExactSubstringHint( + rows.Select(row => row.Compact), + SearchQueryAdvisor.BuildExactSubstringHint(namedQuery.Query, options.RawFts, userExact, options.Prefix)); + total += rows.Count; + queryResults.Add(new SearchNamedBatchQueryResultJsonResult( + namedQuery.Name, + namedQuery.Query, + userExact, + rows.Count, + rows.Select(row => row.Compact).ToList())); + } + + return queryResults; + } + private static SearchIssueDraftJsonResult ToSearchIssueDraft( SearchAuditRecipe recipe, SearchRecipeQueryResultJsonResult queryResult, @@ -1144,6 +1270,14 @@ private static IEnumerable ToSearchLspLocations(SearchDisplayRow ro private sealed record SearchDisplayRow(SearchResult Result, CompactSearchResult Compact); + private static void AttachExactSubstringHint(IEnumerable results, SearchQueryHint? hint) + { + if (hint == null) + return; + foreach (var result in results) + result.ExactSubstringHint = hint; + } + private static void WriteJsonStreamDone(int count, JsonSerializerOptions jsonOptions) => Console.WriteLine(JsonSerializer.Serialize( new JsonStreamDoneResult(Done: true, Count: count, Interrupted: false), @@ -1312,6 +1446,16 @@ private static void WriteCompactLocations(IEnumerable locatio jsonOptions); } + private static void WriteCompactSearchResults(IEnumerable results, JsonSerializerOptions jsonOptions) + { + var itemOptions = GetCompactJsonOptions(jsonOptions); + var context = CliJsonSerializerContextFactory.Create(itemOptions); + WriteJsonArray( + results, + (writer, result) => writer.Write(JsonSerializer.Serialize(result, context.CompactSearchResult)), + jsonOptions); + } + private static void WriteJsonArray(IEnumerable items, Action writeItem, JsonSerializerOptions jsonOptions) { var writer = Console.Out; @@ -6174,6 +6318,7 @@ public static QueryCommandOptions ParseArgs( string? recipeName = null; bool listRecipes = false; string? openIssuesPath = null; + var namedSearchQueries = new List(); bool languagesIndexedOnly = false; var languageCapabilities = new List(); @@ -6453,6 +6598,25 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(openIssuesError!); break; + case "--named-query": + if (!allowNamedQuery) + { + AddParseError("Error: --named-query is not supported by this command."); + if (i + 1 < args.Length && !args[i + 1].StartsWith("-", StringComparison.Ordinal)) + i++; + } + else if (TryReadStringOptionValue(args, ref i, "--named-query", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var namedQueryValue, out var namedQueryError)) + { + if (TryParseNamedSearchQuery(namedQueryValue!, out var namedQuery, out var namedQueryParseError)) + namedSearchQueries.Add(namedQuery); + else + AddParseError(namedQueryParseError!); + } + else + { + AddParseError(namedQueryError!); + } + break; case "--require-before": if (TryReadStringOptionValue(args, ref i, "--require-before", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var requireBeforeValue, out var requireBeforeError)) AddSearchGuardFilter("--require-before", SearchGuardRole.Require, SearchGuardDirection.Before, requireBeforeValue!); @@ -6992,6 +7156,11 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) ValidateQueryPathOptionValues(userPathPatterns, excludePaths, AddParseError); if (guardFilters.Count > DbReader.MaxSearchGuardFilters) AddParseError($"Error: search accepts at most {DbReader.MaxSearchGuardFilters} guard filters; got {guardFilters.Count}."); + var duplicateNamedQuery = namedSearchQueries + .GroupBy(query => query.Name, StringComparer.OrdinalIgnoreCase) + .FirstOrDefault(group => group.Count() > 1); + if (duplicateNamedQuery != null) + AddParseError($"Error: duplicate --named-query name '{ConsoleUi.FormatBoundedValue(duplicateNamedQuery.Key)}'. Use unique names so grouped results are unambiguous."); if (validateDefaultLimit && !limitExplicit && defaultLimitError != null) AddParseError(defaultLimitError); @@ -7085,12 +7254,51 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) RecipeName = recipeName, ListRecipes = listRecipes, OpenIssuesPath = openIssuesPath, + NamedSearchQueries = namedSearchQueries, LanguagesIndexedOnly = languagesIndexedOnly, LanguageCapabilities = languageCapabilities, ParseError = parseErrors == null ? null : string.Join(Environment.NewLine, parseErrors), }; } + private static bool TryParseNamedSearchQuery(string value, out SearchNamedQuery namedQuery, out string? error) + { + namedQuery = new SearchNamedQuery(string.Empty, string.Empty); + error = null; + var separator = value.IndexOf('='); + if (separator <= 0) + { + error = "Error: --named-query must use =."; + return false; + } + + var name = value[..separator].Trim(); + var query = value[(separator + 1)..].Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + error = "Error: --named-query name cannot be empty."; + return false; + } + if (name.Length > MaxNamedSearchQueryNameLength) + { + error = $"Error: --named-query name '{ConsoleUi.FormatBoundedValue(name)}' exceeds the {MaxNamedSearchQueryNameLength} character limit."; + return false; + } + if (string.IsNullOrWhiteSpace(query)) + { + error = $"Error: --named-query '{ConsoleUi.FormatBoundedValue(name)}' query cannot be empty."; + return false; + } + if (query.Length > QueryLimits.MaxQueryLength) + { + error = QueryLimits.FormatQueryTooLongError(); + return false; + } + + namedQuery = new SearchNamedQuery(name, query); + return true; + } + private static string ResolveProjectFilterRoot(string dbPath, bool dbPathExplicit) { var effectiveDbPath = s_batchReader != null && !string.IsNullOrWhiteSpace(s_batchDbPath) @@ -10068,7 +10276,10 @@ public sealed class QueryCommandOptions public string? RecipeName { get; init; } public bool ListRecipes { get; init; } public string? OpenIssuesPath { get; init; } + public List NamedSearchQueries { get; init; } = []; public bool LanguagesIndexedOnly { get; init; } public List LanguageCapabilities { get; init; } = []; public string? ParseError { get; init; } } + +public sealed record SearchNamedQuery(string Name, string Query); diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 7e8afde3d2..50a19e0b24 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -99,6 +99,19 @@ internal sealed record SearchRecipeRunJsonResult( [property: JsonPropertyName("result_count")] int ResultCount, [property: JsonPropertyName("queries")] List Queries); +internal sealed record SearchNamedBatchRunJsonResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("query_count")] int QueryCount, + [property: JsonPropertyName("result_count")] int ResultCount, + [property: JsonPropertyName("queries")] List Queries); + +internal sealed record SearchNamedBatchQueryResultJsonResult( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("query")] string Query, + [property: JsonPropertyName("exact_substring")] bool ExactSubstring, + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("results")] List Results); + internal sealed record SearchRecipeQueryResultJsonResult( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("query")] string Query, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 8f1cd7d55d..f80489e346 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -9,7 +9,7 @@ namespace CodeIndex.Tests; public partial class QueryCommandRunnerTests { [Fact] - public void RunSearch_FormatCompactEmitsFileLineOnly_Issue1642() + public void RunSearch_FormatCompactEmitsBoundedSnippet_Issue3481() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_format_compact"); try @@ -29,9 +29,12 @@ public void RunSearch_FormatCompactEmitsFileLineOnly_Issue1642() Assert.Equal(string.Empty, stderr); using var document = ParseJsonOutput(stdout); var row = Assert.Single(document.RootElement.EnumerateArray()); - Assert.Equal("src/app.cs", row.GetProperty("file").GetString()); - Assert.True(row.GetProperty("line").GetInt32() > 0); - Assert.False(row.TryGetProperty("snippet", out _)); + Assert.Equal("Authenticate", row.GetProperty("query").GetString()); + Assert.Equal("src/app.cs", row.GetProperty("path").GetString()); + Assert.True(row.GetProperty("chunk_start_line").GetInt32() > 0); + Assert.Contains("Authenticate", row.GetProperty("snippet").GetString(), StringComparison.Ordinal); + Assert.NotEmpty(row.GetProperty("match_lines").EnumerateArray()); + Assert.NotEmpty(row.GetProperty("highlights").EnumerateArray()); Assert.False(row.TryGetProperty("name", out _)); } finally @@ -40,6 +43,48 @@ public void RunSearch_FormatCompactEmitsFileLineOnly_Issue1642() } } + [Fact] + public void RunSearch_NamedQueriesReturnGroupedCompactResults_Issue3481() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_named_queries"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "release/pack.md", + "markdown", + "Run dotnet pack before publishing."); + TestProjectHelper.InsertIndexedFile( + dbPath, + "release/push.md", + "markdown", + "Run nuget push after package validation."); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--named-query", "pack=dotnet pack", "--named-query", "push=nuget push", "--db", dbPath, "--format", "compact"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + Assert.Equal(2, root.GetProperty("query_count").GetInt32()); + Assert.Equal(2, root.GetProperty("result_count").GetInt32()); + var queries = root.GetProperty("queries").EnumerateArray().ToList(); + var pack = Assert.Single(queries, query => query.GetProperty("name").GetString() == "pack"); + Assert.Equal("dotnet pack", pack.GetProperty("query").GetString()); + var packResult = Assert.Single(pack.GetProperty("results").EnumerateArray()); + Assert.Equal("release/pack.md", packResult.GetProperty("path").GetString()); + Assert.Contains("dotnet pack", packResult.GetProperty("snippet").GetString(), StringComparison.Ordinal); + Assert.NotEmpty(packResult.GetProperty("match_lines").EnumerateArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_FormatLspEmitsLocationArray() { From f5b82bb932092369826059982560717fee1e91cb Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 00:52:29 +0900 Subject: [PATCH 2/6] Add grouped search counts (#3388) --- USER_GUIDE.md | 4 + changelog.d/unreleased/3388.added.md | 21 +++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 3 +- src/CodeIndex/Cli/JsonOutputContracts.cs | 20 +++ src/CodeIndex/Cli/QueryCommandRunner.cs | 148 ++++++++++++++++++ tests/CodeIndex.Tests/ConsoleUiTests.cs | 4 +- .../QueryCommandRunnerSearchTests.cs | 129 +++++++++++++++ 8 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/3388.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 0752ce0136..446b53e50a 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -873,6 +873,8 @@ cdidx search "FileMode.Create" --exact-substring --require-after "File.Move" --g cdidx search --list-recipes # show reusable audit recipes cdidx search --recipe risky-code --json # run a curated audit query set and return grouped JSON cdidx search --named-query pack="dotnet pack" --named-query push="nuget push" --format compact # named ad hoc batch with compact snippets +cdidx search "catch (Exception" --group-by file --count --json # rank broad audit hits by file +cdidx search "JsonDocument.Parse" --group-by symbol --count --json # rank broad audit hits by enclosing symbol cdidx search --recipe risky-code --format issue-drafts --open-issues open-issues.json # issue draft JSON with duplicate preflight cdidx search "--open-reports" --path README.md --count # quoted literal that starts with -- cdidx search --query "--path" --path README.md # search for an option-looking literal @@ -3182,6 +3184,8 @@ cdidx search "FileMode.Create" --exact-substring --require-after "File.Move" --g cdidx search --list-recipes # 再利用可能な audit recipe を表示 cdidx search --recipe risky-code --json # curated audit query set を実行し、grouped JSON を返す cdidx search --named-query pack="dotnet pack" --named-query push="nuget push" --format compact # 名前付き ad hoc batch と compact snippet +cdidx search "catch (Exception" --group-by file --count --json # 広い audit hit を file 別にランク付け +cdidx search "JsonDocument.Parse" --group-by symbol --count --json # 広い audit hit を enclosing symbol 別にランク付け cdidx search --recipe risky-code --format issue-drafts --open-issues open-issues.json # duplicate preflight 付き issue draft JSON cdidx search "--open-reports" --path README.md --count # `--` で始まる引用済みリテラル cdidx search --query "--path" --path README.md # オプションに見えるリテラルを検索 diff --git a/changelog.d/unreleased/3388.added.md b/changelog.d/unreleased/3388.added.md new file mode 100644 index 0000000000..9f45b9a3b3 --- /dev/null +++ b/changelog.d/unreleased/3388.added.md @@ -0,0 +1,21 @@ +--- +category: added +issues: + - 3388 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md +--- + +## English + +- **Search supports grouped count ranking (#3388)** — `cdidx search --group-by file|symbol --count` now ranks broad search results by file or enclosing symbol, with JSON output for audit triage. + +## 日本語 + +- **search が grouped count ranking に対応しました (#3388)** — `cdidx search --group-by file|symbol --count` で、広い検索結果を file または enclosing symbol 別にランク付けできるようにしました。audit triage 用の JSON 出力にも対応しています。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 677f2d4009..6646e1876f 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -316,7 +316,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--depth", ValuePlaceholder = "", Description = "Map: cap module depth; impact: deprecated alias for --max-hops", Commands = Set("impact", "map") }, new() { Name = "--with-paths", Description = "Impact: include shortest call chains per caller", Commands = Set("impact") }, new() { Name = "--reverse", Description = "Reverse direction (show dependents)", Commands = Set("deps") }, - new() { Name = "--group-by", ValuePlaceholder = "", Description = "Hotspots: choose grouping unit", Commands = Set("hotspots") }, + new() { Name = "--group-by", ValuePlaceholder = "", Description = "Search: group --count rows by file or symbol; hotspots: choose grouping unit", Commands = Set("hotspots", "search") }, new() { Name = "--group-by-name", Description = "Hotspots: collapse same-name rows; JSON paths capped per group", Commands = Set("hotspots") }, new() { Name = "--check", Description = "Verify status freshness/readiness", Commands = Set("status") }, new() { Name = "--config", Description = "Print effective configuration with source attribution", Commands = Set("status") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 811bd646ea..b06626253e 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -87,7 +87,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("index-commits", "cdidx index --commits [commit-ref ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-changed-between", "cdidx index --changed-between [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-files", "cdidx index --files [path ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), - ("search", "cdidx search |--query |-- |--recipe |--list-recipes|--named-query = [--named-query = ...] [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--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 ]"), + ("search", "cdidx search |--query |-- |--recipe |--list-recipes|--named-query = [--named-query = ...] [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by ] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]"), ("definition", "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 ]"), ("goto", "cdidx goto |--query |-- [--db ] [--json] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--all]"), ("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]"), @@ -1069,6 +1069,7 @@ private static void PrintFlagReference(Action WriteHelpLine) WriteHelpLine(" --max-hops Max BFS hops for impact analysis, inclusive (default: 5; --max-hops 2 returns callers at hop 1 and 2; --max-hops 0 resolves the symbol without traversing callers)"); Console.WriteLine(" --depth Deprecated alias for --max-hops"); Console.WriteLine(" --reverse Reverse direction for deps (show dependents)"); + WriteHelpLine(" --group-by search: with --count, group rows by file or symbol; hotspots: group by symbol, file, or statement"); WriteHelpLine(" --group-by-name hotspots: collapse rows sharing (name, kind) across files; JSON paths are capped per group with paths_truncated"); WriteHelpLine(" --with-paths impact: also emit `paths` per caller — the shortest call chains [root, ..., caller] (diamond graphs surface every converging route, capped per row)"); WriteHelpLine(" unused reflection note C# nameof/typeof and direct reflection member-name literals such as GetMethod(\"Foo\") are indexed; dynamically constructed reflection names may need manual review"); diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index d261c309b6..419e83f5df 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -166,6 +166,24 @@ internal sealed record QueryCountFilesJsonResult( [property: JsonPropertyName("files")] int Files, [property: JsonPropertyName("query")] string Query); +internal sealed record SearchGroupedCountJsonResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("query")] string Query, + [property: JsonPropertyName("group_by")] string GroupBy, + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("files")] int Files, + [property: JsonPropertyName("groups")] List Groups); + +internal sealed record SearchGroupedCountItemJsonResult( + [property: JsonPropertyName("key")] string Key, + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("file")] string? File, + [property: JsonPropertyName("symbol_name")] string? SymbolName, + [property: JsonPropertyName("symbol_kind")] string? SymbolKind, + [property: JsonPropertyName("symbol_start_line")] int? SymbolStartLine, + [property: JsonPropertyName("symbol_end_line")] int? SymbolEndLine, + [property: JsonPropertyName("container_name")] string? ContainerName); + internal sealed record QueryFindCountJsonResult( [property: JsonPropertyName("count")] int Count, [property: JsonPropertyName("files")] int Files, @@ -449,6 +467,8 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(QueryCountFilesJsonResult))] [JsonSerializable(typeof(QueryFindCountJsonResult))] [JsonSerializable(typeof(QueryPathErrorJsonResult))] +[JsonSerializable(typeof(SearchGroupedCountJsonResult))] +[JsonSerializable(typeof(SearchGroupedCountItemJsonResult))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List>))] [JsonSerializable(typeof(List))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 512ccf23ef..4968c1b9b5 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -667,12 +667,52 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) } if (TryWriteUnexpectedExtraPositionals("search", options)) return CommandExitCodes.UsageError; + if (options.GroupBy != null) + { + if (options.GroupBy is not "file" and not "symbol") + { + WriteUsageError( + "--group-by for search must be one of file or symbol.", + GetUsageLineOrThrow("search"), + "Use `cdidx search --group-by file --count` or `cdidx search --group-by symbol --count`."); + return CommandExitCodes.UsageError; + } + if (!options.CountOnly) + { + WriteUsageError( + "search --group-by requires --count.", + GetUsageLineOrThrow("search"), + "Add --count to request grouped result counts, or remove --group-by to print matching snippets."); + return CommandExitCodes.UsageError; + } + if (options.OutputFormat is not OutputFormatText and not OutputFormatJson and not OutputFormatCount) + { + WriteUsageError( + "--group-by for search only supports plain count output or JSON.", + GetUsageLineOrThrow("search"), + "Use `--count`, optionally with `--json`, instead of compact/location formats."); + return CommandExitCodes.UsageError; + } + if (options.JsonOutputFormat == JsonOutputFormatArray) + { + WriteUsageError( + "--json=array is not supported with search --group-by because grouped count output is a JSON object.", + GetUsageLineOrThrow("search"), + "Use plain `--json` for the grouped-count object."); + return CommandExitCodes.UsageError; + } + } var exactSubstringHint = SearchQueryAdvisor.BuildExactSubstringHint(options.Query, options.RawFts, exact, options.Prefix); var ndjsonOptions = options.JsonOutputFormat == JsonOutputFormatNdjson ? GetCompactJsonOptions(jsonOptions) : jsonOptions; int? jsonDoneCount = null; return WithDb(options, jsonOptions, reader => { + if (options.GroupBy != null) + { + return RunGroupedSearchCount(reader, options, jsonOptions, exact, exactSubstringHint); + } + if (options.CountOnly) { var counts = reader.CountSearchResults(options.Query, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, options.GuardFilters, options.GuardWindow); @@ -831,6 +871,114 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) }); } + private static int RunGroupedSearchCount(DbReader reader, QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool exact, SearchQueryHint? exactSubstringHint) + { + var results = reader.Search(options.Query!, int.MaxValue, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow); + var displayRows = BuildSearchDisplayRows(results, options, exact); + var groups = BuildSearchGroupedCounts(options.GroupBy!, displayRows); + var fileCount = displayRows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count(); + + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new SearchGroupedCountJsonResult( + JsonOutputContract.ApiVersion, + options.Query!, + options.GroupBy!, + displayRows.Count, + fileCount, + groups), + CliJsonSerializerContextFactory.Create(jsonOptions).SearchGroupedCountJsonResult)); + } + else + { + WriteSearchGroupedCounts(options.GroupBy!, groups, displayRows.Count, fileCount); + WriteExactSubstringHintIfNeeded(exactSubstringHint); + } + + return CommandExitCodes.Success; + } + + private static List BuildSearchGroupedCounts(string groupBy, List rows) + => groupBy == "file" + ? rows + .GroupBy(row => row.Result.Path, StringComparer.Ordinal) + .Select(group => new SearchGroupedCountItemJsonResult( + group.Key, + group.Count(), + group.Key, + null, + null, + null, + null, + null)) + .OrderByDescending(group => group.Count) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .ToList() + : rows + .GroupBy(row => BuildSearchSymbolGroupKey(row.Result), StringComparer.Ordinal) + .Select(group => + { + var result = group.First().Result; + var key = BuildSearchSymbolDisplayKey(result); + return new SearchGroupedCountItemJsonResult( + key, + group.Count(), + result.Path, + result.EnclosingSymbolName, + result.EnclosingSymbolKind, + result.EnclosingSymbolStartLine, + result.EnclosingSymbolEndLine, + result.EnclosingContainerName); + }) + .OrderByDescending(group => group.Count) + .ThenBy(group => group.Key, StringComparer.Ordinal) + .ToList(); + + private static string BuildSearchSymbolGroupKey(SearchResult result) + => result.EnclosingSymbolName == null + ? string.Join('\0', result.Path, "") + : string.Join( + '\0', + result.Path, + result.EnclosingSymbolKind ?? string.Empty, + result.EnclosingSymbolName, + result.EnclosingSymbolStartLine?.ToString(CultureInfo.InvariantCulture) ?? string.Empty, + result.EnclosingSymbolEndLine?.ToString(CultureInfo.InvariantCulture) ?? string.Empty); + + private static string BuildSearchSymbolDisplayKey(SearchResult result) + { + if (result.EnclosingSymbolName == null) + return $"{result.Path}:"; + + var start = result.EnclosingSymbolStartLine?.ToString(CultureInfo.InvariantCulture) ?? "?"; + var kind = result.EnclosingSymbolKind ?? "symbol"; + return $"{result.Path}:{start}:{kind}:{result.EnclosingSymbolName}"; + } + + private static void WriteSearchGroupedCounts(string groupBy, List groups, int totalCount, int fileCount) + { + foreach (var group in groups) + { + if (groupBy == "file") + { + Console.WriteLine($"{group.Count,8} {group.File}"); + continue; + } + + var location = group.SymbolStartLine.HasValue + ? $"{group.File}:{group.SymbolStartLine}-{group.SymbolEndLine ?? group.SymbolStartLine}" + : group.File ?? group.Key; + var symbol = group.SymbolName == null + ? "" + : $"{group.SymbolKind ?? "symbol"} {group.SymbolName}"; + var container = group.ContainerName == null ? string.Empty : $" ({group.ContainerName})"; + Console.WriteLine($"{group.Count,8} {location} {symbol}{container}"); + } + + Console.Error.WriteLine($"({totalCount} results in {fileCount} files; grouped by {groupBy})"); + } + private static int RunSearchNamedBatch(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) { return WithDb(options, jsonOptions, reader => diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index aecbf9c203..86196453de 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -115,7 +115,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx references |--query |-- ", output); Assert.Contains("cdidx callers |--query |-- ", output); Assert.Contains("cdidx callees |--query |-- ", output); - 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] [--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 search |--query |-- |--recipe |--list-recipes|--named-query = [--named-query = ...] [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by ] [--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] [--max-line-width ] [--exact|--exact-name]", output); @@ -269,7 +269,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] [--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 search |--query |-- |--recipe |--list-recipes|--named-query = [--named-query = ...] [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by ] [--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 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); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index f80489e346..5d1aacc2d3 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -85,6 +85,135 @@ public void RunSearch_NamedQueriesReturnGroupedCompactResults_Issue3481() } } + [Fact] + public void RunSearch_GroupByFileCountJsonReturnsRankedGroups_Issue3388() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_group_by_file"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/alpha.cs", + "csharp", + "public class Alpha { public void Run() { AuditMarker(); } }"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/beta.cs", + "csharp", + "public class Beta { public void Run() { AuditMarker(); } }"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["AuditMarker();", "--db", dbPath, "--exact-substring", "--group-by", "file", "--count", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + Assert.Equal("AuditMarker();", root.GetProperty("query").GetString()); + Assert.Equal("file", root.GetProperty("group_by").GetString()); + Assert.Equal(2, root.GetProperty("count").GetInt32()); + Assert.Equal(2, root.GetProperty("files").GetInt32()); + var groups = root.GetProperty("groups").EnumerateArray().ToList(); + Assert.Equal(["src/alpha.cs", "src/beta.cs"], groups.Select(group => group.GetProperty("file").GetString()).ToArray()); + Assert.All(groups, group => Assert.Equal(1, group.GetProperty("count").GetInt32())); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_GroupBySymbolCountJsonIncludesEnclosingSymbols_Issue3388() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_group_by_symbol"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/alpha.cs", + "csharp", + """ + public class Alpha + { + public void Run() + { + AuditMarker(); + } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/beta.cs", + "csharp", + """ + public class Beta + { + public void Execute() + { + AuditMarker(); + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["AuditMarker();", "--db", dbPath, "--exact-substring", "--group-by", "symbol", "--count", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + Assert.Equal("symbol", root.GetProperty("group_by").GetString()); + Assert.Equal(2, root.GetProperty("count").GetInt32()); + var groups = root.GetProperty("groups").EnumerateArray().ToList(); + Assert.Equal(2, groups.Count); + Assert.Contains(groups, group => + group.GetProperty("file").GetString() == "src/alpha.cs" && + group.GetProperty("symbol_name").GetString() == "Run" && + group.GetProperty("symbol_kind").GetString() == "function" && + group.GetProperty("symbol_start_line").GetInt32() > 0); + Assert.Contains(groups, group => + group.GetProperty("file").GetString() == "src/beta.cs" && + group.GetProperty("symbol_name").GetString() == "Execute" && + group.GetProperty("symbol_kind").GetString() == "function" && + group.GetProperty("symbol_start_line").GetInt32() > 0); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_GroupByRequiresCount_Issue3388() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_group_by_requires_count"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + "public class App { public void Run() { AuditMarker(); } }"); + + var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["AuditMarker();", "--db", dbPath, "--exact-substring", "--group-by", "file"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("search --group-by requires --count", stderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_FormatLspEmitsLocationArray() { From 437be5ba259aa7b27d24c89a1ee8ddee0894f76a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:16:36 +0900 Subject: [PATCH 3/6] Reject named query exact prefix conflict (#3481) --- src/CodeIndex/Cli/QueryCommandRunner.cs | 14 +++++----- .../QueryCommandRunnerSearchTests.cs | 26 +++++++++++++++++++ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 4968c1b9b5..67871e0bff 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -510,6 +510,13 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Use an open-issues JSON file from `gh issue list --state open --json number,title,labels,url`."); return CommandExitCodes.UsageError; } + if (exact && options.Prefix) + { + WriteValidationError( + "--prefix cannot be combined with --exact / --exact-substring (exact uses instr(), not FTS5 prefix phrases).", + "Drop --prefix to keep the exact substring path, or drop --exact to opt into FTS5 prefix matching."); + return CommandExitCodes.UsageError; + } if (options.ListRecipes) { if (options.Query != null || options.RecipeName != null || options.NamedSearchQueries.Count > 0 || options.ExtraNames.Count > 0) @@ -640,13 +647,6 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) return RunSearchRecipe(options, jsonOptions, exact); } - if (exact && options.Prefix) - { - WriteValidationError( - "--prefix cannot be combined with --exact / --exact-substring (exact uses instr(), not FTS5 prefix phrases).", - "Drop --prefix to keep the exact substring path, or drop --exact to opt into FTS5 prefix matching."); - return CommandExitCodes.UsageError; - } if (TryWriteBlankQueryError(options, "search")) return CommandExitCodes.UsageError; if (options.Query == null) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 5d1aacc2d3..d5d0287379 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -85,6 +85,32 @@ public void RunSearch_NamedQueriesReturnGroupedCompactResults_Issue3481() } } + [Fact] + public void RunSearch_NamedQueriesRejectExactPrefixConflict_Issue3481() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_named_queries_exact_prefix"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + "public class App { void Run() { Authenticate(); } }"); + + var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--named-query", "auth=Authenticate", "--db", dbPath, "--exact-substring", "--prefix"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("--prefix cannot be combined with --exact", stderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_GroupByFileCountJsonReturnsRankedGroups_Issue3388() { From 7a5a32f035fdb94c2642b8cece87329127c7f07e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:18:27 +0900 Subject: [PATCH 4/6] Reject grouped search submode flags (#3388) --- src/CodeIndex/Cli/QueryCommandRunner.cs | 13 +++++++ .../QueryCommandRunnerSearchTests.cs | 36 +++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 67871e0bff..b42c5de896 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -517,6 +517,19 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Drop --prefix to keep the exact substring path, or drop --exact to opt into FTS5 prefix matching."); return CommandExitCodes.UsageError; } + if (options.GroupBy != null && (options.ListRecipes || options.NamedSearchQueries.Count > 0 || options.RecipeName != null)) + { + var mode = options.ListRecipes + ? "--list-recipes" + : options.NamedSearchQueries.Count > 0 + ? "--named-query" + : "--recipe"; + WriteUsageError( + $"--group-by is not supported with {mode}.", + GetUsageLineOrThrow("search"), + "Use `cdidx search --group-by file --count` or remove --group-by for recipe and named-batch output."); + return CommandExitCodes.UsageError; + } if (options.ListRecipes) { if (options.Query != null || options.RecipeName != null || options.NamedSearchQueries.Count > 0 || options.ExtraNames.Count > 0) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index d5d0287379..b03a92072b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -240,6 +240,42 @@ public void RunSearch_GroupByRequiresCount_Issue3388() } } + [Fact] + public void RunSearch_GroupByIsRejectedForSearchSubmodes_Issue3388() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_group_by_submodes"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + "public class App { public void Run() { AuditMarker(); } }"); + + var (listExitCode, _, listStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--list-recipes", "--group-by", "file"], + _jsonOptions)); + var (recipeExitCode, _, recipeStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code", "--db", dbPath, "--group-by", "file"], + _jsonOptions)); + var (namedExitCode, _, namedStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--named-query", "audit=AuditMarker", "--db", dbPath, "--group-by", "file"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, listExitCode); + Assert.Equal(CommandExitCodes.UsageError, recipeExitCode); + Assert.Equal(CommandExitCodes.UsageError, namedExitCode); + Assert.Contains("--group-by is not supported with --list-recipes", listStderr, StringComparison.Ordinal); + Assert.Contains("--group-by is not supported with --recipe", recipeStderr, StringComparison.Ordinal); + Assert.Contains("--group-by is not supported with --named-query", namedStderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_FormatLspEmitsLocationArray() { From d600ebe9d977c0345d5cf16127949e50c39877ec Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 01:32:30 +0900 Subject: [PATCH 5/6] Support inline named search queries (#3481) --- src/CodeIndex/Cli/QueryCommandRunner.cs | 1 + tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index b42c5de896..4333ec6a9e 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -134,6 +134,7 @@ public static class QueryCommandRunner "--depth", "--query", "--recipe", + "--named-query", "--open-issues", "--group-by", "--focus-line", diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index b03a92072b..593d230c4e 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -62,7 +62,7 @@ public void RunSearch_NamedQueriesReturnGroupedCompactResults_Issue3481() "Run nuget push after package validation."); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - ["--named-query", "pack=dotnet pack", "--named-query", "push=nuget push", "--db", dbPath, "--format", "compact"], + ["--named-query=pack=dotnet pack", "--named-query=push=nuget push", "--db", dbPath, "--format", "compact"], _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); From 3432c1e496c829654d964744bdfff26b0eb5e188 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 12 Jun 2026 08:44:04 +0900 Subject: [PATCH 6/6] Fix grouped search test expectation (#3388) --- tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 593d230c4e..ef1188f6d5 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -3462,7 +3462,7 @@ public void RunSearch_GroupByName_IsRejectedOutsideHotspots() } [Fact] - public void RunSearch_GroupBy_IsRejectedOutsideHotspots() + public void RunSearch_GroupByWithoutCount_IsRejected() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_group_by_reject"); try @@ -3475,7 +3475,7 @@ public void RunSearch_GroupBy_IsRejectedOutsideHotspots() _jsonOptions)); Assert.Equal(CommandExitCodes.UsageError, exitCode); - Assert.Contains("--group-by is only supported by 'hotspots'", stderr); + Assert.Contains("search --group-by requires --count", stderr, StringComparison.Ordinal); } finally {