From b6da11d65c5c820cdb22268159546e928932e41a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 11 Jun 2026 23:30:10 +0900 Subject: [PATCH 1/8] Add ad hoc search issue drafts (#3520) --- USER_GUIDE.md | 4 + changelog.d/unreleased/3520.added.md | 21 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 + src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 248 +++++++++++++++++- src/CodeIndex/Cli/SearchAuditRecipes.cs | 6 +- tests/CodeIndex.Tests/ConsoleUiTests.cs | 4 +- .../QueryCommandRunnerSearchTests.cs | 82 +++++- 8 files changed, 345 insertions(+), 24 deletions(-) create mode 100644 changelog.d/unreleased/3520.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 4c49a86911..73438514ed 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -868,6 +868,7 @@ 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 --recipe risky-code --format issue-drafts --open-issues open-issues.json # issue draft JSON with duplicate preflight +cdidx search "Thread.Yield" --format issue-drafts --issue-title "Thread.Yield audit" --issue-label audit # ad hoc issue draft JSON 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 ``` @@ -1227,6 +1228,7 @@ same source location. | `--offset ` | `suggestions list`, `suggestions export` | Skip filtered suggestion records after newest-first ordering before emitting results. | | `--format ` | `suggestions export` | Choose export format. JSON is the default, markdown is intended for human triage, and issue-drafts emits issue-ready draft objects. | | `--open-issues ` | `suggestions export --format issue-drafts` | Preflight drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`. Inputs are capped at 8 MiB, 32 JSON nesting levels, 1000 issue entries, 32 labels per issue, and bounded title/URL/label strings. | +| `--issue-title ` / `--issue-label <label>` | `search --format issue-drafts` | Set the title for an ad hoc search draft and add label hints. `--issue-label` can be repeated or comma-separated. | | `--check` | `status` | Verify that `.cdidx/codeindex.db` exactly matches the current indexable workspace by comparing DB file paths/checksums against a fresh filesystem scan. Matching indexes exit `0`; stale indexes exit `5`. | | `--dry-run` | `index` | Scan files and report what would change without writing to the database | | `--limit <n>` | Query result commands except `suggestions` | Max results (default: 20, max: 10000; `map` uses it per section) | @@ -3172,6 +3174,7 @@ 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 --recipe risky-code --format issue-drafts --open-issues open-issues.json # duplicate preflight 付き issue draft JSON +cdidx search "Thread.Yield" --format issue-drafts --issue-title "Thread.Yield audit" --issue-label audit # ad hoc issue draft JSON cdidx search "--open-reports" --path README.md --count # `--` で始まる引用済みリテラル cdidx search --query "--path" --path README.md # オプションに見えるリテラルを検索 ``` @@ -3525,6 +3528,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--offset <n>` | `suggestions list`, `suggestions export` | filter と新しい順の並び替え後、出力前に指定件数の提案レコードをスキップします。 | | `--format <json\|markdown\|issue-drafts>` | `suggestions export` | エクスポート形式を選びます。既定は JSON、markdown は人間の triage 共有向け、issue-drafts は Issue 作成用の draft object を出力します。 | | `--open-issues <path>` | `suggestions export --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` などの open issue JSON と照合して draft を事前重複確認します。入力は 8 MiB、JSON ネスト 32 段、issue entry 1000 件、issue ごとの label 32 件、title / URL / label 文字列長の上限に制限されます。 | +| `--issue-title <title>` / `--issue-label <label>` | `search --format issue-drafts` | ad hoc search draft の title を指定し、label hint を追加します。`--issue-label` は繰り返し指定またはカンマ区切りに対応します。 | | `--check` | `status` | DB のファイル path/checksum と現在の index 対象 workspace を比較し、`.cdidx/codeindex.db` が完全一致するか確認。完全一致なら終了コード `0`、stale なら `5` | | `--dry-run` | `index` | DB に書き込まず、どの変更が発生するかだけを走査して報告 | | `--limit <n>` | `suggestions` 以外のクエリ結果コマンド | 最大結果数(デフォルト: 20、最大: 10000。`map` では各セクションごとの件数) | diff --git a/changelog.d/unreleased/3520.added.md b/changelog.d/unreleased/3520.added.md new file mode 100644 index 0000000000..da54fb085a --- /dev/null +++ b/changelog.d/unreleased/3520.added.md @@ -0,0 +1,21 @@ +--- +category: added +issues: + - 3520 +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 + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - USER_GUIDE.md +--- + +## English + +- **Ad hoc search issue-drafts are now supported (#3520)** — `cdidx search <query> --format issue-drafts` can emit issue-ready draft JSON without a named recipe, with `--issue-title` and repeatable `--issue-label` hints for triage metadata. + +## 日本語 + +- **ad hoc search から issue-drafts を出力できるようになりました (#3520)** — `cdidx search <query> --format issue-drafts` は named recipe なしで Issue 作成用 draft JSON を出力でき、`--issue-title` と繰り返し指定可能な `--issue-label` で triage metadata を補えます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 9593377625..3840e6444c 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -278,6 +278,8 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--recipe", ValuePlaceholder = "<name>", 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 = "--open-issues", ValuePlaceholder = "<path>", Description = "Preflight issue drafts against open issue JSON", Commands = Set("search", "suggestions") }, + new() { Name = "--issue-title", ValuePlaceholder = "<title>", Description = "Search issue-drafts: override the title for an ad hoc search draft", Commands = Set("search") }, + new() { Name = "--issue-label", ValuePlaceholder = "<label>", Description = "Search issue-drafts: add a label hint; repeat or comma-separate values", Commands = Set("search") }, new() { Name = "--status", ValuePlaceholder = "<status>", Description = "Suggestions: filter by suggestion status", Commands = Set("suggestions") }, new() { Name = "--category", ValuePlaceholder = "<category>", Description = "Suggestions: filter by category", Commands = Set("suggestions") }, new() { Name = "--agent", ValuePlaceholder = "<agent>", Description = "Suggestions: filter by agent", Commands = Set("suggestions") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index ba31188742..b0447fa5b4 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 <projectPath> --commits <commit-ref> [commit-ref ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-changed-between", "cdidx index <projectPath> --changed-between <old-ref> <new-ref> [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-files", "cdidx index <projectPath> --files <path> [path ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), - ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), + ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), ("definition", "cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since <datetime>]"), ("goto", "cdidx goto <query>|--query <query>|-- <query> [--db <path>] [--json] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--all]"), ("references", "cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 3b0a8f6536..838d5b18fd 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -72,6 +72,8 @@ public static class QueryCommandRunner internal const int MaxStatusCheckScopesCsvEntries = 16; internal const int MaxVisibilityFilterCsvLength = 256; internal const int MaxVisibilityFilterCsvEntries = 16; + internal const int MaxIssueDraftLabelCount = 16; + internal const int MaxIssueDraftTitleLength = GitHubIssueReporter.MaxGitHubIssueTitleLength; internal const int MaxQueryPathFilterCount = 128; internal const int MaxQueryPathFilterLength = 1024; internal const int ExactZeroHintProbeLimit = 1; @@ -134,6 +136,8 @@ public static class QueryCommandRunner "--query", "--recipe", "--open-issues", + "--issue-title", + "--issue-label", "--group-by", "--focus-line", "--focus-column", @@ -504,11 +508,43 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) if (options.OpenIssuesPath != null && options.OutputFormat != OutputFormatIssueDrafts) { WriteUsageError( - "--open-issues can only be used with `cdidx search --recipe <name> --format issue-drafts`.", + "--open-issues can only be used with `cdidx search --format issue-drafts`.", GetUsageLineOrThrow("search"), "Use an open-issues JSON file from `gh issue list --state open --json number,title,labels,url`."); return CommandExitCodes.UsageError; } + if ((options.IssueTitle != null || options.IssueLabels.Count > 0) && options.OutputFormat != OutputFormatIssueDrafts) + { + WriteUsageError( + "--issue-title and --issue-label can only be used with `cdidx search --format issue-drafts`.", + GetUsageLineOrThrow("search"), + "Use these hints when exporting issue draft JSON for a plain search."); + return CommandExitCodes.UsageError; + } + if (options.IssueTitle != null && options.RecipeName != null) + { + WriteUsageError( + "--issue-title is only supported for ad hoc search issue drafts.", + GetUsageLineOrThrow("search"), + "Recipe issue-drafts produce one draft per recipe query, so their titles are derived from the recipe metadata."); + return CommandExitCodes.UsageError; + } + if (options.OutputFormat == OutputFormatIssueDrafts && options.CountOnly) + { + WriteUsageError( + "--count cannot be combined with --format issue-drafts.", + GetUsageLineOrThrow("search"), + "Issue-draft export needs result evidence; remove --count."); + return CommandExitCodes.UsageError; + } + if (options.OutputFormat == OutputFormatIssueDrafts && options.JsonOutputFormat == JsonOutputFormatArray) + { + WriteUsageError( + "--json=array is not supported with --format issue-drafts because draft export is a JSON object.", + GetUsageLineOrThrow("search"), + "Use plain `--json` or omit --json when exporting issue drafts."); + return CommandExitCodes.UsageError; + } if (options.ListRecipes) { if (options.Query != null || options.RecipeName != null || options.ExtraNames.Count > 0) @@ -538,14 +574,6 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) return WriteSearchRecipeList(options, jsonOptions); } - if (options.OutputFormat == OutputFormatIssueDrafts && options.RecipeName == null) - { - WriteUsageError( - "--format issue-drafts requires --recipe because issue drafts are generated from named audit queries.", - GetUsageLineOrThrow("search"), - "Run `cdidx search --list-recipes` to choose a recipe, then rerun with `--recipe <name> --format issue-drafts`."); - return CommandExitCodes.UsageError; - } if (options.RecipeName != null) { if (options.Query != null || options.ExtraNames.Count > 0) @@ -621,6 +649,8 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) } if (TryWriteUnexpectedExtraPositionals("search", options)) return CommandExitCodes.UsageError; + if (options.OutputFormat == OutputFormatIssueDrafts) + return RunSearchIssueDrafts(options, jsonOptions, exact); var exactSubstringHint = SearchQueryAdvisor.BuildExactSubstringHint(options.Query, options.RawFts, exact, options.Prefix); var ndjsonOptions = options.JsonOutputFormat == JsonOutputFormatNdjson ? GetCompactJsonOptions(jsonOptions) : jsonOptions; @@ -891,7 +921,7 @@ private static int RunSearchRecipeIssueDrafts(QueryCommandOptions options, JsonS var queryResults = CollectSearchRecipeQueryResults(reader, recipe, options, userExact, out var total); var drafts = queryResults .Where(queryResult => queryResult.Count > 0) - .Select(queryResult => ToSearchIssueDraft(recipe, queryResult, preflight)) + .Select(queryResult => ToSearchIssueDraft(recipe, queryResult, preflight, options)) .ToList(); Console.WriteLine(JsonSerializer.Serialize( new SearchIssueDraftExportJsonResult( @@ -910,6 +940,65 @@ private static int RunSearchRecipeIssueDrafts(QueryCommandOptions options, JsonS }); } + private static int RunSearchIssueDrafts(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool exact) + { + if (!IssueDuplicatePreflight.TryLoad(options.OpenIssuesPath, out var preflight, out var error)) + { + WriteUsageError( + error!, + GetUsageLineOrThrow("search"), + "Pass a readable JSON array from `gh issue list --state open --json number,title,labels,url`."); + return CommandExitCodes.UsageError; + } + + return WithDb(options, jsonOptions, reader => + { + var results = reader.Search( + options.Query!, + options.Limit, + 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 rows = BuildSearchDisplayRows(results, options, exact); + var queryResult = new SearchRecipeQueryResultJsonResult( + "ad-hoc", + options.Query!, + $"Ad hoc search for `{options.Query}`.", + BuildAdHocIssueDraftLabels(options), + "Review the evidence paths and surrounding code before filing.", + exact, + rows.Count, + rows.Select(row => row.Compact).ToList()); + var drafts = rows.Count == 0 + ? [] + : new List<SearchIssueDraftJsonResult> { ToAdHocSearchIssueDraft(options, queryResult, preflight) }; + + Console.WriteLine(JsonSerializer.Serialize( + new SearchIssueDraftExportJsonResult( + JsonOutputContract.ApiVersion, + null, + 1, + rows.Count, + drafts.Count, + new SuggestionIssueDraftPreflightSummaryJsonResult( + preflight.Checked, + preflight.Source, + preflight.OpenIssueCount), + drafts), + CliJsonSerializerContextFactory.Create(jsonOptions).SearchIssueDraftExportJsonResult)); + return CommandExitCodes.Success; + }); + } + private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryResults( DbReader reader, SearchAuditRecipe recipe, @@ -956,9 +1045,11 @@ private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryR private static SearchIssueDraftJsonResult ToSearchIssueDraft( SearchAuditRecipe recipe, SearchRecipeQueryResultJsonResult queryResult, - IssueDuplicatePreflight preflight) + IssueDuplicatePreflight preflight, + QueryCommandOptions options) { var labels = queryResult.RecommendedLabels + .Concat(options.IssueLabels) .Where(label => !string.IsNullOrWhiteSpace(label)) .Select(label => label.Trim()) .Distinct(StringComparer.OrdinalIgnoreCase) @@ -992,9 +1083,56 @@ private static SearchIssueDraftJsonResult ToSearchIssueDraft( duplicateMatches)); } + private static SearchIssueDraftJsonResult ToAdHocSearchIssueDraft( + QueryCommandOptions options, + SearchRecipeQueryResultJsonResult queryResult, + IssueDuplicatePreflight preflight) + { + var labels = BuildAdHocIssueDraftLabels(options); + var title = BuildAdHocSearchIssueDraftTitle(options); + var evidencePaths = queryResult.Results + .Select(result => result.Path) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.Ordinal) + .Take(10) + .ToList(); + var duplicateMatches = preflight.FindMatches(title, labels); + return new SearchIssueDraftJsonResult( + "search/ad-hoc", + title, + labels, + evidencePaths, + BuildAdHocSearchIssueDraftBody(queryResult, evidencePaths), + new SearchIssueDraftSourceJsonResult( + null, + null, + queryResult.Query, + queryResult.Description, + queryResult.FalsePositiveGuidance, + queryResult.ExactSubstring, + queryResult.Count), + new SuggestionIssueDraftDuplicatePreflightJsonResult( + preflight.Checked, + duplicateMatches.Count, + duplicateMatches)); + } + private static string BuildSearchIssueDraftTitle(SearchAuditRecipe recipe, SearchRecipeQueryResultJsonResult queryResult) => $"Search audit recipe {recipe.Name}: {queryResult.Name}"; + private static string BuildAdHocSearchIssueDraftTitle(QueryCommandOptions options) + => string.IsNullOrWhiteSpace(options.IssueTitle) + ? $"Search issue draft: {options.Query}" + : options.IssueTitle.Trim(); + + private static List<string> BuildAdHocIssueDraftLabels(QueryCommandOptions options) + => options.IssueLabels + .Where(label => !string.IsNullOrWhiteSpace(label)) + .Select(label => label.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(label => label, StringComparer.OrdinalIgnoreCase) + .ToList(); + private static string BuildSearchIssueDraftBody( SearchAuditRecipe recipe, SearchRecipeQueryResultJsonResult queryResult, @@ -1032,6 +1170,38 @@ private static string BuildSearchIssueDraftBody( return sb.ToString().TrimEnd(); } + private static string BuildAdHocSearchIssueDraftBody( + SearchRecipeQueryResultJsonResult queryResult, + IReadOnlyList<string> evidencePaths) + { + var sb = new StringBuilder(); + sb.AppendLine("## Summary"); + sb.AppendLine(queryResult.Description); + sb.AppendLine(); + sb.AppendLine("## Search query"); + sb.AppendLine(queryResult.Query); + sb.AppendLine(); + sb.AppendLine("## Evidence paths"); + if (evidencePaths.Count == 0) + { + sb.AppendLine("N/A"); + } + else + { + foreach (var path in evidencePaths) + sb.AppendLine($"- {path}"); + } + sb.AppendLine(); + sb.AppendLine("## Review guidance"); + sb.AppendLine(queryResult.FalsePositiveGuidance); + sb.AppendLine(); + sb.AppendLine("## Search metadata"); + sb.AppendLine("- draft_id: `search/ad-hoc`"); + sb.AppendLine($"- result_count: `{queryResult.Count}`"); + sb.AppendLine($"- exact_substring: `{queryResult.ExactSubstring.ToString().ToLowerInvariant()}`"); + return sb.ToString().TrimEnd(); + } + private static SearchRecipeListItemJsonResult ToSearchRecipeListItem(SearchAuditRecipe recipe) => new( recipe.Name, recipe.Description, @@ -6174,6 +6344,8 @@ public static QueryCommandOptions ParseArgs( string? recipeName = null; bool listRecipes = false; string? openIssuesPath = null; + string? issueTitle = null; + var issueLabels = new List<string>(); bool languagesIndexedOnly = false; var languageCapabilities = new List<string>(); @@ -6199,6 +6371,31 @@ void AddSearchGuardFilter(string optionName, SearchGuardRole role, SearchGuardDi guardFilters.Add(new SearchGuardFilter(role, direction, value)); } + void AddIssueDraftLabels(string rawLabels) + { + if (string.IsNullOrWhiteSpace(rawLabels)) + { + AddParseError("Error: --issue-label value cannot be empty."); + return; + } + + foreach (var label in rawLabels.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (issueLabels.Count >= MaxIssueDraftLabelCount) + { + AddParseError($"Error: search issue drafts accept at most {MaxIssueDraftLabelCount} labels."); + return; + } + if (label.Length > IssueDuplicatePreflight.MaxOpenIssueLabelLength) + { + AddParseError($"Error: --issue-label value too long (max {IssueDuplicatePreflight.MaxOpenIssueLabelLength} characters)."); + return; + } + if (!issueLabels.Contains(label, StringComparer.OrdinalIgnoreCase)) + issueLabels.Add(label); + } + } + void AddStatusCheckScopes(string rawScopes) { if (string.IsNullOrWhiteSpace(rawScopes)) @@ -6453,6 +6650,27 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(openIssuesError!); break; + case "--issue-title": + if (TryReadStringOptionValue(args, ref i, "--issue-title", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var issueTitleValue, out var issueTitleError)) + { + WarnIfDuplicateSingleValueOption("--issue-title", issueTitleValue!); + var trimmedTitle = issueTitleValue!.Trim(); + if (trimmedTitle.Length == 0) + AddParseError("Error: --issue-title value cannot be empty."); + else if (trimmedTitle.Length > MaxIssueDraftTitleLength) + AddParseError($"Error: --issue-title value too long (max {MaxIssueDraftTitleLength} characters)."); + else + issueTitle = trimmedTitle; + } + else + AddParseError(issueTitleError!); + break; + case "--issue-label": + if (TryReadStringOptionValue(args, ref i, "--issue-label", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var issueLabelValue, out var issueLabelError)) + AddIssueDraftLabels(issueLabelValue!); + else + AddParseError(issueLabelError!); + 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!); @@ -7085,6 +7303,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) RecipeName = recipeName, ListRecipes = listRecipes, OpenIssuesPath = openIssuesPath, + IssueTitle = issueTitle, + IssueLabels = issueLabels, LanguagesIndexedOnly = languagesIndexedOnly, LanguageCapabilities = languageCapabilities, ParseError = parseErrors == null ? null : string.Join(Environment.NewLine, parseErrors), @@ -9645,7 +9865,9 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont ["--lang"] = "pass a language identifier, e.g. `--lang csharp`. Run `cdidx languages` for the supported set.", ["--query"] = "pass a search literal, e.g. `--query \"authenticate\"`. Use the `--query` form when the literal starts with `-`.", ["--recipe"] = "pass a built-in audit recipe name, e.g. `--recipe risky-code`; run `cdidx search --list-recipes` to list available recipes.", - ["--open-issues"] = "pass an open-issues JSON file, e.g. `--open-issues open-issues.json`; only valid with `search --recipe <name> --format issue-drafts`.", + ["--open-issues"] = "pass an open-issues JSON file, e.g. `--open-issues open-issues.json`; only valid with `search --format issue-drafts`.", + ["--issue-title"] = "pass an issue title hint for ad hoc search issue-drafts, e.g. `--issue-title \"Thread.Yield audit\"`.", + ["--issue-label"] = "pass an issue label hint for search issue-drafts, e.g. `--issue-label audit`; repeat or comma-separate values.", ["--kind"] = "pass a kind identifier, e.g. `--kind function`. definition/symbols/hotspots/unused take a symbol kind; references/callers/callees take a reference kind such as `call`, `instantiate`, or `subscribe`. Run the command's `--help` for the kind list.", ["--bucket"] = "pass one unused-symbol bucket: likely_unused_private, maybe_unused_nonpublic, public_or_exported_no_refs, or reflection_or_config_suspect.", ["--min-confidence"] = "pass one unused-symbol confidence threshold: medium or low.", @@ -10068,6 +10290,8 @@ public sealed class QueryCommandOptions public string? RecipeName { get; init; } public bool ListRecipes { get; init; } public string? OpenIssuesPath { get; init; } + public string? IssueTitle { get; init; } + public List<string> IssueLabels { get; init; } = []; public bool LanguagesIndexedOnly { get; init; } public List<string> LanguageCapabilities { get; init; } = []; public string? ParseError { get; init; } diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 7e8afde3d2..111091e0e2 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -111,7 +111,7 @@ internal sealed record SearchRecipeQueryResultJsonResult( internal sealed record SearchIssueDraftExportJsonResult( [property: JsonPropertyName("api_version")] string ApiVersion, - [property: JsonPropertyName("recipe")] SearchRecipeListItemJsonResult Recipe, + [property: JsonPropertyName("recipe")] SearchRecipeListItemJsonResult? Recipe, [property: JsonPropertyName("query_count")] int QueryCount, [property: JsonPropertyName("result_count")] int ResultCount, [property: JsonPropertyName("count")] int Count, @@ -128,8 +128,8 @@ internal sealed record SearchIssueDraftJsonResult( [property: JsonPropertyName("duplicate_preflight")] SuggestionIssueDraftDuplicatePreflightJsonResult DuplicatePreflight); internal sealed record SearchIssueDraftSourceJsonResult( - [property: JsonPropertyName("recipe")] string Recipe, - [property: JsonPropertyName("query_name")] string QueryName, + [property: JsonPropertyName("recipe")] string? Recipe, + [property: JsonPropertyName("query_name")] string? QueryName, [property: JsonPropertyName("query")] string Query, [property: JsonPropertyName("description")] string Description, [property: JsonPropertyName("false_positive_guidance")] string FalsePositiveGuidance, diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index aecbf9c203..3e738f98ad 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>|--query <query>|-- <query>", output); Assert.Contains("cdidx callers <query>|--query <query>|-- <query>", output); Assert.Contains("cdidx callees <query>|--query <query>|-- <query>", output); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]", output); Assert.Contains("cdidx inspect <query>|--query <query>|-- <query> [--db <path>] [--json] [--pretty] [--compact] [--fields <csv>] [--body-only] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--max-line-width <n>] [--exact|--exact-name]", output); @@ -269,7 +269,7 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() { var output = CaptureFullUsageOutput(showBanner: false); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx symbols [query|--query <query>|-- <query>] [--name <name>] [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx files [query|--query <query>|-- <query>] [--db <path>] [--json[=ndjson|array]] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--since <datetime>] [--bytes]", output); Assert.Contains("cdidx hotspots [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count]", output); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 8f1cd7d55d..e9134b22b3 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -478,14 +478,84 @@ public void Run() } [Fact] - public void RunSearch_IssueDraftsRequireRecipe_Issue3145() + public void RunSearch_AdHocIssueDraftsUseTitleLabelsAndDuplicatePreflight_Issue3520() { - var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - ["Authenticate", "--format", "issue-drafts"], - _jsonOptions)); + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_issue_drafts_ad_hoc"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var openIssuesPath = Path.Combine(projectRoot, "open-issues.json"); + File.WriteAllText( + openIssuesPath, + """ + [ + { + "number": 3520, + "title": "Thread.Yield audit", + "labels": [{"name": "audit"}], + "url": "https://example.test/issues/3520" + } + ] + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/scheduler.cs", + "csharp", + """ + public sealed class Scheduler + { + public void Run() + { + Thread.Yield(); + } + } + """); - Assert.Equal(CommandExitCodes.UsageError, exitCode); - Assert.Contains("--format issue-drafts requires --recipe", stderr); + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [ + "Thread.Yield", + "--db", dbPath, + "--format", "issue-drafts", + "--exact-substring", + "--issue-title", "Thread.Yield audit", + "--issue-label", "audit,bug", + "--issue-label", "needs-triage", + "--open-issues", openIssuesPath + ], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + var draft = Assert.Single(root.GetProperty("drafts").EnumerateArray()); + var labels = draft.GetProperty("labels").EnumerateArray().Select(label => label.GetString()).ToList(); + var duplicatePreflight = draft.GetProperty("duplicate_preflight"); + var match = Assert.Single(duplicatePreflight.GetProperty("matches").EnumerateArray()); + var body = draft.GetProperty("body").GetString(); + + Assert.Equal(1, root.GetProperty("query_count").GetInt32()); + Assert.Equal(JsonValueKind.Null, root.GetProperty("recipe").ValueKind); + Assert.Equal(1, root.GetProperty("count").GetInt32()); + Assert.Equal("Thread.Yield audit", draft.GetProperty("title").GetString()); + Assert.Contains("audit", labels); + Assert.Contains("bug", labels); + Assert.Contains("needs-triage", labels); + Assert.Equal("src/scheduler.cs", draft.GetProperty("evidence_paths")[0].GetString()); + Assert.Contains("Thread.Yield", body, StringComparison.Ordinal); + Assert.DoesNotContain("public sealed class Scheduler", body, StringComparison.Ordinal); + Assert.Equal(JsonValueKind.Null, draft.GetProperty("source").GetProperty("recipe").ValueKind); + Assert.Equal(JsonValueKind.Null, draft.GetProperty("source").GetProperty("query_name").ValueKind); + Assert.Equal("Thread.Yield", draft.GetProperty("source").GetProperty("query").GetString()); + Assert.True(draft.GetProperty("source").GetProperty("exact_substring").GetBoolean()); + Assert.Equal(1, duplicatePreflight.GetProperty("match_count").GetInt32()); + Assert.Equal(3520, match.GetProperty("number").GetInt32()); + Assert.Equal("title_exact", match.GetProperty("reason").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } } [Fact] From 3af5d7d911f140f9840639f795fc76b791a57f12 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Thu, 11 Jun 2026 23:42:39 +0900 Subject: [PATCH 2/8] Fetch GitHub open issues for draft preflight (#3449) --- USER_GUIDE.md | 6 +- changelog.d/unreleased/3449.added.md | 21 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 3 +- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/IssueDuplicatePreflight.cs | 182 +++++++++++++++++- src/CodeIndex/Cli/QueryCommandRunner.cs | 32 ++- tests/CodeIndex.Tests/ConsoleUiTests.cs | 4 +- .../IssueDuplicatePreflightTests.cs | 83 ++++++++ 8 files changed, 321 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/3449.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 73438514ed..2d0db10328 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -868,6 +868,7 @@ 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 --recipe risky-code --format issue-drafts --open-issues open-issues.json # issue draft JSON with duplicate preflight +cdidx search --recipe risky-code --format issue-drafts --open-issues github --repo Widthdom/CodeIndex # preflight against live open GitHub issues cdidx search "Thread.Yield" --format issue-drafts --issue-title "Thread.Yield audit" --issue-label audit # ad hoc issue draft JSON 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 @@ -1227,7 +1228,7 @@ same source location. | `--limit <n>` | `suggestions list`, `suggestions export` | Cap emitted suggestion records after filters and newest-first ordering. | | `--offset <n>` | `suggestions list`, `suggestions export` | Skip filtered suggestion records after newest-first ordering before emitting results. | | `--format <json\|markdown\|issue-drafts>` | `suggestions export` | Choose export format. JSON is the default, markdown is intended for human triage, and issue-drafts emits issue-ready draft objects. | -| `--open-issues <path>` | `suggestions export --format issue-drafts` | Preflight drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`. Inputs are capped at 8 MiB, 32 JSON nesting levels, 1000 issue entries, 32 labels per issue, and bounded title/URL/label strings. | +| `--open-issues <path\|github\|github:owner/name>` / `--repo <owner/name>` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | Preflight drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`; `search` can also fetch live open GitHub issues with `--open-issues github --repo owner/name` or `--open-issues github:owner/name`. Inputs are capped at 8 MiB, 32 JSON nesting levels, 1000 issue entries, 32 labels per issue, and bounded title/URL/label strings. | | `--issue-title <title>` / `--issue-label <label>` | `search --format issue-drafts` | Set the title for an ad hoc search draft and add label hints. `--issue-label` can be repeated or comma-separated. | | `--check` | `status` | Verify that `.cdidx/codeindex.db` exactly matches the current indexable workspace by comparing DB file paths/checksums against a fresh filesystem scan. Matching indexes exit `0`; stale indexes exit `5`. | | `--dry-run` | `index` | Scan files and report what would change without writing to the database | @@ -3174,6 +3175,7 @@ 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 --recipe risky-code --format issue-drafts --open-issues open-issues.json # duplicate preflight 付き issue draft JSON +cdidx search --recipe risky-code --format issue-drafts --open-issues github --repo Widthdom/CodeIndex # GitHub の live open issue と照合 cdidx search "Thread.Yield" --format issue-drafts --issue-title "Thread.Yield audit" --issue-label audit # ad hoc issue draft JSON cdidx search "--open-reports" --path README.md --count # `--` で始まる引用済みリテラル cdidx search --query "--path" --path README.md # オプションに見えるリテラルを検索 @@ -3527,7 +3529,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--limit <n>` | `suggestions list`, `suggestions export` | filter と新しい順の並び替え後に出力する提案レコード数を制限します。 | | `--offset <n>` | `suggestions list`, `suggestions export` | filter と新しい順の並び替え後、出力前に指定件数の提案レコードをスキップします。 | | `--format <json\|markdown\|issue-drafts>` | `suggestions export` | エクスポート形式を選びます。既定は JSON、markdown は人間の triage 共有向け、issue-drafts は Issue 作成用の draft object を出力します。 | -| `--open-issues <path>` | `suggestions export --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` などの open issue JSON と照合して draft を事前重複確認します。入力は 8 MiB、JSON ネスト 32 段、issue entry 1000 件、issue ごとの label 32 件、title / URL / label 文字列長の上限に制限されます。 | +| `--open-issues <path\|github\|github:owner/name>` / `--repo <owner/name>` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` などの open issue JSON と照合して draft を事前重複確認します。`search` では `--open-issues github --repo owner/name` または `--open-issues github:owner/name` で GitHub の live open issue も取得できます。入力は 8 MiB、JSON ネスト 32 段、issue entry 1000 件、issue ごとの label 32 件、title / URL / label 文字列長の上限に制限されます。 | | `--issue-title <title>` / `--issue-label <label>` | `search --format issue-drafts` | ad hoc search draft の title を指定し、label hint を追加します。`--issue-label` は繰り返し指定またはカンマ区切りに対応します。 | | `--check` | `status` | DB のファイル path/checksum と現在の index 対象 workspace を比較し、`.cdidx/codeindex.db` が完全一致するか確認。完全一致なら終了コード `0`、stale なら `5` | | `--dry-run` | `index` | DB に書き込まず、どの変更が発生するかだけを走査して報告 | diff --git a/changelog.d/unreleased/3449.added.md b/changelog.d/unreleased/3449.added.md new file mode 100644 index 0000000000..588bd936bb --- /dev/null +++ b/changelog.d/unreleased/3449.added.md @@ -0,0 +1,21 @@ +--- +category: added +issues: + - 3449 +affected: + - src/CodeIndex/Cli/IssueDuplicatePreflight.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - USER_GUIDE.md +--- + +## English + +- **Issue-draft duplicate preflight can fetch open GitHub issues directly (#3449)** — `cdidx search --format issue-drafts --open-issues github --repo owner/name` now preflights drafts against live open GitHub issues without requiring a local JSON export. + +## 日本語 + +- **issue-draft の重複事前確認で GitHub の open issue を直接取得できるようになりました (#3449)** — `cdidx search --format issue-drafts --open-issues github --repo owner/name` は、ローカル JSON export を用意せずに GitHub の live open issue と draft を照合できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 3840e6444c..5eed043805 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -277,7 +277,8 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--query", ValuePlaceholder = "<query>", Description = "Literal query", Commands = Set(QueryCommands) }, new() { Name = "--recipe", ValuePlaceholder = "<name>", 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 = "--open-issues", ValuePlaceholder = "<path>", Description = "Preflight issue drafts against open issue JSON", Commands = Set("search", "suggestions") }, + new() { Name = "--open-issues", ValuePlaceholder = "<path|github|github:owner/name>", Description = "Preflight issue drafts against open issue JSON or GitHub open issues", Commands = Set("search", "suggestions") }, + new() { Name = "--repo", ValuePlaceholder = "<owner/name>", Description = "Search issue-drafts: GitHub repository for --open-issues github", Commands = Set("search") }, new() { Name = "--issue-title", ValuePlaceholder = "<title>", Description = "Search issue-drafts: override the title for an ad hoc search draft", Commands = Set("search") }, new() { Name = "--issue-label", ValuePlaceholder = "<label>", Description = "Search issue-drafts: add a label hint; repeat or comma-separate values", Commands = Set("search") }, new() { Name = "--status", ValuePlaceholder = "<status>", Description = "Suggestions: filter by suggestion status", Commands = Set("suggestions") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index b0447fa5b4..619c37b4df 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 <projectPath> --commits <commit-ref> [commit-ref ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-changed-between", "cdidx index <projectPath> --changed-between <old-ref> <new-ref> [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-files", "cdidx index <projectPath> --files <path> [path ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), - ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), + ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), ("definition", "cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since <datetime>]"), ("goto", "cdidx goto <query>|--query <query>|-- <query> [--db <path>] [--json] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--all]"), ("references", "cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]"), diff --git a/src/CodeIndex/Cli/IssueDuplicatePreflight.cs b/src/CodeIndex/Cli/IssueDuplicatePreflight.cs index 27fcccdb15..50d26287a7 100644 --- a/src/CodeIndex/Cli/IssueDuplicatePreflight.cs +++ b/src/CodeIndex/Cli/IssueDuplicatePreflight.cs @@ -1,4 +1,6 @@ using System.Globalization; +using System.Net; +using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; @@ -15,6 +17,13 @@ internal sealed class IssueDuplicatePreflight internal const int MaxOpenIssueUrlLength = 2048; internal const int MaxOpenIssueLabelLength = 128; internal const int MaxTitleTokenizationInputLength = MaxOpenIssueTitleLength; + internal const int MaxGitHubRepositoryLength = 200; + private const int GitHubOpenIssuesPerPage = 100; + private const int MaxGitHubOpenIssuePages = (MaxOpenIssueCount / GitHubOpenIssuesPerPage) + 1; + private const string GitHubSourceName = "github"; + private const string GitHubSourcePrefix = "github:"; + private const string GitHubTokenEnvironmentVariable = "CDIDX_GITHUB_TOKEN"; + private const string GitHubApiBase = "https://api.github.com"; private static readonly HashSet<string> StopTitleTokens = new(StringComparer.OrdinalIgnoreCase) { @@ -34,6 +43,9 @@ internal sealed class IssueDuplicatePreflight }; private readonly List<OpenIssue> _issues; + private static readonly HttpClient s_defaultHttpClient = CreateDefaultHttpClient(); + internal static HttpClient? s_httpClientOverride; + private static HttpClient HttpClient => s_httpClientOverride ?? s_defaultHttpClient; private IssueDuplicatePreflight(bool isChecked, string? source, List<OpenIssue> issues) { @@ -46,6 +58,11 @@ private IssueDuplicatePreflight(bool isChecked, string? source, List<OpenIssue> public string? Source { get; } public int OpenIssueCount => _issues.Count; + public static bool IsGitHubOpenIssuesSource(string? source) + => !string.IsNullOrWhiteSpace(source) + && (string.Equals(source.Trim(), GitHubSourceName, StringComparison.OrdinalIgnoreCase) + || source.Trim().StartsWith(GitHubSourcePrefix, StringComparison.OrdinalIgnoreCase)); + public static bool TryLoad(string? path, out IssueDuplicatePreflight preflight, out string? error) { error = null; @@ -80,6 +97,31 @@ public static bool TryLoad(string? path, out IssueDuplicatePreflight preflight, } } + public static bool TryLoad(string? source, string? repository, out IssueDuplicatePreflight preflight, out string? error) + { + error = null; + if (!IsGitHubOpenIssuesSource(source)) + { + if (!string.IsNullOrWhiteSpace(repository)) + { + preflight = new IssueDuplicatePreflight(false, null, []); + error = "--repo can only be used with `--open-issues github`."; + return false; + } + + return TryLoad(source, out preflight, out error); + } + + var requestedRepository = ExtractGitHubRepository(source, repository); + if (!TryNormalizeGitHubRepository(requestedRepository, out var normalizedRepository, out error)) + { + preflight = new IssueDuplicatePreflight(false, null, []); + return false; + } + + return TryLoadFromGitHub(normalizedRepository, out preflight, out error); + } + public List<SuggestionIssueDraftDuplicateMatchJsonResult> FindMatches(string draftTitle, IReadOnlyList<string> draftLabels) { if (!Checked || _issues.Count == 0) @@ -144,7 +186,7 @@ public List<SuggestionIssueDraftDuplicateMatchJsonResult> FindMatches(string dra .ToList(); } - private static List<OpenIssue> ParseOpenIssues(JsonNode? root) + private static List<OpenIssue> ParseOpenIssues(JsonNode? root, bool skipPullRequests = false) { var array = root as JsonArray ?? root?["issues"] as JsonArray @@ -160,6 +202,8 @@ private static List<OpenIssue> ParseOpenIssues(JsonNode? root) break; entriesRead++; + if (skipPullRequests && item?["pull_request"] != null) + continue; var title = TryReadString(item?["title"], MaxOpenIssueTitleLength); if (string.IsNullOrWhiteSpace(title)) continue; @@ -195,6 +239,142 @@ private static List<string> ReadLabels(JsonNode? labelsNode) return result.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } + private static bool TryLoadFromGitHub(string repository, out IssueDuplicatePreflight preflight, out string? error) + { + var issues = new List<OpenIssue>(); + try + { + for (var page = 1; page <= MaxGitHubOpenIssuePages && issues.Count < MaxOpenIssueCount; page++) + { + var pageIssues = FetchGitHubOpenIssuePage(repository, page, out var rawEntryCount); + issues.AddRange(pageIssues); + if (rawEntryCount == 0 || rawEntryCount < GitHubOpenIssuesPerPage) + break; + } + + preflight = new IssueDuplicatePreflight(true, $"{GitHubSourcePrefix}{repository}", issues.Take(MaxOpenIssueCount).ToList()); + error = null; + return true; + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException or IOException or InvalidOperationException) + { + preflight = new IssueDuplicatePreflight(false, null, []); + error = $"could not fetch --open-issues github for repository '{repository}': {ex.Message}"; + return false; + } + } + + private static List<OpenIssue> FetchGitHubOpenIssuePage(string repository, int page, out int rawEntryCount) + { + var slash = repository.IndexOf('/'); + var owner = repository[..slash]; + var name = repository[(slash + 1)..]; + var url = $"{GitHubApiBase}/repos/{Uri.EscapeDataString(owner)}/{Uri.EscapeDataString(name)}/issues?state=open&per_page={GitHubOpenIssuesPerPage.ToString(CultureInfo.InvariantCulture)}&page={page.ToString(CultureInfo.InvariantCulture)}"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + var token = Environment.GetEnvironmentVariable(GitHubTokenEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(token)) + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + + using var response = HttpClient.Send(request, HttpCompletionOption.ResponseHeadersRead); + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"GitHub API responded {(int)response.StatusCode} {response.ReasonPhrase}"); + + var json = ReadContentWithinLimit(response.Content, MaxOpenIssuesJsonBytes) + ?? throw new IOException($"GitHub open-issues response exceeds maximum supported size of {MaxOpenIssuesJsonBytes} bytes."); + var root = JsonNode.Parse(json, documentOptions: new JsonDocumentOptions { MaxDepth = MaxOpenIssuesJsonDepth }); + rawEntryCount = root is JsonArray array ? array.Count : 0; + return ParseOpenIssues(root, skipPullRequests: true); + } + + private static string? ExtractGitHubRepository(string? source, string? repository) + { + if (!string.IsNullOrWhiteSpace(repository)) + return repository; + var trimmed = source?.Trim(); + return trimmed != null && trimmed.StartsWith(GitHubSourcePrefix, StringComparison.OrdinalIgnoreCase) + ? trimmed[GitHubSourcePrefix.Length..] + : null; + } + + private static bool TryNormalizeGitHubRepository(string? repository, out string normalizedRepository, out string? error) + { + normalizedRepository = string.Empty; + if (string.IsNullOrWhiteSpace(repository)) + { + error = "--open-issues github requires --repo <owner/name> or --open-issues github:<owner/name>."; + return false; + } + + var trimmed = repository.Trim(); + if (trimmed.Length > MaxGitHubRepositoryLength) + { + error = $"--repo value too long (max {MaxGitHubRepositoryLength} characters)."; + return false; + } + + var slash = trimmed.IndexOf('/'); + if (slash <= 0 || slash != trimmed.LastIndexOf('/') || slash == trimmed.Length - 1) + { + error = "--repo must use owner/name form."; + return false; + } + + var owner = trimmed[..slash]; + var name = trimmed[(slash + 1)..]; + if (!IsValidGitHubRepositoryPart(owner) || !IsValidGitHubRepositoryPart(name)) + { + error = "--repo must contain only letters, digits, '.', '_', or '-' in owner/name form."; + return false; + } + + normalizedRepository = $"{owner}/{name}"; + error = null; + return true; + } + + private static bool IsValidGitHubRepositoryPart(string value) + => value.Length > 0 && value.All(c => char.IsLetterOrDigit(c) || c is '.' or '_' or '-'); + + private static string? ReadContentWithinLimit(HttpContent content, int maxBytes) + { + using var stream = content.ReadAsStream(); + using var buffer = new MemoryStream(Math.Min(maxBytes, 8192)); + var chunk = new byte[8192]; + var total = 0; + while (true) + { + var read = stream.Read(chunk, 0, chunk.Length); + if (read == 0) + break; + total += read; + if (total > maxBytes) + return null; + buffer.Write(chunk, 0, read); + } + + return Encoding.UTF8.GetString(buffer.ToArray()); + } + + private static HttpClient CreateDefaultHttpClient() + { + var handler = new HttpClientHandler + { + UseProxy = true, + Proxy = HttpClient.DefaultProxy, + DefaultProxyCredentials = CredentialCache.DefaultCredentials, + }; + return new HttpClient(handler) + { + Timeout = TimeSpan.FromSeconds(10), + DefaultRequestHeaders = + { + { "User-Agent", "cdidx" }, + { "Accept", "application/vnd.github+json" }, + { "X-GitHub-Api-Version", "2022-11-28" }, + }, + }; + } + private static string? TryReadString(JsonNode? node, int maxLength) { if (node == null) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 838d5b18fd..bb10a2496e 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -136,6 +136,7 @@ public static class QueryCommandRunner "--query", "--recipe", "--open-issues", + "--repo", "--issue-title", "--issue-label", "--group-by", @@ -513,6 +514,14 @@ 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 (options.OpenIssuesRepository != null && !IssueDuplicatePreflight.IsGitHubOpenIssuesSource(options.OpenIssuesPath)) + { + WriteUsageError( + "--repo can only be used with `--open-issues github`.", + GetUsageLineOrThrow("search"), + "Use `--open-issues github --repo owner/name` to fetch open issues directly from GitHub."); + return CommandExitCodes.UsageError; + } if ((options.IssueTitle != null || options.IssueLabels.Count > 0) && options.OutputFormat != OutputFormatIssueDrafts) { WriteUsageError( @@ -907,12 +916,12 @@ private static int RunSearchRecipeIssueDrafts(QueryCommandOptions options, JsonS $"Use `cdidx search --list-recipes` to see available recipes: {available}."); return CommandExitCodes.UsageError; } - if (!IssueDuplicatePreflight.TryLoad(options.OpenIssuesPath, out var preflight, out var error)) + if (!IssueDuplicatePreflight.TryLoad(options.OpenIssuesPath, options.OpenIssuesRepository, out var preflight, out var error)) { WriteUsageError( error!, GetUsageLineOrThrow("search"), - "Pass a readable JSON array from `gh issue list --state open --json number,title,labels,url`."); + "Pass a readable JSON array from `gh issue list --state open --json number,title,labels,url`, or use `--open-issues github --repo owner/name`."); return CommandExitCodes.UsageError; } @@ -942,12 +951,12 @@ private static int RunSearchRecipeIssueDrafts(QueryCommandOptions options, JsonS private static int RunSearchIssueDrafts(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool exact) { - if (!IssueDuplicatePreflight.TryLoad(options.OpenIssuesPath, out var preflight, out var error)) + if (!IssueDuplicatePreflight.TryLoad(options.OpenIssuesPath, options.OpenIssuesRepository, out var preflight, out var error)) { WriteUsageError( error!, GetUsageLineOrThrow("search"), - "Pass a readable JSON array from `gh issue list --state open --json number,title,labels,url`."); + "Pass a readable JSON array from `gh issue list --state open --json number,title,labels,url`, or use `--open-issues github --repo owner/name`."); return CommandExitCodes.UsageError; } @@ -6344,6 +6353,7 @@ public static QueryCommandOptions ParseArgs( string? recipeName = null; bool listRecipes = false; string? openIssuesPath = null; + string? openIssuesRepository = null; string? issueTitle = null; var issueLabels = new List<string>(); bool languagesIndexedOnly = false; @@ -6650,6 +6660,15 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(openIssuesError!); break; + case "--repo": + if (TryReadStringOptionValue(args, ref i, "--repo", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var repoValue, out var repoError)) + { + WarnIfDuplicateSingleValueOption("--repo", repoValue!); + openIssuesRepository = repoValue; + } + else + AddParseError(repoError!); + break; case "--issue-title": if (TryReadStringOptionValue(args, ref i, "--issue-title", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var issueTitleValue, out var issueTitleError)) { @@ -7303,6 +7322,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) RecipeName = recipeName, ListRecipes = listRecipes, OpenIssuesPath = openIssuesPath, + OpenIssuesRepository = openIssuesRepository, IssueTitle = issueTitle, IssueLabels = issueLabels, LanguagesIndexedOnly = languagesIndexedOnly, @@ -9865,7 +9885,8 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont ["--lang"] = "pass a language identifier, e.g. `--lang csharp`. Run `cdidx languages` for the supported set.", ["--query"] = "pass a search literal, e.g. `--query \"authenticate\"`. Use the `--query` form when the literal starts with `-`.", ["--recipe"] = "pass a built-in audit recipe name, e.g. `--recipe risky-code`; run `cdidx search --list-recipes` to list available recipes.", - ["--open-issues"] = "pass an open-issues JSON file, e.g. `--open-issues open-issues.json`; only valid with `search --format issue-drafts`.", + ["--open-issues"] = "pass an open-issues JSON file or GitHub source, e.g. `--open-issues open-issues.json` or `--open-issues github --repo owner/name`; only valid with `search --format issue-drafts`.", + ["--repo"] = "pass a GitHub repository in owner/name form for `--open-issues github`, e.g. `--repo Widthdom/CodeIndex`.", ["--issue-title"] = "pass an issue title hint for ad hoc search issue-drafts, e.g. `--issue-title \"Thread.Yield audit\"`.", ["--issue-label"] = "pass an issue label hint for search issue-drafts, e.g. `--issue-label audit`; repeat or comma-separate values.", ["--kind"] = "pass a kind identifier, e.g. `--kind function`. definition/symbols/hotspots/unused take a symbol kind; references/callers/callees take a reference kind such as `call`, `instantiate`, or `subscribe`. Run the command's `--help` for the kind list.", @@ -10290,6 +10311,7 @@ public sealed class QueryCommandOptions public string? RecipeName { get; init; } public bool ListRecipes { get; init; } public string? OpenIssuesPath { get; init; } + public string? OpenIssuesRepository { get; init; } public string? IssueTitle { get; init; } public List<string> IssueLabels { get; init; } = []; public bool LanguagesIndexedOnly { get; init; } diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 3e738f98ad..3aba3dbe37 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>|--query <query>|-- <query>", output); Assert.Contains("cdidx callers <query>|--query <query>|-- <query>", output); Assert.Contains("cdidx callees <query>|--query <query>|-- <query>", output); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]", output); Assert.Contains("cdidx inspect <query>|--query <query>|-- <query> [--db <path>] [--json] [--pretty] [--compact] [--fields <csv>] [--body-only] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--max-line-width <n>] [--exact|--exact-name]", output); @@ -269,7 +269,7 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() { var output = CaptureFullUsageOutput(showBanner: false); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx symbols [query|--query <query>|-- <query>] [--name <name>] [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx files [query|--query <query>|-- <query>] [--db <path>] [--json[=ndjson|array]] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--since <datetime>] [--bytes]", output); Assert.Contains("cdidx hotspots [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count]", output); diff --git a/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs b/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs index f046f759fe..de3a718e26 100644 --- a/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs +++ b/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs @@ -4,9 +4,11 @@ namespace CodeIndex.Tests; +[Collection("SQLite pool sensitive")] public sealed class IssueDuplicatePreflightTests : IDisposable { private readonly string _tempDir; + private readonly EnvironmentVariableScope _env = EnvironmentVariableScope.Capture("CDIDX_GITHUB_TOKEN", "GITHUB_TOKEN"); public IssueDuplicatePreflightTests() { @@ -88,6 +90,57 @@ public void TryLoad_CapsScalarsAndLabelsPerIssue() Assert.All(match.Labels, label => Assert.True(label.Length <= IssueDuplicatePreflight.MaxOpenIssueLabelLength)); } + [Fact] + public void TryLoad_GitHubSourceFetchesOpenIssuesWithExplicitToken_Issue3449() + { + _env.Set("CDIDX_GITHUB_TOKEN", "explicit-token"); + _env.Set("GITHUB_TOKEN", "ignored-token"); + var handler = new RecordingOpenIssuesHandler( + """ + [ + { + "number": 3449, + "title": "Issue-draft duplicate preflight should fetch open GitHub issues directly", + "labels": [{"name": "enhancement"}], + "html_url": "https://github.example.test/Widthdom/CodeIndex/issues/3449" + }, + { + "number": 1, + "title": "Pull request entry should be ignored", + "labels": [{"name": "enhancement"}], + "html_url": "https://github.example.test/Widthdom/CodeIndex/pull/1", + "pull_request": {} + } + ] + """); + IssueDuplicatePreflight.s_httpClientOverride = new HttpClient(handler); + + var loaded = IssueDuplicatePreflight.TryLoad("github", "Widthdom/CodeIndex", out var preflight, out var error); + + Assert.True(loaded, error); + Assert.True(preflight.Checked); + Assert.Equal("github:Widthdom/CodeIndex", preflight.Source); + Assert.Equal(1, preflight.OpenIssueCount); + var request = Assert.Single(handler.Requests); + Assert.Equal("https://api.github.com/repos/Widthdom/CodeIndex/issues?state=open&per_page=100&page=1", request.Uri); + Assert.Equal("Bearer", request.AuthorizationScheme); + Assert.Equal("explicit-token", request.AuthorizationParameter); + var match = Assert.Single(preflight.FindMatches( + "Issue-draft duplicate preflight should fetch open GitHub issues directly", + ["enhancement"])); + Assert.Equal(3449, match.Number); + } + + [Fact] + public void TryLoad_GitHubSourceRequiresRepository_Issue3449() + { + var loaded = IssueDuplicatePreflight.TryLoad("github", repository: null, out var preflight, out var error); + + Assert.False(loaded); + Assert.False(preflight.Checked); + Assert.Contains("--open-issues github requires --repo", error); + } + private string WriteOpenIssuesJson(string json) { var path = Path.Combine(_tempDir, "open-issues.json"); @@ -117,6 +170,8 @@ private static void AppendIssue(StringBuilder builder, int number, string title, public void Dispose() { + IssueDuplicatePreflight.s_httpClientOverride = null; + _env.Dispose(); try { if (Directory.Exists(_tempDir)) @@ -126,4 +181,32 @@ public void Dispose() { } } + + private sealed class RecordingOpenIssuesHandler(string json) : HttpMessageHandler + { + internal List<RecordedOpenIssuesRequest> Requests { get; } = []; + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + => BuildResponse(request); + + protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + => Task.FromResult(BuildResponse(request)); + + private HttpResponseMessage BuildResponse(HttpRequestMessage request) + { + Requests.Add(new RecordedOpenIssuesRequest( + request.RequestUri!.ToString(), + request.Headers.Authorization?.Scheme, + request.Headers.Authorization?.Parameter)); + return new HttpResponseMessage(System.Net.HttpStatusCode.OK) + { + Content = new StringContent(json, Encoding.UTF8, "application/json"), + }; + } + } + + private sealed record RecordedOpenIssuesRequest( + string Uri, + string? AuthorizationScheme, + string? AuthorizationParameter); } From 68e66b402dbd66190db0c0026ba77587062aa865 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Thu, 11 Jun 2026 23:59:54 +0900 Subject: [PATCH 3/8] Expose recipe metadata and replay commands (#3447) --- USER_GUIDE.md | 8 +- changelog.d/unreleased/3447.added.md | 19 ++++ src/CodeIndex/Cli/JsonOutputContracts.cs | 2 + src/CodeIndex/Cli/QueryCommandRunner.cs | 105 +++++++++++++++++- src/CodeIndex/Cli/SearchAuditRecipes.cs | 20 ++++ .../QueryCommandRunnerSearchTests.cs | 11 +- 6 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/3447.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 2d0db10328..7637c4eef1 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1238,8 +1238,8 @@ same source location. | `--exclude-visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | Exclude symbols with the requested visibility values. Accepts the same comma-separated values and alias expansion as `--visibility`. | | `--path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | Restrict results to glob-style path patterns. `*` and `?` are wildcards. Repeatable; multiple values are OR'd together | | `--query <query>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | Pass a query literal explicitly, useful when the query starts with `-`. Query commands except `find` also accept `-- <query>` as a one-token query escape while continuing to parse later options. | -| `--recipe <name>` | `search` | Run a reusable audit recipe such as `risky-code`. Normal search filters and snippet controls apply to every recipe query; text, `--json` / `--format json`, and `--format issue-drafts` are supported. | -| `--list-recipes` | `search` | List available search audit recipes with query text, recommended labels, exact-match mode, and false-positive guidance. | +| `--recipe <name>` | `search` | Run a reusable audit recipe such as `risky-code`. Normal search filters and snippet controls apply to every recipe query; `--limit` / `--top` is per child query. Text, `--json` / `--format json`, and `--format issue-drafts` are supported, and issue drafts include a replay command. | +| `--list-recipes` | `search` | List available search audit recipes with query text, recommended labels, exact-match mode, false-positive guidance, supported formats, filter support, and limit semantics. | | `--open-issues <path>` | `search --recipe <name> --format issue-drafts` | Preflight generated issue drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`. | | `--exclude-path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | Exclude glob-style path patterns. `*` and `?` are wildcards (repeatable) | | `--exclude-tests` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | Exclude likely test files and prefer production code | @@ -3538,8 +3538,8 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--exclude-visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | 指定した可視性のシンボルを除外する。値と alias 展開は `--visibility` と同じ | | `--path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | glob 形式のパスパターンで結果を絞る。`*` と `?` がワイルドカード。繰り返し指定可(複数値は OR で結合) | | `--query <query>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | クエリを明示的なリテラルとして渡す。クエリが `-` で始まる場合に有用。`find` 以外のクエリ系コマンドでは `-- <query>` も1トークンのクエリエスケープとして受け付け、その後のオプション解析を続ける。 | -| `--recipe <name>` | `search` | `risky-code` などの再利用可能な audit recipe を実行する。通常の search filter と snippet control は recipe 内の各 query に適用され、text、`--json` / `--format json`、`--format issue-drafts` に対応する。 | -| `--list-recipes` | `search` | 利用可能な search audit recipe を query text、推奨 label、exact-match mode、false-positive guidance 付きで一覧表示する。 | +| `--recipe <name>` | `search` | `risky-code` などの再利用可能な audit recipe を実行する。通常の search filter と snippet control は recipe 内の各 query に適用され、`--limit` / `--top` は child query ごとの上限になる。text、`--json` / `--format json`、`--format issue-drafts` に対応し、issue draft には再実行コマンドを含める。 | +| `--list-recipes` | `search` | 利用可能な search audit recipe を query text、推奨 label、exact-match mode、false-positive guidance、対応 format、filter support、limit semantics 付きで一覧表示する。 | | `--open-issues <path>` | `search --recipe <name> --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` のような open issue JSON file と照合し、生成した issue draft を事前重複確認する。 | | `--exclude-path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | glob 形式のパスパターンを除外する。`*` と `?` がワイルドカード。繰り返し指定可 | | `--exclude-tests` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | テストらしいパスを除外し、本番コードを優先 | diff --git a/changelog.d/unreleased/3447.added.md b/changelog.d/unreleased/3447.added.md new file mode 100644 index 0000000000..5903855948 --- /dev/null +++ b/changelog.d/unreleased/3447.added.md @@ -0,0 +1,19 @@ +--- +category: added +issues: + - 3447 +affected: + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md +--- + +## English + +- **Recipe metadata and issue-draft replay commands are now exposed (#3447)** — `search --list-recipes --json` includes supported formats, filter support, and per-query limit semantics, and recipe issue drafts include a replayable `cdidx search` command. + +## 日本語 + +- **recipe metadata と issue-draft replay command を出力するようになりました (#3447)** — `search --list-recipes --json` は対応 format、filter support、per-query の limit semantics を含み、recipe issue draft には再実行可能な `cdidx search` コマンドを含めます。 diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index bb5066b1ee..5280f2462b 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -472,6 +472,8 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(SearchQueryHint))] [JsonSerializable(typeof(SearchRecipeListItemJsonResult))] [JsonSerializable(typeof(SearchRecipeListJsonResult))] +[JsonSerializable(typeof(SearchRecipeFilterSupportJsonResult))] +[JsonSerializable(typeof(SearchRecipeLimitSemanticsJsonResult))] [JsonSerializable(typeof(SearchRecipeQueryListItemJsonResult))] [JsonSerializable(typeof(SearchRecipeQueryResultJsonResult))] [JsonSerializable(typeof(SearchRecipeRunJsonResult))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index bb10a2496e..5e30f309a7 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -84,6 +84,22 @@ public static class QueryCommandRunner private const string HotspotsGroupedByStatement = "statement"; private const string JsonOutputFormatNdjson = "ndjson"; private const string JsonOutputFormatArray = "array"; + private static readonly List<string> SearchRecipeSupportedFormats = ["text", "json", OutputFormatIssueDrafts]; + private static readonly SearchRecipeFilterSupportJsonResult SearchRecipeFilterSupport = new( + Lang: true, + Path: true, + ExcludePath: true, + ExcludeTests: true, + Since: true, + Dedup: true, + VisibilityRank: true, + GuardFilters: true, + SnippetControls: true, + ExactModeOverride: true); + private static readonly SearchRecipeLimitSemanticsJsonResult SearchRecipeLimitSemantics = new( + "per_query", + DefaultQueryLimit, + "--limit/--top is applied independently to each recipe child query; result_count is the sum of returned rows."); private static readonly Dictionary<string, string[]> LanguageDisplayAliases = new(StringComparer.Ordinal) { ["javascript"] = ["js", "jsx", "cjs", "mjs"], @@ -1077,7 +1093,7 @@ private static SearchIssueDraftJsonResult ToSearchIssueDraft( title, labels, evidencePaths, - BuildSearchIssueDraftBody(recipe, queryResult, evidencePaths), + BuildSearchIssueDraftBody(recipe, queryResult, evidencePaths, options), new SearchIssueDraftSourceJsonResult( recipe.Name, queryResult.Name, @@ -1145,7 +1161,8 @@ private static List<string> BuildAdHocIssueDraftLabels(QueryCommandOptions optio private static string BuildSearchIssueDraftBody( SearchAuditRecipe recipe, SearchRecipeQueryResultJsonResult queryResult, - IReadOnlyList<string> evidencePaths) + IReadOnlyList<string> evidencePaths, + QueryCommandOptions options) { var sb = new StringBuilder(); sb.AppendLine("## Summary"); @@ -1171,6 +1188,11 @@ private static string BuildSearchIssueDraftBody( sb.AppendLine("## False-positive guidance"); sb.AppendLine(queryResult.FalsePositiveGuidance); sb.AppendLine(); + sb.AppendLine("## Replay command"); + sb.AppendLine("```sh"); + sb.AppendLine(BuildSearchRecipeReplayCommand(recipe, options)); + sb.AppendLine("```"); + sb.AppendLine(); sb.AppendLine("## Search metadata"); sb.AppendLine($"- draft_id: `{recipe.Name}/{queryResult.Name}`"); sb.AppendLine($"- recipe_query: `{queryResult.Name}`"); @@ -1179,6 +1201,82 @@ private static string BuildSearchIssueDraftBody( return sb.ToString().TrimEnd(); } + private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, QueryCommandOptions options) + { + var args = new List<string> + { + "cdidx", + "search", + "--recipe", + recipe.Name, + "--format", + OutputFormatIssueDrafts, + "--limit", + options.Limit.ToString(CultureInfo.InvariantCulture), + }; + + if (options.DbPathExplicit) + AddReplayValueOption(args, "--db", options.DbPath); + if (!string.IsNullOrWhiteSpace(options.Lang)) + AddReplayValueOption(args, "--lang", options.Lang); + foreach (var pathPattern in options.PathPatterns) + AddReplayValueOption(args, "--path", pathPattern); + foreach (var excludePath in options.ExcludePaths) + AddReplayValueOption(args, "--exclude-path", excludePath); + if (options.ExcludeTests) + args.Add("--exclude-tests"); + if (options.Since.HasValue) + AddReplayValueOption(args, "--since", options.Since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); + if (options.NoDedup) + args.Add("--no-dedup"); + if (options.NoVisibilityRank) + args.Add("--no-visibility-rank"); + if (options.Exact) + args.Add("--exact"); + if (options.ExactSubstring) + args.Add("--exact-substring"); + foreach (var guardFilter in options.GuardFilters) + AddReplayValueOption(args, BuildSearchGuardReplayOptionName(guardFilter), guardFilter.Query); + if (options.GuardFilters.Count > 0 && options.GuardWindow != DbReader.DefaultSearchGuardWindow) + AddReplayValueOption(args, "--guard-window", options.GuardWindow.ToString(CultureInfo.InvariantCulture)); + AddReplayValueOption(args, "--snippet-lines", options.SnippetLines.ToString(CultureInfo.InvariantCulture)); + AddReplayValueOption(args, "--snippet-focus", FormatSearchSnippetFocusMode(options.SnippetFocus)); + AddReplayValueOption(args, "--max-line-width", options.MaxLineWidth.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(options.OpenIssuesPath)) + AddReplayValueOption(args, "--open-issues", options.OpenIssuesPath); + if (!string.IsNullOrWhiteSpace(options.OpenIssuesRepository)) + AddReplayValueOption(args, "--repo", options.OpenIssuesRepository); + foreach (var label in options.IssueLabels) + AddReplayValueOption(args, "--issue-label", label); + + return string.Join(" ", args.Select(QuoteReplayShellArg)); + } + + private static void AddReplayValueOption(List<string> args, string optionName, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return; + args.Add(optionName); + args.Add(value); + } + + private static string BuildSearchGuardReplayOptionName(SearchGuardFilter guardFilter) + { + var role = guardFilter.Role == SearchGuardRole.Require ? "require" : "reject"; + var direction = guardFilter.Direction == SearchGuardDirection.Before ? "before" : "after"; + return $"--{role}-{direction}"; + } + + private static string FormatSearchSnippetFocusMode(SearchSnippetFocusMode mode) + => mode.ToString().ToLowerInvariant(); + + private static string QuoteReplayShellArg(string arg) + { + if (arg.Length > 0 && arg.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' or '/' or ':' or '=')) + return arg; + return "'" + arg.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + } + private static string BuildAdHocSearchIssueDraftBody( SearchRecipeQueryResultJsonResult queryResult, IReadOnlyList<string> evidencePaths) @@ -1215,6 +1313,9 @@ private static string BuildAdHocSearchIssueDraftBody( recipe.Name, recipe.Description, recipe.RecommendedLabels, + SearchRecipeSupportedFormats, + SearchRecipeFilterSupport, + SearchRecipeLimitSemantics, recipe.Queries.Select(query => new SearchRecipeQueryListItemJsonResult( query.Name, query.Query, diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 111091e0e2..d82d2a9da8 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -82,8 +82,28 @@ internal sealed record SearchRecipeListItemJsonResult( [property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("description")] string Description, [property: JsonPropertyName("recommended_labels")] List<string> RecommendedLabels, + [property: JsonPropertyName("supported_formats")] List<string> SupportedFormats, + [property: JsonPropertyName("filter_support")] SearchRecipeFilterSupportJsonResult FilterSupport, + [property: JsonPropertyName("limit_semantics")] SearchRecipeLimitSemanticsJsonResult LimitSemantics, [property: JsonPropertyName("queries")] List<SearchRecipeQueryListItemJsonResult> Queries); +internal sealed record SearchRecipeFilterSupportJsonResult( + [property: JsonPropertyName("lang")] bool Lang, + [property: JsonPropertyName("path")] bool Path, + [property: JsonPropertyName("exclude_path")] bool ExcludePath, + [property: JsonPropertyName("exclude_tests")] bool ExcludeTests, + [property: JsonPropertyName("since")] bool Since, + [property: JsonPropertyName("dedup")] bool Dedup, + [property: JsonPropertyName("visibility_rank")] bool VisibilityRank, + [property: JsonPropertyName("guard_filters")] bool GuardFilters, + [property: JsonPropertyName("snippet_controls")] bool SnippetControls, + [property: JsonPropertyName("exact_mode_override")] bool ExactModeOverride); + +internal sealed record SearchRecipeLimitSemanticsJsonResult( + [property: JsonPropertyName("scope")] string Scope, + [property: JsonPropertyName("default")] int Default, + [property: JsonPropertyName("description")] string Description); + internal sealed record SearchRecipeQueryListItemJsonResult( [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 e9134b22b3..58c11befa3 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -297,6 +297,11 @@ public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() Assert.Equal(1, root.GetProperty("count").GetInt32()); Assert.Contains(recipe.GetProperty("recommended_labels").EnumerateArray(), label => label.GetString() == "audit"); + Assert.Contains(recipe.GetProperty("supported_formats").EnumerateArray(), format => format.GetString() == "issue-drafts"); + Assert.True(recipe.GetProperty("filter_support").GetProperty("exclude_tests").GetBoolean()); + Assert.True(recipe.GetProperty("filter_support").GetProperty("guard_filters").GetBoolean()); + Assert.Equal("per_query", recipe.GetProperty("limit_semantics").GetProperty("scope").GetString()); + Assert.Equal(20, recipe.GetProperty("limit_semantics").GetProperty("default").GetInt32()); Assert.Equal("ex.Message", query.GetProperty("query").GetString()); Assert.True(query.GetProperty("exact_substring").GetBoolean()); Assert.Contains("redaction", query.GetProperty("description").GetString(), StringComparison.OrdinalIgnoreCase); @@ -444,7 +449,7 @@ public void Run() """); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - ["--recipe", "risky-code", "--db", dbPath, "--format", "issue-drafts", "--open-issues", openIssuesPath], + ["--recipe", "risky-code", "--db", dbPath, "--format", "issue-drafts", "--limit", "5", "--lang", "csharp", "--path", "src/app.cs", "--exclude-tests", "--open-issues", openIssuesPath], _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); @@ -465,6 +470,10 @@ public void Run() Assert.Equal("src/app.cs", draft.GetProperty("evidence_paths")[0].GetString()); Assert.Contains("JsonDocument.Parse", body, StringComparison.Ordinal); Assert.Contains("False-positive guidance", body, StringComparison.Ordinal); + Assert.Contains("## Replay command", body, StringComparison.Ordinal); + Assert.Contains("cdidx search --recipe risky-code --format issue-drafts --limit 5", body, StringComparison.Ordinal); + Assert.Contains("--lang csharp --path src/app.cs --exclude-tests", body, StringComparison.Ordinal); + Assert.Contains($"--open-issues {openIssuesPath}", body, StringComparison.Ordinal); Assert.DoesNotContain("public sealed class App", body, StringComparison.Ordinal); Assert.Equal("unbounded-json-parse", draft.GetProperty("source").GetProperty("query_name").GetString()); Assert.Equal(1, duplicatePreflight.GetProperty("match_count").GetInt32()); From bba36e9e562c35982ffeb39dbc1b7daf8dde439d Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Fri, 12 Jun 2026 00:13:45 +0900 Subject: [PATCH 4/8] Allow recipe child query selection (#3519) --- USER_GUIDE.md | 10 +- changelog.d/unreleased/3519.added.md | 20 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 4 +- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 225 ++++++++++++++++-- tests/CodeIndex.Tests/ConsoleUiTests.cs | 4 +- .../QueryCommandRunnerSearchTests.cs | 106 ++++++++- 7 files changed, 340 insertions(+), 31 deletions(-) create mode 100644 changelog.d/unreleased/3519.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 7637c4eef1..8fc0b1d5dd 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -867,6 +867,8 @@ 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 --recipe risky-code/raw-diagnostic-echo --json # run one child query from a recipe +cdidx search --recipe risky-code --include-query raw-diagnostic-echo --exclude-query cancellation-gap --json cdidx search --recipe risky-code --format issue-drafts --open-issues open-issues.json # issue draft JSON with duplicate preflight cdidx search --recipe risky-code --format issue-drafts --open-issues github --repo Widthdom/CodeIndex # preflight against live open GitHub issues cdidx search "Thread.Yield" --format issue-drafts --issue-title "Thread.Yield audit" --issue-label audit # ad hoc issue draft JSON @@ -1238,7 +1240,8 @@ same source location. | `--exclude-visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | Exclude symbols with the requested visibility values. Accepts the same comma-separated values and alias expansion as `--visibility`. | | `--path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | Restrict results to glob-style path patterns. `*` and `?` are wildcards. Repeatable; multiple values are OR'd together | | `--query <query>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | Pass a query literal explicitly, useful when the query starts with `-`. Query commands except `find` also accept `-- <query>` as a one-token query escape while continuing to parse later options. | -| `--recipe <name>` | `search` | Run a reusable audit recipe such as `risky-code`. Normal search filters and snippet controls apply to every recipe query; `--limit` / `--top` is per child query. Text, `--json` / `--format json`, and `--format issue-drafts` are supported, and issue drafts include a replay command. | +| `--recipe <name>` | `search` | Run a reusable audit recipe such as `risky-code`. Use `recipe/query` form, such as `risky-code/raw-diagnostic-echo`, to run one child query directly. Normal search filters and snippet controls apply to every selected recipe query; `--limit` / `--top` is per child query. Text, `--json` / `--format json`, and `--format issue-drafts` are supported, and issue drafts include a replay command. | +| `--include-query <name>` / `--exclude-query <name>` | `search --recipe <name>` | Include or exclude child recipe queries by name. Repeatable and comma-separated; names are listed by `cdidx search --list-recipes`. | | `--list-recipes` | `search` | List available search audit recipes with query text, recommended labels, exact-match mode, false-positive guidance, supported formats, filter support, and limit semantics. | | `--open-issues <path>` | `search --recipe <name> --format issue-drafts` | Preflight generated issue drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`. | | `--exclude-path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | Exclude glob-style path patterns. `*` and `?` are wildcards (repeatable) | @@ -3174,6 +3177,8 @@ 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 --recipe risky-code/raw-diagnostic-echo --json # recipe 内の child query を1つだけ実行 +cdidx search --recipe risky-code --include-query raw-diagnostic-echo --exclude-query cancellation-gap --json cdidx search --recipe risky-code --format issue-drafts --open-issues open-issues.json # duplicate preflight 付き issue draft JSON cdidx search --recipe risky-code --format issue-drafts --open-issues github --repo Widthdom/CodeIndex # GitHub の live open issue と照合 cdidx search "Thread.Yield" --format issue-drafts --issue-title "Thread.Yield audit" --issue-label audit # ad hoc issue draft JSON @@ -3538,7 +3543,8 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--exclude-visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | 指定した可視性のシンボルを除外する。値と alias 展開は `--visibility` と同じ | | `--path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | glob 形式のパスパターンで結果を絞る。`*` と `?` がワイルドカード。繰り返し指定可(複数値は OR で結合) | | `--query <query>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | クエリを明示的なリテラルとして渡す。クエリが `-` で始まる場合に有用。`find` 以外のクエリ系コマンドでは `-- <query>` も1トークンのクエリエスケープとして受け付け、その後のオプション解析を続ける。 | -| `--recipe <name>` | `search` | `risky-code` などの再利用可能な audit recipe を実行する。通常の search filter と snippet control は recipe 内の各 query に適用され、`--limit` / `--top` は child query ごとの上限になる。text、`--json` / `--format json`、`--format issue-drafts` に対応し、issue draft には再実行コマンドを含める。 | +| `--recipe <name>` | `search` | `risky-code` などの再利用可能な audit recipe を実行する。`risky-code/raw-diagnostic-echo` のような `recipe/query` 形式で child query を1つだけ直接実行できる。通常の search filter と snippet control は選択された各 query に適用され、`--limit` / `--top` は child query ごとの上限になる。text、`--json` / `--format json`、`--format issue-drafts` に対応し、issue draft には再実行コマンドを含める。 | +| `--include-query <name>` / `--exclude-query <name>` | `search --recipe <name>` | recipe 内の child query を名前で含める、または除外する。繰り返し指定とカンマ区切りに対応し、名前は `cdidx search --list-recipes` で確認できる。 | | `--list-recipes` | `search` | 利用可能な search audit recipe を query text、推奨 label、exact-match mode、false-positive guidance、対応 format、filter support、limit semantics 付きで一覧表示する。 | | `--open-issues <path>` | `search --recipe <name> --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` のような open issue JSON file と照合し、生成した issue draft を事前重複確認する。 | | `--exclude-path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | glob 形式のパスパターンを除外する。`*` と `?` がワイルドカード。繰り返し指定可 | diff --git a/changelog.d/unreleased/3519.added.md b/changelog.d/unreleased/3519.added.md new file mode 100644 index 0000000000..fe8c3b51bd --- /dev/null +++ b/changelog.d/unreleased/3519.added.md @@ -0,0 +1,20 @@ +--- +category: added +issues: + - 3519 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - USER_GUIDE.md +--- + +## English + +- **Search recipes can now run selected child queries (#3519)** — `cdidx search --recipe recipe/query` runs one child query directly, while repeatable `--include-query` and `--exclude-query` select recipe subsets. + +## 日本語 + +- **search recipe で選択した child query を実行できるようになりました (#3519)** — `cdidx search --recipe recipe/query` で child query を1つだけ直接実行でき、繰り返し指定可能な `--include-query` と `--exclude-query` で recipe subset を選択できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 5eed043805..69f576c728 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -275,7 +275,9 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--indexed-only", Description = "Languages: list only languages present in the current index", Commands = Set(LanguagesFilterCommands) }, new() { Name = "--capability", ValuePlaceholder = "<graph|symbols|references>", Description = "Languages: filter by language capability", Commands = Set(LanguagesFilterCommands) }, new() { Name = "--query", ValuePlaceholder = "<query>", Description = "Literal query", Commands = Set(QueryCommands) }, - new() { Name = "--recipe", ValuePlaceholder = "<name>", Description = "Search: run a built-in audit recipe query set", Commands = Set("search") }, + new() { Name = "--recipe", ValuePlaceholder = "<name|name/query>", Description = "Search: run a built-in audit recipe query set, optionally selecting one child query", Commands = Set("search") }, + new() { Name = "--include-query", ValuePlaceholder = "<name>", Description = "Search recipe: include one child query; repeat or comma-separate values", Commands = Set("search") }, + new() { Name = "--exclude-query", ValuePlaceholder = "<name>", Description = "Search recipe: exclude one child query; repeat or comma-separate values", Commands = Set("search") }, new() { Name = "--list-recipes", Description = "Search: list built-in audit recipes", Commands = Set("search") }, new() { Name = "--open-issues", ValuePlaceholder = "<path|github|github:owner/name>", Description = "Preflight issue drafts against open issue JSON or GitHub open issues", Commands = Set("search", "suggestions") }, new() { Name = "--repo", ValuePlaceholder = "<owner/name>", Description = "Search issue-drafts: GitHub repository for --open-issues github", Commands = Set("search") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 619c37b4df..ee5076de74 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 <projectPath> --commits <commit-ref> [commit-ref ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-changed-between", "cdidx index <projectPath> --changed-between <old-ref> <new-ref> [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-files", "cdidx index <projectPath> --files <path> [path ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), - ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), + ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes [--include-query <name>] [--exclude-query <name>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), ("definition", "cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since <datetime>]"), ("goto", "cdidx goto <query>|--query <query>|-- <query> [--db <path>] [--json] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--all]"), ("references", "cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 5e30f309a7..a3780c2ed8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -74,6 +74,8 @@ public static class QueryCommandRunner internal const int MaxVisibilityFilterCsvEntries = 16; internal const int MaxIssueDraftLabelCount = 16; internal const int MaxIssueDraftTitleLength = GitHubIssueReporter.MaxGitHubIssueTitleLength; + internal const int MaxSearchRecipeQuerySelectorCount = 64; + internal const int MaxSearchRecipeQuerySelectorLength = 128; internal const int MaxQueryPathFilterCount = 128; internal const int MaxQueryPathFilterLength = 1024; internal const int ExactZeroHintProbeLimit = 1; @@ -151,6 +153,8 @@ public static class QueryCommandRunner "--depth", "--query", "--recipe", + "--include-query", + "--exclude-query", "--open-issues", "--repo", "--issue-title", @@ -538,6 +542,14 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Use `--open-issues github --repo owner/name` to fetch open issues directly from GitHub."); return CommandExitCodes.UsageError; } + if ((options.IncludeRecipeQueries.Count > 0 || options.ExcludeRecipeQueries.Count > 0) && options.RecipeName == null) + { + WriteUsageError( + "--include-query and --exclude-query can only be used with --recipe.", + GetUsageLineOrThrow("search"), + "Use `--recipe risky-code --include-query raw-diagnostic-echo` to run a child query subset."); + return CommandExitCodes.UsageError; + } if ((options.IssueTitle != null || options.IssueLabels.Count > 0) && options.OutputFormat != OutputFormatIssueDrafts) { WriteUsageError( @@ -842,7 +854,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) private static int WriteSearchRecipeList(QueryCommandOptions options, JsonSerializerOptions jsonOptions) { var recipes = SearchAuditRecipes.All - .Select(ToSearchRecipeListItem) + .Select(recipe => ToSearchRecipeListItem(recipe)) .ToList(); if (options.Json) { @@ -868,29 +880,139 @@ private static int WriteSearchRecipeList(QueryCommandOptions options, JsonSerial return CommandExitCodes.Success; } - private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) + private static bool TryResolveSearchRecipeSelection( + QueryCommandOptions options, + out SearchRecipeSelection selection, + out string? error) { - if (!SearchAuditRecipes.TryGet(options.RecipeName!, out var recipe)) + selection = default!; + error = null; + var recipeSelector = options.RecipeName!; + var recipeName = recipeSelector; + string? directQueryName = null; + var slash = recipeSelector.IndexOf('/'); + if (slash >= 0) + { + if (slash == 0 || slash == recipeSelector.Length - 1 || slash != recipeSelector.LastIndexOf('/')) + { + error = "--recipe child selection must use recipe/query form."; + return false; + } + if (options.IncludeRecipeQueries.Count > 0 || options.ExcludeRecipeQueries.Count > 0) + { + error = "--recipe recipe/query cannot be combined with --include-query or --exclude-query."; + return false; + } + + recipeName = recipeSelector[..slash]; + directQueryName = recipeSelector[(slash + 1)..]; + } + + if (!SearchAuditRecipes.TryGet(recipeName, out var recipe)) { var available = string.Join(", ", SearchAuditRecipes.All.Select(r => r.Name)); + error = $"unknown search recipe '{recipeName}'. Available recipes: {available}."; + return false; + } + + var queryByName = recipe.Queries.ToDictionary(query => query.Name, StringComparer.OrdinalIgnoreCase); + var availableQueries = string.Join(", ", recipe.Queries.Select(query => query.Name)); + if (!TryValidateRecipeQuerySelectors(queryByName, availableQueries, recipe.Name, options.IncludeRecipeQueries, "--include-query", out error) || + !TryValidateRecipeQuerySelectors(queryByName, availableQueries, recipe.Name, options.ExcludeRecipeQueries, "--exclude-query", out error)) + { + return false; + } + if (directQueryName != null && !queryByName.ContainsKey(directQueryName)) + { + error = $"unknown recipe query '{directQueryName}' for recipe '{recipe.Name}'. Available queries: {availableQueries}."; + return false; + } + + var selected = new List<SearchAuditRecipeQuery>(); + if (directQueryName != null) + { + selected.Add(queryByName[directQueryName]); + } + else if (options.IncludeRecipeQueries.Count > 0) + { + foreach (var queryName in options.IncludeRecipeQueries) + { + var query = queryByName[queryName]; + if (!selected.Any(existing => string.Equals(existing.Name, query.Name, StringComparison.OrdinalIgnoreCase))) + selected.Add(query); + } + } + else + { + selected.AddRange(recipe.Queries); + } + + if (options.ExcludeRecipeQueries.Count > 0) + { + var excludeSet = options.ExcludeRecipeQueries.ToHashSet(StringComparer.OrdinalIgnoreCase); + selected = selected + .Where(query => !excludeSet.Contains(query.Name)) + .ToList(); + } + + if (selected.Count == 0) + { + error = $"recipe query selection for '{recipe.Name}' is empty after applying --include-query/--exclude-query."; + return false; + } + + selection = new SearchRecipeSelection(recipe, selected); + return true; + } + + private static bool TryValidateRecipeQuerySelectors( + IReadOnlyDictionary<string, SearchAuditRecipeQuery> queryByName, + string availableQueries, + string recipeName, + IReadOnlyList<string> selectors, + string optionName, + out string? error) + { + foreach (var selector in selectors) + { + if (!queryByName.ContainsKey(selector)) + { + error = $"unknown recipe query '{selector}' for recipe '{recipeName}' in {optionName}. Available queries: {availableQueries}."; + return false; + } + } + + error = null; + return true; + } + + private sealed record SearchRecipeSelection( + SearchAuditRecipe Recipe, + List<SearchAuditRecipeQuery> Queries); + + private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) + { + if (!TryResolveSearchRecipeSelection(options, out var selection, out var selectionError)) + { WriteUsageError( - $"unknown search recipe '{options.RecipeName}'.", + selectionError!, GetUsageLineOrThrow("search"), - $"Use `cdidx search --list-recipes` to see available recipes: {available}."); + "Use `cdidx search --recipe risky-code/raw-diagnostic-echo`, or `--include-query` / `--exclude-query` with a recipe name."); return CommandExitCodes.UsageError; } + var recipe = selection.Recipe; return WithDb(options, jsonOptions, reader => { - var queryResults = CollectSearchRecipeQueryResults(reader, recipe, options, userExact, out var total); + var queryResults = CollectSearchRecipeQueryResults(reader, selection.Queries, options, userExact, out var total); if (options.Json) { Console.WriteLine(JsonSerializer.Serialize( new SearchRecipeRunJsonResult( JsonOutputContract.ApiVersion, - ToSearchRecipeListItem(recipe), - recipe.Queries.Count, + ToSearchRecipeListItem(recipe, selection.Queries), + selection.Queries.Count, total, queryResults), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeRunJsonResult)); @@ -916,22 +1038,22 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp Console.WriteLine(); } - Console.Error.WriteLine($"({total} recipe results across {recipe.Queries.Count} queries)"); + Console.Error.WriteLine($"({total} recipe results across {selection.Queries.Count} queries)"); return CommandExitCodes.Success; }); } private static int RunSearchRecipeIssueDrafts(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) { - if (!SearchAuditRecipes.TryGet(options.RecipeName!, out var recipe)) + if (!TryResolveSearchRecipeSelection(options, out var selection, out var selectionError)) { - var available = string.Join(", ", SearchAuditRecipes.All.Select(r => r.Name)); WriteUsageError( - $"unknown search recipe '{options.RecipeName}'.", + selectionError!, GetUsageLineOrThrow("search"), - $"Use `cdidx search --list-recipes` to see available recipes: {available}."); + "Use `cdidx search --recipe risky-code/raw-diagnostic-echo`, or `--include-query` / `--exclude-query` with a recipe name."); return CommandExitCodes.UsageError; } + var recipe = selection.Recipe; if (!IssueDuplicatePreflight.TryLoad(options.OpenIssuesPath, options.OpenIssuesRepository, out var preflight, out var error)) { WriteUsageError( @@ -943,7 +1065,7 @@ private static int RunSearchRecipeIssueDrafts(QueryCommandOptions options, JsonS return WithDb(options, jsonOptions, reader => { - var queryResults = CollectSearchRecipeQueryResults(reader, recipe, options, userExact, out var total); + var queryResults = CollectSearchRecipeQueryResults(reader, selection.Queries, options, userExact, out var total); var drafts = queryResults .Where(queryResult => queryResult.Count > 0) .Select(queryResult => ToSearchIssueDraft(recipe, queryResult, preflight, options)) @@ -951,8 +1073,8 @@ private static int RunSearchRecipeIssueDrafts(QueryCommandOptions options, JsonS Console.WriteLine(JsonSerializer.Serialize( new SearchIssueDraftExportJsonResult( JsonOutputContract.ApiVersion, - ToSearchRecipeListItem(recipe), - recipe.Queries.Count, + ToSearchRecipeListItem(recipe, selection.Queries), + selection.Queries.Count, total, drafts.Count, new SuggestionIssueDraftPreflightSummaryJsonResult( @@ -1026,14 +1148,14 @@ private static int RunSearchIssueDrafts(QueryCommandOptions options, JsonSeriali private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryResults( DbReader reader, - SearchAuditRecipe recipe, + IReadOnlyList<SearchAuditRecipeQuery> recipeQueries, QueryCommandOptions options, bool userExact, out int total) { var queryResults = new List<SearchRecipeQueryResultJsonResult>(); total = 0; - foreach (var recipeQuery in recipe.Queries) + foreach (var recipeQuery in recipeQueries) { var exact = userExact || recipeQuery.ExactSubstring; var results = reader.Search( @@ -1190,7 +1312,7 @@ private static string BuildSearchIssueDraftBody( sb.AppendLine(); sb.AppendLine("## Replay command"); sb.AppendLine("```sh"); - sb.AppendLine(BuildSearchRecipeReplayCommand(recipe, options)); + sb.AppendLine(BuildSearchRecipeReplayCommand(recipe, options, queryResult.Name)); sb.AppendLine("```"); sb.AppendLine(); sb.AppendLine("## Search metadata"); @@ -1201,14 +1323,17 @@ private static string BuildSearchIssueDraftBody( return sb.ToString().TrimEnd(); } - private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, QueryCommandOptions options) + private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, QueryCommandOptions options, string? queryName = null) { + var recipeSelector = string.IsNullOrWhiteSpace(queryName) + ? recipe.Name + : $"{recipe.Name}/{queryName}"; var args = new List<string> { "cdidx", "search", "--recipe", - recipe.Name, + recipeSelector, "--format", OutputFormatIssueDrafts, "--limit", @@ -1246,6 +1371,13 @@ private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, Q AddReplayValueOption(args, "--open-issues", options.OpenIssuesPath); if (!string.IsNullOrWhiteSpace(options.OpenIssuesRepository)) AddReplayValueOption(args, "--repo", options.OpenIssuesRepository); + if (queryName == null) + { + foreach (var includeQuery in options.IncludeRecipeQueries) + AddReplayValueOption(args, "--include-query", includeQuery); + foreach (var excludeQuery in options.ExcludeRecipeQueries) + AddReplayValueOption(args, "--exclude-query", excludeQuery); + } foreach (var label in options.IssueLabels) AddReplayValueOption(args, "--issue-label", label); @@ -1309,14 +1441,14 @@ private static string BuildAdHocSearchIssueDraftBody( return sb.ToString().TrimEnd(); } - private static SearchRecipeListItemJsonResult ToSearchRecipeListItem(SearchAuditRecipe recipe) => new( + private static SearchRecipeListItemJsonResult ToSearchRecipeListItem(SearchAuditRecipe recipe, IReadOnlyList<SearchAuditRecipeQuery>? queries = null) => new( recipe.Name, recipe.Description, recipe.RecommendedLabels, SearchRecipeSupportedFormats, SearchRecipeFilterSupport, SearchRecipeLimitSemantics, - recipe.Queries.Select(query => new SearchRecipeQueryListItemJsonResult( + (queries ?? recipe.Queries).Select(query => new SearchRecipeQueryListItemJsonResult( query.Name, query.Query, query.Description, @@ -6452,6 +6584,8 @@ public static QueryCommandOptions ParseArgs( List<string>? mapSections = null; bool dependencyCycles = false; string? recipeName = null; + var includeRecipeQueries = new List<string>(); + var excludeRecipeQueries = new List<string>(); bool listRecipes = false; string? openIssuesPath = null; string? openIssuesRepository = null; @@ -6507,6 +6641,31 @@ void AddIssueDraftLabels(string rawLabels) } } + void AddRecipeQuerySelectors(string optionName, string rawSelectors, List<string> selectors) + { + if (string.IsNullOrWhiteSpace(rawSelectors)) + { + AddParseError($"Error: {optionName} value cannot be empty."); + return; + } + + foreach (var selector in rawSelectors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (selectors.Count >= MaxSearchRecipeQuerySelectorCount) + { + AddParseError($"Error: search recipes accept at most {MaxSearchRecipeQuerySelectorCount} {optionName} values."); + return; + } + if (selector.Length > MaxSearchRecipeQuerySelectorLength) + { + AddParseError($"Error: {optionName} value too long (max {MaxSearchRecipeQuerySelectorLength} characters)."); + return; + } + if (!selectors.Contains(selector, StringComparer.OrdinalIgnoreCase)) + selectors.Add(selector); + } + } + void AddStatusCheckScopes(string rawScopes) { if (string.IsNullOrWhiteSpace(rawScopes)) @@ -6749,6 +6908,18 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(recipeError!); break; + case "--include-query": + if (TryReadStringOptionValue(args, ref i, "--include-query", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var includeQueryValue, out var includeQueryError)) + AddRecipeQuerySelectors("--include-query", includeQueryValue!, includeRecipeQueries); + else + AddParseError(includeQueryError!); + break; + case "--exclude-query": + if (TryReadStringOptionValue(args, ref i, "--exclude-query", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var excludeQueryValue, out var excludeQueryError)) + AddRecipeQuerySelectors("--exclude-query", excludeQueryValue!, excludeRecipeQueries); + else + AddParseError(excludeQueryError!); + break; case "--list-recipes": listRecipes = true; break; @@ -7421,6 +7592,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) MapSections = mapSections, DependencyCycles = dependencyCycles, RecipeName = recipeName, + IncludeRecipeQueries = includeRecipeQueries, + ExcludeRecipeQueries = excludeRecipeQueries, ListRecipes = listRecipes, OpenIssuesPath = openIssuesPath, OpenIssuesRepository = openIssuesRepository, @@ -9985,7 +10158,9 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont ["--top"] = "pass a positive integer, e.g. `--top 20` (alias for `--limit`, default 20).", ["--lang"] = "pass a language identifier, e.g. `--lang csharp`. Run `cdidx languages` for the supported set.", ["--query"] = "pass a search literal, e.g. `--query \"authenticate\"`. Use the `--query` form when the literal starts with `-`.", - ["--recipe"] = "pass a built-in audit recipe name, e.g. `--recipe risky-code`; run `cdidx search --list-recipes` to list available recipes.", + ["--recipe"] = "pass a built-in audit recipe name, e.g. `--recipe risky-code`, or a child query selector such as `--recipe risky-code/raw-diagnostic-echo`; run `cdidx search --list-recipes` to list available recipes.", + ["--include-query"] = "pass a child query name from the selected recipe, e.g. `--include-query raw-diagnostic-echo`; repeat or comma-separate values.", + ["--exclude-query"] = "pass a child query name to omit from the selected recipe, e.g. `--exclude-query cancellation-gap`; repeat or comma-separate values.", ["--open-issues"] = "pass an open-issues JSON file or GitHub source, e.g. `--open-issues open-issues.json` or `--open-issues github --repo owner/name`; only valid with `search --format issue-drafts`.", ["--repo"] = "pass a GitHub repository in owner/name form for `--open-issues github`, e.g. `--repo Widthdom/CodeIndex`.", ["--issue-title"] = "pass an issue title hint for ad hoc search issue-drafts, e.g. `--issue-title \"Thread.Yield audit\"`.", @@ -10410,6 +10585,8 @@ public sealed class QueryCommandOptions public List<string>? MapSections { get; init; } public bool DependencyCycles { get; init; } public string? RecipeName { get; init; } + public List<string> IncludeRecipeQueries { get; init; } = []; + public List<string> ExcludeRecipeQueries { get; init; } = []; public bool ListRecipes { get; init; } public string? OpenIssuesPath { get; init; } public string? OpenIssuesRepository { get; init; } diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 3aba3dbe37..34a3b35d54 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>|--query <query>|-- <query>", output); Assert.Contains("cdidx callers <query>|--query <query>|-- <query>", output); Assert.Contains("cdidx callees <query>|--query <query>|-- <query>", output); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes [--include-query <name>] [--exclude-query <name>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]", output); Assert.Contains("cdidx inspect <query>|--query <query>|-- <query> [--db <path>] [--json] [--pretty] [--compact] [--fields <csv>] [--body-only] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--max-line-width <n>] [--exact|--exact-name]", output); @@ -269,7 +269,7 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() { var output = CaptureFullUsageOutput(showBanner: false); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name>|--list-recipes [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes [--include-query <name>] [--exclude-query <name>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx symbols [query|--query <query>|-- <query>] [--name <name>] [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx files [query|--query <query>|-- <query>] [--db <path>] [--json[=ndjson|array]] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--since <datetime>] [--bytes]", output); Assert.Contains("cdidx hotspots [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count]", output); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 58c11befa3..bd113d4e9f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -386,6 +386,110 @@ public void Run(Exception ex, CancellationToken token) } } + [Fact] + public void RunSearch_RecipeChildSelectorRunsSingleQuery_Issue3519() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_child_selector"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + """ + using System.Text.Json; + + public sealed class App + { + public void Run(Exception ex) + { + JsonDocument.Parse("{}"); + Console.WriteLine(ex.Message); + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/raw-diagnostic-echo", "--db", dbPath, "--json", "--limit", "10"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + var query = Assert.Single(root.GetProperty("queries").EnumerateArray()); + var recipeQuery = Assert.Single(root.GetProperty("recipe").GetProperty("queries").EnumerateArray()); + + Assert.Equal(1, root.GetProperty("query_count").GetInt32()); + Assert.Equal("raw-diagnostic-echo", query.GetProperty("name").GetString()); + Assert.Equal("raw-diagnostic-echo", recipeQuery.GetProperty("name").GetString()); + Assert.Equal("ex.Message", query.GetProperty("query").GetString()); + Assert.Equal(1, query.GetProperty("count").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_RecipeIncludeExcludeQueriesFilterChildren_Issue3519() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_include_exclude"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + """ + using System.Text.Json; + + public sealed class App + { + public void Run(Exception ex) + { + JsonDocument.Parse("{}"); + Console.WriteLine(ex.Message); + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code", "--include-query", "raw-diagnostic-echo,unbounded-json-parse", "--exclude-query", "raw-diagnostic-echo", "--db", dbPath, "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + var query = Assert.Single(root.GetProperty("queries").EnumerateArray()); + + Assert.Equal(1, root.GetProperty("query_count").GetInt32()); + Assert.Equal("unbounded-json-parse", query.GetProperty("name").GetString()); + Assert.Equal("JsonDocument.Parse", query.GetProperty("query").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_RecipeUnknownChildSelectorReturnsUsage_Issue3519() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/missing-child", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("unknown recipe query 'missing-child'", stderr); + Assert.Contains("raw-diagnostic-echo", stderr); + } + [Theory] [InlineData("count")] [InlineData("compact")] @@ -471,7 +575,7 @@ public void Run() Assert.Contains("JsonDocument.Parse", body, StringComparison.Ordinal); Assert.Contains("False-positive guidance", body, StringComparison.Ordinal); Assert.Contains("## Replay command", body, StringComparison.Ordinal); - Assert.Contains("cdidx search --recipe risky-code --format issue-drafts --limit 5", body, StringComparison.Ordinal); + Assert.Contains("cdidx search --recipe risky-code/unbounded-json-parse --format issue-drafts --limit 5", body, StringComparison.Ordinal); Assert.Contains("--lang csharp --path src/app.cs --exclude-tests", body, StringComparison.Ordinal); Assert.Contains($"--open-issues {openIssuesPath}", body, StringComparison.Ordinal); Assert.DoesNotContain("public sealed class App", body, StringComparison.Ordinal); From 8221148c8f0b9b1af77a7831b583eb988293483d Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Fri, 12 Jun 2026 00:35:43 +0900 Subject: [PATCH 5/8] Add compact recipe search pagination (#3392) --- USER_GUIDE.md | 10 +- changelog.d/unreleased/3392.added.md | 22 +++ src/CodeIndex/Cli/CliFlagSchema.cs | 1 + src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/JsonOutputContracts.cs | 4 + src/CodeIndex/Cli/QueryCommandRunner.cs | 149 +++++++++++++++++- src/CodeIndex/Cli/SearchAuditRecipes.cs | 31 ++++ tests/CodeIndex.Tests/ConsoleUiTests.cs | 4 +- .../QueryCommandRunnerSearchTests.cs | 88 ++++++++++- 9 files changed, 300 insertions(+), 11 deletions(-) create mode 100644 changelog.d/unreleased/3392.added.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8fc0b1d5dd..42bd0f3775 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -869,6 +869,8 @@ cdidx search --list-recipes # show reusable audit re cdidx search --recipe risky-code --json # run a curated audit query set and return grouped JSON cdidx search --recipe risky-code/raw-diagnostic-echo --json # run one child query from a recipe cdidx search --recipe risky-code --include-query raw-diagnostic-echo --exclude-query cancellation-gap --json +cdidx search --recipe risky-code/raw-diagnostic-echo --format compact --limit 20 # summary-first compact JSON with next_cursor +cdidx search --recipe risky-code/raw-diagnostic-echo --format compact --cursor <next_cursor> cdidx search --recipe risky-code --format issue-drafts --open-issues open-issues.json # issue draft JSON with duplicate preflight cdidx search --recipe risky-code --format issue-drafts --open-issues github --repo Widthdom/CodeIndex # preflight against live open GitHub issues cdidx search "Thread.Yield" --format issue-drafts --issue-title "Thread.Yield audit" --issue-label audit # ad hoc issue draft JSON @@ -1240,8 +1242,9 @@ same source location. | `--exclude-visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | Exclude symbols with the requested visibility values. Accepts the same comma-separated values and alias expansion as `--visibility`. | | `--path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | Restrict results to glob-style path patterns. `*` and `?` are wildcards. Repeatable; multiple values are OR'd together | | `--query <query>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | Pass a query literal explicitly, useful when the query starts with `-`. Query commands except `find` also accept `-- <query>` as a one-token query escape while continuing to parse later options. | -| `--recipe <name>` | `search` | Run a reusable audit recipe such as `risky-code`. Use `recipe/query` form, such as `risky-code/raw-diagnostic-echo`, to run one child query directly. Normal search filters and snippet controls apply to every selected recipe query; `--limit` / `--top` is per child query. Text, `--json` / `--format json`, and `--format issue-drafts` are supported, and issue drafts include a replay command. | +| `--recipe <name>` | `search` | Run a reusable audit recipe such as `risky-code`. Use `recipe/query` form, such as `risky-code/raw-diagnostic-echo`, to run one child query directly. Normal search filters and snippet controls apply to every selected recipe query; `--limit` / `--top` is per child query. Text, `--json` / `--format json`, `--format compact`, and `--format issue-drafts` are supported, and issue drafts include a replay command. | | `--include-query <name>` / `--exclude-query <name>` | `search --recipe <name>` | Include or exclude child recipe queries by name. Repeatable and comma-separated; names are listed by `cdidx search --list-recipes`. | +| `--cursor <cursor>` | `search --recipe <name/query>` | Fetch the next page for one selected recipe child query. Use the `next_cursor` returned by recipe JSON or compact output. | | `--list-recipes` | `search` | List available search audit recipes with query text, recommended labels, exact-match mode, false-positive guidance, supported formats, filter support, and limit semantics. | | `--open-issues <path>` | `search --recipe <name> --format issue-drafts` | Preflight generated issue drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`. | | `--exclude-path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | Exclude glob-style path patterns. `*` and `?` are wildcards (repeatable) | @@ -3179,6 +3182,8 @@ cdidx search --list-recipes # 再利用可能な aud cdidx search --recipe risky-code --json # curated audit query set を実行し、grouped JSON を返す cdidx search --recipe risky-code/raw-diagnostic-echo --json # recipe 内の child query を1つだけ実行 cdidx search --recipe risky-code --include-query raw-diagnostic-echo --exclude-query cancellation-gap --json +cdidx search --recipe risky-code/raw-diagnostic-echo --format compact --limit 20 # summary-first compact JSON と next_cursor +cdidx search --recipe risky-code/raw-diagnostic-echo --format compact --cursor <next_cursor> cdidx search --recipe risky-code --format issue-drafts --open-issues open-issues.json # duplicate preflight 付き issue draft JSON cdidx search --recipe risky-code --format issue-drafts --open-issues github --repo Widthdom/CodeIndex # GitHub の live open issue と照合 cdidx search "Thread.Yield" --format issue-drafts --issue-title "Thread.Yield audit" --issue-label audit # ad hoc issue draft JSON @@ -3543,8 +3548,9 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--exclude-visibility <v[,v]>` | `definition`, `symbols`, `unused`, `hotspots` | 指定した可視性のシンボルを除外する。値と alias 展開は `--visibility` と同じ | | `--path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | glob 形式のパスパターンで結果を絞る。`*` と `?` がワイルドカード。繰り返し指定可(複数値は OR で結合) | | `--query <query>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | クエリを明示的なリテラルとして渡す。クエリが `-` で始まる場合に有用。`find` 以外のクエリ系コマンドでは `-- <query>` も1トークンのクエリエスケープとして受け付け、その後のオプション解析を続ける。 | -| `--recipe <name>` | `search` | `risky-code` などの再利用可能な audit recipe を実行する。`risky-code/raw-diagnostic-echo` のような `recipe/query` 形式で child query を1つだけ直接実行できる。通常の search filter と snippet control は選択された各 query に適用され、`--limit` / `--top` は child query ごとの上限になる。text、`--json` / `--format json`、`--format issue-drafts` に対応し、issue draft には再実行コマンドを含める。 | +| `--recipe <name>` | `search` | `risky-code` などの再利用可能な audit recipe を実行する。`risky-code/raw-diagnostic-echo` のような `recipe/query` 形式で child query を1つだけ直接実行できる。通常の search filter と snippet control は選択された各 query に適用され、`--limit` / `--top` は child query ごとの上限になる。text、`--json` / `--format json`、`--format compact`、`--format issue-drafts` に対応し、issue draft には再実行コマンドを含める。 | | `--include-query <name>` / `--exclude-query <name>` | `search --recipe <name>` | recipe 内の child query を名前で含める、または除外する。繰り返し指定とカンマ区切りに対応し、名前は `cdidx search --list-recipes` で確認できる。 | +| `--cursor <cursor>` | `search --recipe <name/query>` | 選択した recipe child query の次ページを取得する。recipe JSON または compact output が返す `next_cursor` を指定する。 | | `--list-recipes` | `search` | 利用可能な search audit recipe を query text、推奨 label、exact-match mode、false-positive guidance、対応 format、filter support、limit semantics 付きで一覧表示する。 | | `--open-issues <path>` | `search --recipe <name> --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` のような open issue JSON file と照合し、生成した issue draft を事前重複確認する。 | | `--exclude-path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | glob 形式のパスパターンを除外する。`*` と `?` がワイルドカード。繰り返し指定可 | diff --git a/changelog.d/unreleased/3392.added.md b/changelog.d/unreleased/3392.added.md new file mode 100644 index 0000000000..99b713ea78 --- /dev/null +++ b/changelog.d/unreleased/3392.added.md @@ -0,0 +1,22 @@ +--- +category: added +issues: + - 3392 +affected: + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - USER_GUIDE.md +--- + +## English + +- **Recipe search now supports compact JSON and pagination cursors (#3392)** — `cdidx search --recipe recipe/query --format compact` emits summary-first JSON with top files, snippet-free locations, and `next_cursor` values for paging one child query. + +## 日本語 + +- **recipe search が compact JSON と pagination cursor に対応しました (#3392)** — `cdidx search --recipe recipe/query --format compact` は top files、snippet なしの位置情報、child query 1件をページングするための `next_cursor` を含む summary-first JSON を出力します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 69f576c728..3ee7ef27a0 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -283,6 +283,7 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--repo", ValuePlaceholder = "<owner/name>", Description = "Search issue-drafts: GitHub repository for --open-issues github", Commands = Set("search") }, new() { Name = "--issue-title", ValuePlaceholder = "<title>", Description = "Search issue-drafts: override the title for an ad hoc search draft", Commands = Set("search") }, new() { Name = "--issue-label", ValuePlaceholder = "<label>", Description = "Search issue-drafts: add a label hint; repeat or comma-separate values", Commands = Set("search") }, + new() { Name = "--cursor", ValuePlaceholder = "<cursor>", Description = "Search recipe: pagination cursor returned as next_cursor", Commands = Set("search") }, new() { Name = "--status", ValuePlaceholder = "<status>", Description = "Suggestions: filter by suggestion status", Commands = Set("suggestions") }, new() { Name = "--category", ValuePlaceholder = "<category>", Description = "Suggestions: filter by category", Commands = Set("suggestions") }, new() { Name = "--agent", ValuePlaceholder = "<agent>", Description = "Suggestions: filter by agent", Commands = Set("suggestions") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index ee5076de74..1b8c9d18e5 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 <projectPath> --commits <commit-ref> [commit-ref ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-changed-between", "cdidx index <projectPath> --changed-between <old-ref> <new-ref> [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), ("index-files", "cdidx index <projectPath> --files <path> [path ...] [--db <path>] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format <auto|seconds|hms>] [--max-file-bytes <bytes>] [--include-symbol-kind <kind>[,<kind>]] [--exclude-symbol-kind <kind>[,<kind>]]"), - ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes [--include-query <name>] [--exclude-query <name>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), + ("search", "cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]"), ("definition", "cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since <datetime>]"), ("goto", "cdidx goto <query>|--query <query>|-- <query> [--db <path>] [--json] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--all]"), ("references", "cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]"), diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 5280f2462b..687721dbd3 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -474,6 +474,10 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(SearchRecipeListJsonResult))] [JsonSerializable(typeof(SearchRecipeFilterSupportJsonResult))] [JsonSerializable(typeof(SearchRecipeLimitSemanticsJsonResult))] +[JsonSerializable(typeof(SearchRecipeCompactRunJsonResult))] +[JsonSerializable(typeof(SearchRecipeCompactQueryResultJsonResult))] +[JsonSerializable(typeof(SearchRecipeCompactResultJsonResult))] +[JsonSerializable(typeof(SearchRecipeTopFileJsonResult))] [JsonSerializable(typeof(SearchRecipeQueryListItemJsonResult))] [JsonSerializable(typeof(SearchRecipeQueryResultJsonResult))] [JsonSerializable(typeof(SearchRecipeRunJsonResult))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index a3780c2ed8..0c512f35b7 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -86,7 +86,7 @@ public static class QueryCommandRunner private const string HotspotsGroupedByStatement = "statement"; private const string JsonOutputFormatNdjson = "ndjson"; private const string JsonOutputFormatArray = "array"; - private static readonly List<string> SearchRecipeSupportedFormats = ["text", "json", OutputFormatIssueDrafts]; + private static readonly List<string> SearchRecipeSupportedFormats = ["text", "json", "compact", OutputFormatIssueDrafts]; private static readonly SearchRecipeFilterSupportJsonResult SearchRecipeFilterSupport = new( Lang: true, Path: true, @@ -159,6 +159,7 @@ public static class QueryCommandRunner "--repo", "--issue-title", "--issue-label", + "--cursor", "--group-by", "--focus-line", "--focus-column", @@ -550,6 +551,14 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Use `--recipe risky-code --include-query raw-diagnostic-echo` to run a child query subset."); return CommandExitCodes.UsageError; } + if (options.SearchCursor.HasValue && options.RecipeName == null) + { + WriteUsageError( + "--cursor can only be used with --recipe.", + GetUsageLineOrThrow("search"), + "Use `--recipe risky-code/raw-diagnostic-echo --format compact --cursor <next_cursor>` to fetch the next page for one child query."); + return CommandExitCodes.UsageError; + } if ((options.IssueTitle != null || options.IssueLabels.Count > 0) && options.OutputFormat != OutputFormatIssueDrafts) { WriteUsageError( @@ -582,6 +591,14 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Use plain `--json` or omit --json when exporting issue drafts."); return CommandExitCodes.UsageError; } + if (options.SearchCursor.HasValue && options.OutputFormat == OutputFormatIssueDrafts) + { + WriteUsageError( + "--cursor cannot be combined with --format issue-drafts.", + GetUsageLineOrThrow("search"), + "Use --cursor with recipe JSON or compact output, then export issue drafts after choosing the desired query page."); + return CommandExitCodes.UsageError; + } if (options.ListRecipes) { if (options.Query != null || options.RecipeName != null || options.ExtraNames.Count > 0) @@ -637,12 +654,12 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Remove --prefix, or run the individual query from the recipe list yourself."); return CommandExitCodes.UsageError; } - if (options.OutputFormat is not OutputFormatText and not OutputFormatJson and not OutputFormatIssueDrafts) + if (options.OutputFormat is not OutputFormatText and not OutputFormatJson and not OutputFormatCompact and not OutputFormatIssueDrafts) { WriteUsageError( - "--format count/compact/csv/tsv/lsp/qf/sarif is not supported with --recipe.", + "--format count/csv/tsv/lsp/qf/sarif is not supported with --recipe.", GetUsageLineOrThrow("search"), - "Use `--json` for grouped recipe results or `--format issue-drafts` for draft exports."); + "Use `--json` for grouped recipe results, `--format compact` for summary-first compact JSON, or `--format issue-drafts` for draft exports."); return CommandExitCodes.UsageError; } if (options.JsonOutputFormat == JsonOutputFormatArray) @@ -1001,9 +1018,31 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp return CommandExitCodes.UsageError; } var recipe = selection.Recipe; + if (options.SearchCursor.HasValue && selection.Queries.Count != 1) + { + WriteUsageError( + "--cursor requires exactly one selected recipe query.", + GetUsageLineOrThrow("search"), + "Use `--recipe recipe/query` or a single `--include-query` value with --cursor."); + return CommandExitCodes.UsageError; + } return WithDb(options, jsonOptions, reader => { + if (options.OutputFormat == OutputFormatCompact) + { + var compactQueryResults = CollectSearchRecipeCompactQueryResults(reader, selection.Queries, options, userExact, out var compactTotal); + Console.WriteLine(JsonSerializer.Serialize( + new SearchRecipeCompactRunJsonResult( + JsonOutputContract.ApiVersion, + ToSearchRecipeListItem(recipe, selection.Queries), + selection.Queries.Count, + compactTotal, + compactQueryResults), + CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeCompactRunJsonResult)); + return CommandExitCodes.Success; + } + var queryResults = CollectSearchRecipeQueryResults(reader, selection.Queries, options, userExact, out var total); if (options.Json) @@ -1124,6 +1163,7 @@ private static int RunSearchIssueDrafts(QueryCommandOptions options, JsonSeriali "Review the evidence paths and surrounding code before filing.", exact, rows.Count, + null, rows.Select(row => row.Compact).ToList()); var drafts = rows.Count == 0 ? [] @@ -1171,6 +1211,7 @@ private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryR exact, false, !options.NoVisibilityRank, + cursor: options.SearchCursor, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query); @@ -1183,12 +1224,73 @@ private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryR recipeQuery.FalsePositiveGuidance, exact, rows.Count, + rows.Count > 0 ? FormatSearchCursor(rows[^1].Result) : null, rows.Select(row => row.Compact).ToList())); } return queryResults; } + private static List<SearchRecipeCompactQueryResultJsonResult> CollectSearchRecipeCompactQueryResults( + DbReader reader, + IReadOnlyList<SearchAuditRecipeQuery> recipeQueries, + QueryCommandOptions options, + bool userExact, + out int total) + { + var queryResults = new List<SearchRecipeCompactQueryResultJsonResult>(); + total = 0; + foreach (var recipeQuery in recipeQueries) + { + var exact = userExact || recipeQuery.ExactSubstring; + var results = reader.Search( + recipeQuery.Query, + options.Limit, + options.Lang, + false, + options.PathPatterns, + options.ExcludePaths, + options.ExcludeTests, + !options.NoDedup, + options.Since, + exact, + false, + !options.NoVisibilityRank, + cursor: options.SearchCursor, + guardFilters: options.GuardFilters, + guardWindow: options.GuardWindow); + var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query); + total += rows.Count; + queryResults.Add(new SearchRecipeCompactQueryResultJsonResult( + recipeQuery.Name, + recipeQuery.Query, + recipeQuery.Description, + rows.Count, + BuildSearchRecipeTopFiles(rows), + rows.Count > 0 ? FormatSearchCursor(rows[^1].Result) : null, + rows.Select(row => new SearchRecipeCompactResultJsonResult( + row.Result.Path, + row.Result.Lang, + row.Result.Visibility, + row.Result.StartLine, + row.Result.EndLine, + row.Compact.MatchLines, + row.Compact.EnclosingSymbolName, + row.Compact.EnclosingSymbolKind)).ToList())); + } + + return queryResults; + } + + private static List<SearchRecipeTopFileJsonResult> BuildSearchRecipeTopFiles(IReadOnlyList<SearchDisplayRow> rows) + => rows + .GroupBy(row => row.Result.Path, StringComparer.Ordinal) + .Select(group => new SearchRecipeTopFileJsonResult(group.Key, group.Count())) + .OrderByDescending(file => file.Count) + .ThenBy(file => file.Path, StringComparer.Ordinal) + .Take(10) + .ToList(); + private static SearchIssueDraftJsonResult ToSearchIssueDraft( SearchAuditRecipe recipe, SearchRecipeQueryResultJsonResult queryResult, @@ -1402,6 +1504,27 @@ private static string BuildSearchGuardReplayOptionName(SearchGuardFilter guardFi private static string FormatSearchSnippetFocusMode(SearchSnippetFocusMode mode) => mode.ToString().ToLowerInvariant(); + private static string FormatSearchCursor(SearchResult result) + => string.Create(CultureInfo.InvariantCulture, $"{result.Score:R}:{result.ChunkId}:{result.NextOffset}"); + + private static bool TryParseSearchCursor(string value, out SearchCursor cursor) + { + cursor = default; + var parts = value.Split(':'); + if (parts.Length != 3) + return false; + if (!double.TryParse(parts[0], NumberStyles.Float, CultureInfo.InvariantCulture, out var score) || + !long.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var chunkId) || + !int.TryParse(parts[2], NumberStyles.Integer, CultureInfo.InvariantCulture, out var offset) || + offset < 0) + { + return false; + } + + cursor = new SearchCursor(score, chunkId, offset); + return true; + } + private static string QuoteReplayShellArg(string arg) { if (arg.Length > 0 && arg.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' or '/' or ':' or '=')) @@ -6591,6 +6714,7 @@ public static QueryCommandOptions ParseArgs( string? openIssuesRepository = null; string? issueTitle = null; var issueLabels = new List<string>(); + SearchCursor? searchCursor = null; bool languagesIndexedOnly = false; var languageCapabilities = new List<string>(); @@ -6962,6 +7086,20 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(issueLabelError!); break; + case "--cursor": + if (TryReadStringOptionValue(args, ref i, "--cursor", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var cursorValue, out var cursorError)) + { + WarnIfDuplicateSingleValueOption("--cursor", cursorValue!); + if (TryParseSearchCursor(cursorValue!, out var parsedCursor)) + searchCursor = parsedCursor; + else + AddParseError("Error: --cursor must be a search pagination cursor returned as `next_cursor` by a previous recipe search response."); + } + else + { + AddParseError(cursorError!); + } + 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!); @@ -7599,6 +7737,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) OpenIssuesRepository = openIssuesRepository, IssueTitle = issueTitle, IssueLabels = issueLabels, + SearchCursor = searchCursor, LanguagesIndexedOnly = languagesIndexedOnly, LanguageCapabilities = languageCapabilities, ParseError = parseErrors == null ? null : string.Join(Environment.NewLine, parseErrors), @@ -10165,6 +10304,7 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont ["--repo"] = "pass a GitHub repository in owner/name form for `--open-issues github`, e.g. `--repo Widthdom/CodeIndex`.", ["--issue-title"] = "pass an issue title hint for ad hoc search issue-drafts, e.g. `--issue-title \"Thread.Yield audit\"`.", ["--issue-label"] = "pass an issue label hint for search issue-drafts, e.g. `--issue-label audit`; repeat or comma-separate values.", + ["--cursor"] = "pass the `next_cursor` returned by a prior recipe search response; use it with one selected recipe query.", ["--kind"] = "pass a kind identifier, e.g. `--kind function`. definition/symbols/hotspots/unused take a symbol kind; references/callers/callees take a reference kind such as `call`, `instantiate`, or `subscribe`. Run the command's `--help` for the kind list.", ["--bucket"] = "pass one unused-symbol bucket: likely_unused_private, maybe_unused_nonpublic, public_or_exported_no_refs, or reflection_or_config_suspect.", ["--min-confidence"] = "pass one unused-symbol confidence threshold: medium or low.", @@ -10592,6 +10732,7 @@ public sealed class QueryCommandOptions public string? OpenIssuesRepository { get; init; } public string? IssueTitle { get; init; } public List<string> IssueLabels { get; init; } = []; + public SearchCursor? SearchCursor { get; init; } public bool LanguagesIndexedOnly { get; init; } public List<string> LanguageCapabilities { get; init; } = []; public string? ParseError { get; init; } diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index d82d2a9da8..8dc7908c69 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -127,8 +127,39 @@ internal sealed record SearchRecipeQueryResultJsonResult( [property: JsonPropertyName("false_positive_guidance")] string FalsePositiveGuidance, [property: JsonPropertyName("exact_substring")] bool ExactSubstring, [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("next_cursor")] string? NextCursor, [property: JsonPropertyName("results")] List<CompactSearchResult> Results); +internal sealed record SearchRecipeCompactRunJsonResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("recipe")] SearchRecipeListItemJsonResult Recipe, + [property: JsonPropertyName("query_count")] int QueryCount, + [property: JsonPropertyName("result_count")] int ResultCount, + [property: JsonPropertyName("queries")] List<SearchRecipeCompactQueryResultJsonResult> Queries); + +internal sealed record SearchRecipeCompactQueryResultJsonResult( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("query")] string Query, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("top_files")] List<SearchRecipeTopFileJsonResult> TopFiles, + [property: JsonPropertyName("next_cursor")] string? NextCursor, + [property: JsonPropertyName("results")] List<SearchRecipeCompactResultJsonResult> Results); + +internal sealed record SearchRecipeTopFileJsonResult( + [property: JsonPropertyName("path")] string Path, + [property: JsonPropertyName("count")] int Count); + +internal sealed record SearchRecipeCompactResultJsonResult( + [property: JsonPropertyName("path")] string Path, + [property: JsonPropertyName("lang")] string? Lang, + [property: JsonPropertyName("visibility")] string? Visibility, + [property: JsonPropertyName("chunk_start_line")] int ChunkStartLine, + [property: JsonPropertyName("chunk_end_line")] int ChunkEndLine, + [property: JsonPropertyName("match_lines")] List<int> MatchLines, + [property: JsonPropertyName("enclosing_symbol_name")] string? EnclosingSymbolName, + [property: JsonPropertyName("enclosing_symbol_kind")] string? EnclosingSymbolKind); + internal sealed record SearchIssueDraftExportJsonResult( [property: JsonPropertyName("api_version")] string ApiVersion, [property: JsonPropertyName("recipe")] SearchRecipeListItemJsonResult? Recipe, diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 34a3b35d54..3700f5776f 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>|--query <query>|-- <query>", output); Assert.Contains("cdidx callers <query>|--query <query>|-- <query>", output); Assert.Contains("cdidx callees <query>|--query <query>|-- <query>", output); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes [--include-query <name>] [--exclude-query <name>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx definition <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx references <query>|--query <query>|-- <query> [--db <path>] [--json] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--snippet-lines <n>] [--max-line-width <n>] [--exact|--exact-name] [--count]", output); Assert.Contains("cdidx inspect <query>|--query <query>|-- <query> [--db <path>] [--json] [--pretty] [--compact] [--fields <csv>] [--body-only] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--max-line-width <n>] [--exact|--exact-name]", output); @@ -269,7 +269,7 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() { var output = CaptureFullUsageOutput(showBanner: false); - Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes [--include-query <name>] [--exclude-query <name>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); + Assert.Contains("cdidx search <query>|--query <query>|-- <query>|--recipe <name|name/query>|--list-recipes [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--db <path>] [--json[=ndjson|array]] [--pretty] [--format <text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>] [--issue-title <title>] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since <datetime>] [--no-dedup] [--no-visibility-rank] [--require-before <query>] [--require-after <query>] [--reject-before <query>] [--reject-after <query>] [--guard-window <n>]", output); Assert.Contains("cdidx symbols [query|--query <query>|-- <query>] [--name <name>] [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exact|--exact-name] [--count] [--since <datetime>]", output); Assert.Contains("cdidx files [query|--query <query>|-- <query>] [--db <path>] [--json[=ndjson|array]] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--since <datetime>] [--bytes]", output); Assert.Contains("cdidx hotspots [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count]", output); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index bd113d4e9f..a8ed4e9db2 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -490,9 +490,93 @@ public void RunSearch_RecipeUnknownChildSelectorReturnsUsage_Issue3519() Assert.Contains("raw-diagnostic-echo", stderr); } + [Fact] + public void RunSearch_RecipeCompactJsonEmitsSummaryAndCursor_Issue3392() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_compact_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/a.cs", + "csharp", + """ + public sealed class A + { + public void Run(Exception ex) + { + Console.WriteLine(ex.Message); + } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/b.cs", + "csharp", + """ + public sealed class B + { + public void Run(Exception ex) + { + Console.WriteLine(ex.Message); + } + } + """); + + var (firstExitCode, firstStdout, firstStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/raw-diagnostic-echo", "--db", dbPath, "--format", "compact", "--limit", "1"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, firstExitCode); + Assert.Equal(string.Empty, firstStderr); + using var firstDocument = ParseJsonOutput(firstStdout); + var firstRoot = firstDocument.RootElement; + var firstQuery = Assert.Single(firstRoot.GetProperty("queries").EnumerateArray()); + var firstResult = Assert.Single(firstQuery.GetProperty("results").EnumerateArray()); + var firstPath = firstResult.GetProperty("path").GetString(); + var nextCursor = firstQuery.GetProperty("next_cursor").GetString(); + + Assert.Equal(1, firstRoot.GetProperty("query_count").GetInt32()); + Assert.Equal(1, firstRoot.GetProperty("result_count").GetInt32()); + Assert.Equal("raw-diagnostic-echo", firstQuery.GetProperty("name").GetString()); + Assert.Equal(1, firstQuery.GetProperty("top_files")[0].GetProperty("count").GetInt32()); + Assert.True(firstResult.TryGetProperty("match_lines", out _)); + Assert.False(firstResult.TryGetProperty("snippet", out _)); + Assert.False(string.IsNullOrWhiteSpace(nextCursor)); + + var (secondExitCode, secondStdout, secondStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/raw-diagnostic-echo", "--db", dbPath, "--format", "compact", "--limit", "1", "--cursor", nextCursor!], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, secondExitCode); + Assert.Equal(string.Empty, secondStderr); + using var secondDocument = ParseJsonOutput(secondStdout); + var secondResult = Assert.Single(Assert.Single(secondDocument.RootElement.GetProperty("queries").EnumerateArray()).GetProperty("results").EnumerateArray()); + + Assert.NotEqual(firstPath, secondResult.GetProperty("path").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_RecipeCursorRequiresSingleChildQuery_Issue3392() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code", "--format", "compact", "--cursor", "0:1:1"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--cursor requires exactly one selected recipe query", stderr); + } + [Theory] [InlineData("count")] - [InlineData("compact")] + [InlineData("csv")] public void RunSearch_RecipeRejectsUnsupportedFormattedOutputs_Issue3144(string format) { var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( @@ -501,7 +585,7 @@ public void RunSearch_RecipeRejectsUnsupportedFormattedOutputs_Issue3144(string Assert.Equal(CommandExitCodes.UsageError, exitCode); Assert.Equal(string.Empty, stdout); - Assert.Contains("--format count/compact/csv/tsv/lsp/qf/sarif is not supported with --recipe", stderr); + Assert.Contains("--format count/csv/tsv/lsp/qf/sarif is not supported with --recipe", stderr); } [Fact] From 84f818bca9793559c729e0dc54ed7228ab0ce47f Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Fri, 12 Jun 2026 01:07:35 +0900 Subject: [PATCH 6/8] Support GitHub preflight for suggestions export (#3449) --- USER_GUIDE.md | 4 ++-- changelog.d/unreleased/3449.added.md | 6 ++++-- src/CodeIndex/Cli/CliFlagSchema.cs | 2 +- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/SuggestionsCommandRunner.cs | 17 +++++++++++++-- tests/CodeIndex.Tests/ProgramCliTests.cs | 21 +++++++++++++++++++ 6 files changed, 44 insertions(+), 8 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 42bd0f3775..32ae8d5af7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1232,7 +1232,7 @@ same source location. | `--limit <n>` | `suggestions list`, `suggestions export` | Cap emitted suggestion records after filters and newest-first ordering. | | `--offset <n>` | `suggestions list`, `suggestions export` | Skip filtered suggestion records after newest-first ordering before emitting results. | | `--format <json\|markdown\|issue-drafts>` | `suggestions export` | Choose export format. JSON is the default, markdown is intended for human triage, and issue-drafts emits issue-ready draft objects. | -| `--open-issues <path\|github\|github:owner/name>` / `--repo <owner/name>` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | Preflight drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`; `search` can also fetch live open GitHub issues with `--open-issues github --repo owner/name` or `--open-issues github:owner/name`. Inputs are capped at 8 MiB, 32 JSON nesting levels, 1000 issue entries, 32 labels per issue, and bounded title/URL/label strings. | +| `--open-issues <path\|github\|github:owner/name>` / `--repo <owner/name>` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | Preflight drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`; both commands can also fetch live open GitHub issues with `--open-issues github --repo owner/name` or `--open-issues github:owner/name`. Inputs are capped at 8 MiB, 32 JSON nesting levels, 1000 issue entries, 32 labels per issue, and bounded title/URL/label strings. | | `--issue-title <title>` / `--issue-label <label>` | `search --format issue-drafts` | Set the title for an ad hoc search draft and add label hints. `--issue-label` can be repeated or comma-separated. | | `--check` | `status` | Verify that `.cdidx/codeindex.db` exactly matches the current indexable workspace by comparing DB file paths/checksums against a fresh filesystem scan. Matching indexes exit `0`; stale indexes exit `5`. | | `--dry-run` | `index` | Scan files and report what would change without writing to the database | @@ -3539,7 +3539,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--limit <n>` | `suggestions list`, `suggestions export` | filter と新しい順の並び替え後に出力する提案レコード数を制限します。 | | `--offset <n>` | `suggestions list`, `suggestions export` | filter と新しい順の並び替え後、出力前に指定件数の提案レコードをスキップします。 | | `--format <json\|markdown\|issue-drafts>` | `suggestions export` | エクスポート形式を選びます。既定は JSON、markdown は人間の triage 共有向け、issue-drafts は Issue 作成用の draft object を出力します。 | -| `--open-issues <path\|github\|github:owner/name>` / `--repo <owner/name>` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` などの open issue JSON と照合して draft を事前重複確認します。`search` では `--open-issues github --repo owner/name` または `--open-issues github:owner/name` で GitHub の live open issue も取得できます。入力は 8 MiB、JSON ネスト 32 段、issue entry 1000 件、issue ごとの label 32 件、title / URL / label 文字列長の上限に制限されます。 | +| `--open-issues <path\|github\|github:owner/name>` / `--repo <owner/name>` | `search --format issue-drafts`, `suggestions export --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` などの open issue JSON と照合して draft を事前重複確認します。どちらのコマンドも `--open-issues github --repo owner/name` または `--open-issues github:owner/name` で GitHub の live open issue も取得できます。入力は 8 MiB、JSON ネスト 32 段、issue entry 1000 件、issue ごとの label 32 件、title / URL / label 文字列長の上限に制限されます。 | | `--issue-title <title>` / `--issue-label <label>` | `search --format issue-drafts` | ad hoc search draft の title を指定し、label hint を追加します。`--issue-label` は繰り返し指定またはカンマ区切りに対応します。 | | `--check` | `status` | DB のファイル path/checksum と現在の index 対象 workspace を比較し、`.cdidx/codeindex.db` が完全一致するか確認。完全一致なら終了コード `0`、stale なら `5` | | `--dry-run` | `index` | DB に書き込まず、どの変更が発生するかだけを走査して報告 | diff --git a/changelog.d/unreleased/3449.added.md b/changelog.d/unreleased/3449.added.md index 588bd936bb..1b3742e034 100644 --- a/changelog.d/unreleased/3449.added.md +++ b/changelog.d/unreleased/3449.added.md @@ -5,17 +5,19 @@ issues: affected: - src/CodeIndex/Cli/IssueDuplicatePreflight.cs - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/SuggestionsCommandRunner.cs - src/CodeIndex/Cli/ConsoleUi.cs - src/CodeIndex/Cli/CliFlagSchema.cs - tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs + - tests/CodeIndex.Tests/ProgramCliTests.cs - tests/CodeIndex.Tests/ConsoleUiTests.cs - USER_GUIDE.md --- ## English -- **Issue-draft duplicate preflight can fetch open GitHub issues directly (#3449)** — `cdidx search --format issue-drafts --open-issues github --repo owner/name` now preflights drafts against live open GitHub issues without requiring a local JSON export. +- **Issue-draft duplicate preflight can fetch open GitHub issues directly (#3449)** — `cdidx search --format issue-drafts --open-issues github --repo owner/name` and `cdidx suggestions export --format issue-drafts --open-issues github --repo owner/name` now preflight drafts against live open GitHub issues without requiring a local JSON export. ## 日本語 -- **issue-draft の重複事前確認で GitHub の open issue を直接取得できるようになりました (#3449)** — `cdidx search --format issue-drafts --open-issues github --repo owner/name` は、ローカル JSON export を用意せずに GitHub の live open issue と draft を照合できます。 +- **issue-draft の重複事前確認で GitHub の open issue を直接取得できるようになりました (#3449)** — `cdidx search --format issue-drafts --open-issues github --repo owner/name` と `cdidx suggestions export --format issue-drafts --open-issues github --repo owner/name` は、ローカル JSON export を用意せずに GitHub の live open issue と draft を照合できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 3ee7ef27a0..a8e4c367f4 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -280,7 +280,7 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--exclude-query", ValuePlaceholder = "<name>", Description = "Search recipe: exclude one child query; repeat or comma-separate values", Commands = Set("search") }, new() { Name = "--list-recipes", Description = "Search: list built-in audit recipes", Commands = Set("search") }, new() { Name = "--open-issues", ValuePlaceholder = "<path|github|github:owner/name>", Description = "Preflight issue drafts against open issue JSON or GitHub open issues", Commands = Set("search", "suggestions") }, - new() { Name = "--repo", ValuePlaceholder = "<owner/name>", Description = "Search issue-drafts: GitHub repository for --open-issues github", Commands = Set("search") }, + new() { Name = "--repo", ValuePlaceholder = "<owner/name>", Description = "Issue-drafts: GitHub repository for --open-issues github", Commands = Set("search", "suggestions") }, new() { Name = "--issue-title", ValuePlaceholder = "<title>", Description = "Search issue-drafts: override the title for an ad hoc search draft", Commands = Set("search") }, new() { Name = "--issue-label", ValuePlaceholder = "<label>", Description = "Search issue-drafts: add a label hint; repeat or comma-separate values", Commands = Set("search") }, new() { Name = "--cursor", ValuePlaceholder = "<cursor>", Description = "Search recipe: pagination cursor returned as next_cursor", Commands = Set("search") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 1b8c9d18e5..f9334d5c12 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -113,7 +113,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("deps", "cdidx deps [--db <path>] [--json] [--format <dot|graphml|json-graph|edgelist>] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--reverse] [--cycles]"), ("unused", "cdidx unused [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--bucket <bucket>] [--min-confidence <medium|low>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count]"), ("hotspots", "cdidx hotspots [--db <path>] [--json] [--verbose] [--limit <n>|--top <n>] [--kind <kind>] [--visibility <v[,v]>] [--exclude-visibility <v[,v]>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--count] [--group-by <symbol|file|statement>] [--group-by-name]"), - ("suggestions", "cdidx suggestions <list|show|export> [id] [--db <path>] [--json] [--status <all|submitted|unsubmitted>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--open-issues <path>]"), + ("suggestions", "cdidx suggestions <list|show|export> [id] [--db <path>] [--json] [--status <all|submitted|unsubmitted>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>]"), ("export", "cdidx export <archive> [--db <path>] [--json]"), ("export", "cdidx export ctags [--output <path>] [--db <path>]"), ("import", "cdidx import <archive> [--db <path>] [--prune-paths] [--json]"), diff --git a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs index 5f37bfc53d..3fdc15fbb9 100644 --- a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs +++ b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs @@ -9,7 +9,7 @@ namespace CodeIndex.Cli; internal static class SuggestionsCommandRunner { - private const string Usage = "Usage: cdidx suggestions <list|show|export> [id] [--db <path>] [--json] [--status <all|draft|submitted_pending_triage|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded|submitted|unsubmitted>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--open-issues <path>]"; + private const string Usage = "Usage: cdidx suggestions <list|show|export> [id] [--db <path>] [--json] [--status <all|draft|submitted_pending_triage|open_in_upstream|resolved_in_upstream|wont_fix|duplicate|superseded|submitted|unsubmitted>] [--language <lang>] [--category <category>] [--since <datetime>] [--agent <name>] [--limit <n>] [--offset <n>] [--format <json|markdown|issue-drafts>] [--open-issues <path|github|github:owner/name>] [--repo <owner/name>]"; internal const int MaxOpenIssuesJsonBytes = IssueDuplicatePreflight.MaxOpenIssuesJsonBytes; internal const int MaxOpenIssuesJsonDepth = IssueDuplicatePreflight.MaxOpenIssuesJsonDepth; internal const int MaxSuggestionExportTextFieldLength = 4096; @@ -34,6 +34,8 @@ public static int Run(string[] args, JsonSerializerOptions jsonOptions) } if (options.OpenIssuesPath != null && (verb != "export" || options.ExportFormat != "issue-drafts")) return WriteUsageError("--open-issues can only be used with `suggestions export --format issue-drafts`."); + if (options.OpenIssuesRepository != null && (verb != "export" || options.ExportFormat != "issue-drafts")) + return WriteUsageError("--repo can only be used with `suggestions export --format issue-drafts --open-issues github`."); if (verb == "show" && options.HasPagination) return WriteUsageError("--limit and --offset can only be used with `suggestions list` or `suggestions export`."); @@ -165,7 +167,7 @@ private static int RunExport(List<SuggestionRecord> records, Options options, Js private static int RunIssueDraftExport(List<SuggestionRecord> records, Options options, JsonSerializerOptions jsonOptions) { - if (!IssueDuplicatePreflight.TryLoad(options.OpenIssuesPath, out var preflight, out var error)) + if (!IssueDuplicatePreflight.TryLoad(options.OpenIssuesPath, options.OpenIssuesRepository, out var preflight, out var error)) return WriteUsageError(error!); var drafts = records.Select(record => ToIssueDraft(record, preflight)).ToList(); @@ -646,6 +648,14 @@ private static Options Parse(string[] args) } options.OpenIssuesPath = openIssuesPath; break; + case "--repo": + if (!TryReadValue(args, ref i, "--repo", out var repository, out var repositoryError)) + { + options.Error = repositoryError; + return options; + } + options.OpenIssuesRepository = repository; + break; default: if (arg.StartsWith("--db=", StringComparison.Ordinal)) options.DbPath = arg["--db=".Length..]; @@ -684,6 +694,8 @@ private static Options Parse(string[] args) options.ExportFormat = arg["--format=".Length..]; else if (arg.StartsWith("--open-issues=", StringComparison.Ordinal)) options.OpenIssuesPath = arg["--open-issues=".Length..]; + else if (arg.StartsWith("--repo=", StringComparison.Ordinal)) + options.OpenIssuesRepository = arg["--repo=".Length..]; else if (arg.StartsWith("--since=", StringComparison.Ordinal)) { var inlineSince = arg["--since=".Length..]; @@ -757,6 +769,7 @@ private sealed class Options public int Offset { get; set; } public bool OffsetSpecified { get; set; } public string? OpenIssuesPath { get; set; } + public string? OpenIssuesRepository { get; set; } public DateTimeOffset? Since { get; set; } public string? Error { get; set; } public bool HasPagination => Limit.HasValue || OffsetSpecified; diff --git a/tests/CodeIndex.Tests/ProgramCliTests.cs b/tests/CodeIndex.Tests/ProgramCliTests.cs index 711d88c452..34cd37757a 100644 --- a/tests/CodeIndex.Tests/ProgramCliTests.cs +++ b/tests/CodeIndex.Tests/ProgramCliTests.cs @@ -869,6 +869,27 @@ public void Suggestions_ExportIssueDraftsIncludesEvidenceAndDuplicatePreflight() Assert.Equal("title_exact", preflight.GetProperty("matches")[0].GetProperty("reason").GetString()); } + [Fact] + public void Suggestions_ExportIssueDraftsGitHubOpenIssuesRequiresRepository_Issue3449() + { + using var fixture = SuggestionFixture.Create(); + fixture.Add( + "output_format", + "csharp", + "Issue draft export should fetch duplicate preflight issues from GitHub", + submitted: false, + sampledTitle: "Fetch duplicate preflight issues from GitHub"); + + var (exitCode, stdout, stderr) = RunCliInSubprocess([ + "suggestions", "export", "--db", fixture.DbPath, "--format", "issue-drafts", "--open-issues", "github" + ]); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--open-issues github requires --repo", stderr); + Assert.DoesNotContain("could not read --open-issues file 'github'", stderr); + } + [Fact] public void Suggestions_ExportIssueDraftsRedactsSensitiveSampledTitle() { From acef3b255f510af5c0e28ae19120e60d623560eb Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Fri, 12 Jun 2026 01:21:42 +0900 Subject: [PATCH 7/8] Address GitHub preflight review findings (#3449) --- USER_GUIDE.md | 2 -- src/CodeIndex/Cli/IssueDuplicatePreflight.cs | 2 +- tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs | 3 +++ 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 32ae8d5af7..9c85be5c75 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1246,7 +1246,6 @@ same source location. | `--include-query <name>` / `--exclude-query <name>` | `search --recipe <name>` | Include or exclude child recipe queries by name. Repeatable and comma-separated; names are listed by `cdidx search --list-recipes`. | | `--cursor <cursor>` | `search --recipe <name/query>` | Fetch the next page for one selected recipe child query. Use the `next_cursor` returned by recipe JSON or compact output. | | `--list-recipes` | `search` | List available search audit recipes with query text, recommended labels, exact-match mode, false-positive guidance, supported formats, filter support, and limit semantics. | -| `--open-issues <path>` | `search --recipe <name> --format issue-drafts` | Preflight generated issue drafts against an open-issues JSON file such as `gh issue list --state open --json number,title,labels,url`. | | `--exclude-path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | Exclude glob-style path patterns. `*` and `?` are wildcards (repeatable) | | `--exclude-tests` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | Exclude likely test files and prefer production code | | `--include-generated` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots` | Include files detected as generated code; generated files are excluded from query results by default | @@ -3552,7 +3551,6 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--include-query <name>` / `--exclude-query <name>` | `search --recipe <name>` | recipe 内の child query を名前で含める、または除外する。繰り返し指定とカンマ区切りに対応し、名前は `cdidx search --list-recipes` で確認できる。 | | `--cursor <cursor>` | `search --recipe <name/query>` | 選択した recipe child query の次ページを取得する。recipe JSON または compact output が返す `next_cursor` を指定する。 | | `--list-recipes` | `search` | 利用可能な search audit recipe を query text、推奨 label、exact-match mode、false-positive guidance、対応 format、filter support、limit semantics 付きで一覧表示する。 | -| `--open-issues <path>` | `search --recipe <name> --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` のような open issue JSON file と照合し、生成した issue draft を事前重複確認する。 | | `--exclude-path <glob>` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | glob 形式のパスパターンを除外する。`*` と `?` がワイルドカード。繰り返し指定可 | | `--exclude-tests` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect` | テストらしいパスを除外し、本番コードを優先 | | `--include-generated` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `deps`, `impact`, `unused`, `hotspots` | 生成コードとして検出されたファイルを含める。生成ファイルは既定でクエリ結果から除外される | diff --git a/src/CodeIndex/Cli/IssueDuplicatePreflight.cs b/src/CodeIndex/Cli/IssueDuplicatePreflight.cs index 50d26287a7..89acc143cc 100644 --- a/src/CodeIndex/Cli/IssueDuplicatePreflight.cs +++ b/src/CodeIndex/Cli/IssueDuplicatePreflight.cs @@ -210,7 +210,7 @@ private static List<OpenIssue> ParseOpenIssues(JsonNode? root, bool skipPullRequ issues.Add(new OpenIssue( TryReadInt(item?["number"]), title, - TryReadString(item?["url"], MaxOpenIssueUrlLength) ?? TryReadString(item?["html_url"], MaxOpenIssueUrlLength), + TryReadString(item?["html_url"], MaxOpenIssueUrlLength) ?? TryReadString(item?["url"], MaxOpenIssueUrlLength), ReadLabels(item?["labels"]))); } diff --git a/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs b/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs index de3a718e26..493474b40b 100644 --- a/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs +++ b/tests/CodeIndex.Tests/IssueDuplicatePreflightTests.cs @@ -102,12 +102,14 @@ public void TryLoad_GitHubSourceFetchesOpenIssuesWithExplicitToken_Issue3449() "number": 3449, "title": "Issue-draft duplicate preflight should fetch open GitHub issues directly", "labels": [{"name": "enhancement"}], + "url": "https://api.github.example.test/repos/Widthdom/CodeIndex/issues/3449", "html_url": "https://github.example.test/Widthdom/CodeIndex/issues/3449" }, { "number": 1, "title": "Pull request entry should be ignored", "labels": [{"name": "enhancement"}], + "url": "https://api.github.example.test/repos/Widthdom/CodeIndex/issues/1", "html_url": "https://github.example.test/Widthdom/CodeIndex/pull/1", "pull_request": {} } @@ -129,6 +131,7 @@ public void TryLoad_GitHubSourceFetchesOpenIssuesWithExplicitToken_Issue3449() "Issue-draft duplicate preflight should fetch open GitHub issues directly", ["enhancement"])); Assert.Equal(3449, match.Number); + Assert.Equal("https://github.example.test/Widthdom/CodeIndex/issues/3449", match.Url); } [Fact] From 4f907b7726de67cc55ed533ba9ad993bcc9f3147 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Fri, 12 Jun 2026 09:04:05 +0900 Subject: [PATCH 8/8] Fix Windows replay command assertion (#3449) --- tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index a8ed4e9db2..de542b46a0 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -661,7 +661,7 @@ public void Run() Assert.Contains("## Replay command", body, StringComparison.Ordinal); Assert.Contains("cdidx search --recipe risky-code/unbounded-json-parse --format issue-drafts --limit 5", body, StringComparison.Ordinal); Assert.Contains("--lang csharp --path src/app.cs --exclude-tests", body, StringComparison.Ordinal); - Assert.Contains($"--open-issues {openIssuesPath}", body, StringComparison.Ordinal); + Assert.Contains($"--open-issues {QuoteReplayShellArgForAssertion(openIssuesPath)}", body, StringComparison.Ordinal); Assert.DoesNotContain("public sealed class App", body, StringComparison.Ordinal); Assert.Equal("unbounded-json-parse", draft.GetProperty("source").GetProperty("query_name").GetString()); Assert.Equal(1, duplicatePreflight.GetProperty("match_count").GetInt32()); @@ -4349,4 +4349,11 @@ public void RunSearch_ZeroResultsHonorsStaleAfterEnvironment() TestProjectHelper.DeleteDirectory(projectRoot); } } + + private static string QuoteReplayShellArgForAssertion(string arg) + { + if (arg.Length > 0 && arg.All(c => char.IsLetterOrDigit(c) || c is '_' or '-' or '.' or '/' or ':' or '=')) + return arg; + return "'" + arg.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; + } }