diff --git a/README.md b/README.md index a9968f12f8..7ba1922bd4 100644 --- a/README.md +++ b/README.md @@ -63,11 +63,19 @@ cdidx search "handleRequest" cdidx definition UserService cdidx search "Handle" --project MyApp cdidx search "File.ReadAllText" --exact-substring --reject-before "Length" --guard-window 8 +cdidx search --list-recipes +cdidx search --recipe risky-code --json +cdidx search --recipe risky-code --format issue-drafts --open-issues open-issues.json cdidx validate cdidx mcp cdidx lsp --db .cdidx/codeindex.db ``` +Recipe runs support text, `--json` / `--format json`, and +`--format issue-drafts`; `--list-recipes` supports text or JSON. Other search +export formats and `--json=array` are rejected for recipe modes because recipe +output is grouped by query or list metadata. + Custom language loops can stay out of tree: put extension aliases in `.cdidx-langmap.yaml`, put regex symbol patterns in `.cdidx/patterns/*.yaml`, and run `cdidx test-extractor --language --file --json` to test @@ -158,7 +166,7 @@ downgrading `cdidx`. | Area | What cdidx provides | |---|---| -| Search surfaces | CLI-first output for humans and machines; full-text, symbol, reference, caller/callee, dependency, map, inspect, and excerpt commands. `search`, `definition`, `references`, `callers`, `callees`, `find`, and `validate` support `--format count|compact|csv|tsv|lsp|qf|sarif` for token-budgeted agents, scripts, editors, and CI reports. `cdidx lsp --db .cdidx/codeindex.db` starts a read-only stdio Language Server Protocol shim for LSP-native editors. | +| Search surfaces | CLI-first output for humans and machines; full-text, reusable search audit recipes, symbol, reference, caller/callee, dependency, map, inspect, and excerpt commands. Search recipes can also emit issue-draft JSON with labels, evidence paths, and duplicate preflight. `search`, `definition`, `references`, `callers`, `callees`, `find`, and `validate` support `--format count|compact|csv|tsv|lsp|qf|sarif` for token-budgeted agents, scripts, editors, and CI reports. `cdidx lsp --db .cdidx/codeindex.db` starts a read-only stdio Language Server Protocol shim for LSP-native editors. | | Validation diagnostics | `validate --json` and MCP `validate` annotate `replacement_char` rows with `origin` (`source_literal` or `decode_replacement`) and `severity` so agents can separate intentional U+FFFD literals from likely encoding damage. | | Definition and impact diagnostics | `definition --json` includes C# `disambiguator` hints for overloads, partial types, and extension receivers when indexed metadata can distinguish them. `impact --json` and MCP `impact_analysis` include `impact_failure_chain` and `suggestion_type` for zero-result routing; `impact --strict` exits non-zero when resolution or graph preconditions are unmet. | | Ranking and filters | Public/exported symbol matches rank ahead of protected, internal, and private matches. Use `--no-visibility-rank` for legacy order, and `--visibility` / `--exclude-visibility` with `symbols`, `definition`, `unused`, and `hotspots`. Query defaults can be adjusted with `CDIDX_DEFAULT_LIMIT`, `CDIDX_DEFAULT_SNIPPET_LINES`, and `CDIDX_DEFAULT_MAX_LINE_WIDTH`; explicit CLI flags still win. | @@ -362,11 +370,19 @@ cdidx search "handleRequest" cdidx definition UserService cdidx search "Handle" --project MyApp cdidx search "File.ReadAllText" --exact-substring --reject-before "Length" --guard-window 8 +cdidx search --list-recipes +cdidx search --recipe risky-code --json +cdidx search --recipe risky-code --format issue-drafts --open-issues open-issues.json cdidx validate cdidx mcp cdidx lsp --db .cdidx/codeindex.db ``` +recipe run が対応する形式は text、`--json` / `--format json`、 +`--format issue-drafts` です。`--list-recipes` は text または JSON に対応します。 +その他の search export format と `--json=array` は、recipe output が query または +list metadata ごとに grouped されるため usage error で拒否します。 + カスタム言語の開発ループは out-of-tree で回せます。拡張子 alias は `.cdidx-langmap.yaml`、regex シンボルパターンは `.cdidx/patterns/*.yaml` に置き、 `cdidx test-extractor --language --file --json` で full index を作らずに @@ -446,7 +462,7 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し | 分野 | 内容 | |---|---| -| 検索面 | CLI-first の人間向け / 機械処理向け出力。全文検索、シンボル、参照、caller/callee、依存関係、map、inspect、excerpt コマンドを提供します。`search`、`definition`、`references`、`callers`、`callees`、`find`、`validate` は `--format count|compact|csv|tsv|lsp|qf|sarif` をサポートし、token-budgeted agent、script、editor、CI report でも使いやすい出力にできます。`cdidx lsp --db .cdidx/codeindex.db` は LSP-native editor 向けの read-only stdio Language Server Protocol shim を起動します。 | +| 検索面 | CLI-first の人間向け / 機械処理向け出力。全文検索、再利用可能な search audit recipe、シンボル、参照、caller/callee、依存関係、map、inspect、excerpt コマンドを提供します。Search recipe は label、evidence path、duplicate preflight 付きの issue-draft JSON も出力できます。`search`、`definition`、`references`、`callers`、`callees`、`find`、`validate` は `--format count|compact|csv|tsv|lsp|qf|sarif` をサポートし、token-budgeted agent、script、editor、CI report でも使いやすい出力にできます。`cdidx lsp --db .cdidx/codeindex.db` は LSP-native editor 向けの read-only stdio Language Server Protocol shim を起動します。 | | validation 診断 | `validate --json` と MCP `validate` は `replacement_char` 行に `origin` (`source_literal` / `decode_replacement`) と `severity` を付け、意図的な U+FFFD literal とエンコーディング破損の可能性を agent が分離できるようにします。 | | definition / impact 診断 | `definition --json` は C# overload、partial type、extension receiver を区別できる場合に `disambiguator` を返します。`impact --json` と MCP `impact_analysis` は 0 件時の経路判断用に `impact_failure_chain` と `suggestion_type` を返し、`impact --strict` は解決または graph の前提条件が満たされない場合に非 0 で終了します。 | | 順位と filter | public/exported なシンボル一致を protected、internal、private より優先します。従来順は `--no-visibility-rank`、可視性の include / exclude は `symbols`、`definition`、`unused`、`hotspots` の `--visibility` / `--exclude-visibility` で指定できます。query 既定値は `CDIDX_DEFAULT_LIMIT`、`CDIDX_DEFAULT_SNIPPET_LINES`、`CDIDX_DEFAULT_MAX_LINE_WIDTH` で調整でき、明示 CLI flag が常に優先されます。 | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 7aec5917fd..5faa796a4e 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -847,6 +847,9 @@ cdidx search "Run();" --exact-substring # case-sensitive exact s cdidx search "Foo.Bar" --lang csharp --exact-substring # Java/Kotlin/C# exact search/find canonicalizes escaped source identifiers cdidx search "File.ReadAllText" --exact-substring --reject-before "Length" --guard-window 8 # API calls missing a nearby preceding guard 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 --format issue-drafts --open-issues open-issues.json # issue draft JSON with duplicate preflight cdidx search "--open-reports" --path README.md --count # quoted literal that starts with -- cdidx search --query "--path" --path README.md # search for an option-looking literal ``` @@ -868,6 +871,24 @@ The MCP `search` tool exposes the same mode as camelCase arguments: `requireBefore`, `requireAfter`, `rejectBefore`, `rejectAfter`, and `guardWindow`. +Search audit recipes expand one named recipe into multiple curated search +queries. `--list-recipes` reports the available names, descriptions, +recommended labels, query text, exact-match mode, and false-positive guidance. +`--recipe ` applies normal search filters such as `--lang`, `--path`, +`--exclude-path`, `--exclude-tests`, `--limit`, and snippet controls to every +query in the recipe. With `--json`, recipe runs emit one aggregate JSON payload +grouped by recipe query instead of the usual newline-delimited search stream. +Recipe runs support text output, `--json` / `--format json`, and +`--format issue-drafts`; `--list-recipes` supports text or JSON. Other search +export formats and `--json=array` are rejected for recipe modes because recipe +output is grouped by query or list metadata. +For triage automation, `--format issue-drafts` emits draft issue objects with +titles, labels, evidence paths, Markdown bodies, and duplicate-preflight +metadata. `--open-issues ` accepts an open-issue JSON list such as +`gh issue list --state open --json number,title,labels,url`; when omitted, +the payload still includes `duplicate_preflight.checked: false`. Draft bodies +include evidence paths and recipe metadata but not source snippets. + ### Debugging queries Add `--verbose` to any query command (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `excerpt`, `map`, `inspect`, `outline`, `status`, `validate`, `deps`, `impact`, `unused`, or `hotspots`) to print query diagnostics to stderr without changing normal stdout: @@ -1184,6 +1205,9 @@ same source location. | `--exclude-visibility ` | `definition`, `symbols`, `unused`, `hotspots` | Exclude symbols with the requested visibility values. Accepts the same comma-separated values and alias expansion as `--visibility`. | | `--path ` | `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 ` | `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 `-- ` as a one-token query escape while continuing to parse later options. | +| `--recipe ` | `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. | +| `--open-issues ` | `search --recipe --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 ` | `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 | @@ -3061,6 +3085,9 @@ cdidx search "Run();" --exact-substring # 大文字小文字区 cdidx search "Foo.Bar" --lang csharp --exact-substring # Java/Kotlin/C# の exact 検索 / find は escaped source identifier を正規化する cdidx search "File.ReadAllText" --exact-substring --reject-before "Length" --guard-window 8 # 直前の guard がない API 呼び出し 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 --format issue-drafts --open-issues open-issues.json # duplicate preflight 付き issue draft JSON cdidx search "--open-reports" --path README.md --count # `--` で始まる引用済みリテラル cdidx search --query "--path" --path README.md # オプションに見えるリテラルを検索 ``` @@ -3079,6 +3106,24 @@ query text、`--lang`、`--path`、`--exclude-tests` で絞り込むか、MCP cu MCP `search` tool では同じ mode を camelCase 引数 `requireBefore`, `requireAfter`, `rejectBefore`, `rejectAfter`, `guardWindow` で指定できます。 +search audit recipe は、名前付き recipe を複数の curated search query に展開します。 +`--list-recipes` は利用可能な名前、説明、推奨 label、query text、exact-match mode、 +false-positive guidance を表示します。`--recipe ` は `--lang`、`--path`、 +`--exclude-path`、`--exclude-tests`、`--limit`、snippet control など通常の search filter +を recipe 内の各 query に適用します。`--json` 併用時、recipe run は通常の +newline-delimited search stream ではなく、recipe query ごとに grouped された 1 つの +aggregate JSON payload を出力します。 +recipe run が対応する形式は text output、`--json` / `--format json`、 +`--format issue-drafts` です。`--list-recipes` は text または JSON に対応します。 +その他の search export format と `--json=array` は、recipe output が query または +list metadata ごとに grouped されるため usage error で拒否します。 +triage automation では `--format issue-drafts` を使うと、title、label、evidence path、 +Markdown body、duplicate-preflight metadata を持つ issue draft object を出力します。 +`--open-issues ` は `gh issue list --state open --json number,title,labels,url` +のような open issue JSON list を受け取り、未指定の場合も payload には +`duplicate_preflight.checked: false` が含まれます。draft body は evidence path と +recipe metadata を含みますが、source snippet は含めません。 + ### クエリのデバッグ 任意の query command(`search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`find`、`excerpt`、`map`、`inspect`、`outline`、`status`、`validate`、`deps`、`impact`、`unused`、`hotspots`)に `--verbose` を付けると、通常の stdout を変えずに query 診断を stderr へ出力します: @@ -3391,6 +3436,9 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--exclude-visibility ` | `definition`, `symbols`, `unused`, `hotspots` | 指定した可視性のシンボルを除外する。値と alias 展開は `--visibility` と同じ | | `--path ` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | glob 形式のパスパターンで結果を絞る。`*` と `?` がワイルドカード。繰り返し指定可(複数値は OR で結合) | | `--query ` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | クエリを明示的なリテラルとして渡す。クエリが `-` で始まる場合に有用。`find` 以外のクエリ系コマンドでは `-- ` も1トークンのクエリエスケープとして受け付け、その後のオプション解析を続ける。 | +| `--recipe ` | `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 付きで一覧表示する。 | +| `--open-issues ` | `search --recipe --format issue-drafts` | `gh issue list --state open --json number,title,labels,url` のような open issue JSON file と照合し、生成した issue draft を事前重複確認する。 | | `--exclude-path ` | `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/changelog.d/unreleased/3144.added.md b/changelog.d/unreleased/3144.added.md new file mode 100644 index 0000000000..9cd941c23c --- /dev/null +++ b/changelog.d/unreleased/3144.added.md @@ -0,0 +1,22 @@ +--- +category: added +issues: + - 3144 +affected: + - README.md + - USER_GUIDE.md + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +--- + +## English + +- **Search can now run reusable audit recipes (#3144)** — `cdidx search --list-recipes` exposes built-in risky-code audit searches with descriptions, labels, and false-positive guidance, while `cdidx search --recipe risky-code --json` emits grouped results using the normal search filters. + +## 日本語 + +- **search が再利用可能な audit recipe を実行できるようになりました (#3144)** — `cdidx search --list-recipes` は built-in の risky-code audit search を説明、label、false-positive guidance 付きで表示し、`cdidx search --recipe risky-code --json` は通常の search filter を使って grouped result を出力します。 diff --git a/changelog.d/unreleased/3145.added.md b/changelog.d/unreleased/3145.added.md new file mode 100644 index 0000000000..c83940c48e --- /dev/null +++ b/changelog.d/unreleased/3145.added.md @@ -0,0 +1,25 @@ +--- +category: added +issues: + - 3145 +affected: + - README.md + - USER_GUIDE.md + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/IssueDuplicatePreflight.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/SuggestionsCommandRunner.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +--- + +## English + +- **Search audit recipes can now export issue drafts (#3145)** — `cdidx search --recipe risky-code --format issue-drafts` emits draft issue objects with titles, labels, evidence paths, source-free bodies, and optional duplicate preflight from `--open-issues`. + +## 日本語 + +- **search audit recipe が issue draft export に対応しました (#3145)** — `cdidx search --recipe risky-code --format issue-drafts` は title、label、evidence path、source snippet を含まない body、任意の `--open-issues` duplicate preflight を持つ issue draft object を出力します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index b25addc18b..64ca4a9c52 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -213,7 +213,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--workspace-db", ValuePlaceholder = "", Description = "Additional workspace member database path for dependency aggregation", Commands = Set(WorkspaceDbCommands) }, new() { Name = "--data-dir", ValuePlaceholder = "", Description = "Directory containing codeindex.db; overrides CDIDX_DATA_DIR/XDG/workspace defaults", Commands = Set(DataDirCommands) }, new() { Name = "--json", Description = "JSON output; search/files/validate also accept --json=array for a single JSON array", Commands = Set(JsonCommands) }, - new() { Name = "--format", ValuePlaceholder = "", Description = "Standard output format for token budgets, editor integrations, and CI", Commands = Set(FormatCommands) }, + new() { Name = "--format", ValuePlaceholder = "", Description = "Standard output format for token budgets, editor integrations, and CI; search recipes also accept issue-drafts", Commands = Set(FormatCommands) }, new() { Name = "--quiet", ShortName = "-q", Description = "Suppress informational stderr output; errors still print", Commands = Set(AllCommands.ToArray()) }, new() { Name = "--silent", Description = "Alias for --quiet", Commands = Set(AllCommands.ToArray()) }, new() { Name = "--profile", Description = "Emit SQL timing and EXPLAIN QUERY PLAN profile JSON after the normal result", Commands = Set(ProfileCommands) }, @@ -249,6 +249,9 @@ private static IReadOnlyList BuildAll() new() { Name = "--indexed-only", Description = "Languages: list only languages present in the current index", Commands = Set(LanguagesFilterCommands) }, new() { Name = "--capability", ValuePlaceholder = "", Description = "Languages: filter by language capability", Commands = Set(LanguagesFilterCommands) }, new() { Name = "--query", ValuePlaceholder = "", Description = "Literal query", Commands = Set(QueryCommands) }, + new() { Name = "--recipe", ValuePlaceholder = "", Description = "Search: run a built-in audit recipe query set", Commands = Set("search") }, + new() { Name = "--list-recipes", Description = "Search: list built-in audit recipes", Commands = Set("search") }, + new() { Name = "--open-issues", ValuePlaceholder = "", Description = "Search: preflight issue drafts against open issue JSON", Commands = Set("search") }, new() { Name = "--body", Description = "Include body", Commands = Set(BodyCommands) }, new() { Name = "--exact", Description = "Backward-compatible exact shorthand", Commands = Set(ExactCommands) }, new() { Name = "--regex", Description = "Use regular expression matching", Commands = Set("find") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 193ba4221d..922e60534a 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -78,7 +78,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("index-commits", "cdidx index --commits [commit-ref ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-changed-between", "cdidx index --changed-between [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-files", "cdidx index --files [path ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), - ("search", "cdidx search |--query |-- [--db ] [--json[=ndjson|array]] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]"), + ("search", "cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]"), ("definition", "cdidx definition |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since ]"), ("goto", "cdidx goto |--query |-- [--db ] [--json] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--all]"), ("references", "cdidx references |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), diff --git a/src/CodeIndex/Cli/IssueDuplicatePreflight.cs b/src/CodeIndex/Cli/IssueDuplicatePreflight.cs new file mode 100644 index 0000000000..72b3c70cfd --- /dev/null +++ b/src/CodeIndex/Cli/IssueDuplicatePreflight.cs @@ -0,0 +1,274 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace CodeIndex.Cli; + +internal sealed class IssueDuplicatePreflight +{ + internal const int MaxOpenIssuesJsonBytes = 8 * 1024 * 1024; + internal const int MaxOpenIssuesJsonDepth = 32; + + private static readonly HashSet StopTitleTokens = new(StringComparer.OrdinalIgnoreCase) + { + "ai", + "suggestion", + "suggestions", + "cdidx", + "the", + "and", + "or", + "for", + "with", + "from", + "into", + "that", + "this", + }; + + private readonly List _issues; + + private IssueDuplicatePreflight(bool isChecked, string? source, List issues) + { + Checked = isChecked; + Source = source; + _issues = issues; + } + + public bool Checked { get; } + public string? Source { get; } + public int OpenIssueCount => _issues.Count; + + public static bool TryLoad(string? path, out IssueDuplicatePreflight preflight, out string? error) + { + error = null; + if (string.IsNullOrWhiteSpace(path)) + { + preflight = new IssueDuplicatePreflight(false, null, []); + return true; + } + + try + { + var fullPath = Path.GetFullPath(path); + var json = DataDirectorySecurity.ReadTextWithinLimit(fullPath, MaxOpenIssuesJsonBytes); + if (json == null) + { + preflight = new IssueDuplicatePreflight(false, null, []); + error = $"--open-issues file '{path}' exceeds maximum supported size of {MaxOpenIssuesJsonBytes} bytes."; + return false; + } + + var root = JsonNode.Parse( + json, + documentOptions: new JsonDocumentOptions { MaxDepth = MaxOpenIssuesJsonDepth }); + preflight = new IssueDuplicatePreflight(true, fullPath, ParseOpenIssues(root)); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) + { + preflight = new IssueDuplicatePreflight(false, null, []); + error = $"could not read --open-issues file '{path}': {ex.Message}"; + return false; + } + } + + public List FindMatches(string draftTitle, IReadOnlyList draftLabels) + { + if (!Checked || _issues.Count == 0) + return []; + + var draftLabelSet = draftLabels.ToHashSet(StringComparer.OrdinalIgnoreCase); + var normalizedDraftTitle = NormalizeTitleText(draftTitle); + var draftTokens = TokenizeTitle(draftTitle); + var matches = new List(); + foreach (var issue in _issues) + { + var issueLabels = issue.Labels + .Where(label => !string.IsNullOrWhiteSpace(label)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var overlappingLabels = issueLabels + .Where(draftLabelSet.Contains) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var normalizedIssueTitle = NormalizeTitleText(issue.Title); + var score = 0.0; + string? reason = null; + if (normalizedIssueTitle.Length > 0 && normalizedIssueTitle == normalizedDraftTitle) + { + reason = "title_exact"; + score = 1.0; + } + else if (overlappingLabels.Count > 0) + { + score = ScoreTitleSimilarity(draftTokens, TokenizeTitle(issue.Title)); + if (score >= 0.45) + { + reason = "title_label_similarity"; + } + else if (normalizedIssueTitle.Length > 16 + && normalizedDraftTitle.Length > 16 + && (normalizedIssueTitle.Contains(normalizedDraftTitle, StringComparison.Ordinal) + || normalizedDraftTitle.Contains(normalizedIssueTitle, StringComparison.Ordinal))) + { + reason = "title_label_contains"; + score = Math.Max(score, 0.45); + } + } + + if (reason == null) + continue; + + matches.Add(new SuggestionIssueDraftDuplicateMatchJsonResult( + issue.Number, + issue.Title, + issue.Url, + issueLabels, + overlappingLabels, + reason, + Math.Round(score, 3))); + } + + return matches + .OrderByDescending(match => match.Score) + .ThenBy(match => match.Number ?? int.MaxValue) + .Take(5) + .ToList(); + } + + private static List ParseOpenIssues(JsonNode? root) + { + var array = root as JsonArray + ?? root?["issues"] as JsonArray + ?? root?["items"] as JsonArray; + if (array == null) + return []; + + var issues = new List(); + foreach (var item in array) + { + var title = TryReadString(item?["title"]); + if (string.IsNullOrWhiteSpace(title)) + continue; + issues.Add(new OpenIssue( + TryReadInt(item?["number"]), + title, + TryReadString(item?["url"]) ?? TryReadString(item?["html_url"]), + ReadLabels(item?["labels"]))); + } + + return issues; + } + + private static List ReadLabels(JsonNode? labelsNode) + { + if (labelsNode is not JsonArray labels) + return []; + + var result = new List(); + foreach (var labelNode in labels) + { + var label = TryReadString(labelNode) ?? TryReadString(labelNode?["name"]); + if (!string.IsNullOrWhiteSpace(label)) + result.Add(label.Trim()); + } + + return result.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + private static string? TryReadString(JsonNode? node) + { + if (node == null) + return null; + try + { + return node.GetValue(); + } + catch (InvalidOperationException) + { + return null; + } + } + + private static int? TryReadInt(JsonNode? node) + { + if (node == null) + return null; + try + { + return node.GetValue(); + } + catch (InvalidOperationException) + { + var value = TryReadString(node); + return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) + ? parsed + : null; + } + } + + private static string NormalizeTitleText(string title) + { + var builder = new StringBuilder(title.Length); + var previousWasSpace = true; + foreach (var c in title) + { + if (char.IsLetterOrDigit(c)) + { + builder.Append(char.ToLowerInvariant(c)); + previousWasSpace = false; + } + else if (!previousWasSpace) + { + builder.Append(' '); + previousWasSpace = true; + } + } + + return builder.ToString().Trim(); + } + + private static HashSet TokenizeTitle(string title) + { + var tokens = new HashSet(StringComparer.OrdinalIgnoreCase); + var current = new StringBuilder(); + foreach (var c in title) + { + if (char.IsLetterOrDigit(c)) + { + current.Append(char.ToLowerInvariant(c)); + continue; + } + + AddToken(tokens, current); + } + + AddToken(tokens, current); + return tokens; + } + + private static void AddToken(HashSet tokens, StringBuilder current) + { + if (current.Length == 0) + return; + var token = current.ToString(); + current.Clear(); + if (token.Length < 3 || StopTitleTokens.Contains(token)) + return; + tokens.Add(token); + } + + private static double ScoreTitleSimilarity(HashSet left, HashSet right) + { + if (left.Count == 0 || right.Count == 0) + return 0.0; + + var intersection = left.Count(right.Contains); + var union = left.Count + right.Count - intersection; + return union == 0 ? 0.0 : intersection / (double)union; + } + + private sealed record OpenIssue(int? Number, string Title, string? Url, List Labels); +} diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 97a8e73cf7..055ea4400f 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -453,6 +453,14 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(SearchGuardEvidence))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(SearchQueryHint))] +[JsonSerializable(typeof(SearchRecipeListItemJsonResult))] +[JsonSerializable(typeof(SearchRecipeListJsonResult))] +[JsonSerializable(typeof(SearchRecipeQueryListItemJsonResult))] +[JsonSerializable(typeof(SearchRecipeQueryResultJsonResult))] +[JsonSerializable(typeof(SearchRecipeRunJsonResult))] +[JsonSerializable(typeof(SearchIssueDraftExportJsonResult))] +[JsonSerializable(typeof(SearchIssueDraftJsonResult))] +[JsonSerializable(typeof(SearchIssueDraftSourceJsonResult))] [JsonSerializable(typeof(SearchResult))] [JsonSerializable(typeof(SearchTermOccurrence))] [JsonSerializable(typeof(SearchTruncationContext))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index a1207f2aca..b21279f4b2 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -107,6 +107,8 @@ public static class QueryCommandRunner "--max-hops", "--depth", "--query", + "--recipe", + "--open-issues", "--group-by", "--focus-line", "--focus-column", @@ -222,6 +224,7 @@ private sealed record StatusReadinessField( "--bytes", "--profile", "--check-updates", + "--list-recipes", "--read-only", "--immutable", ]; @@ -234,6 +237,7 @@ private sealed record StatusReadinessField( private const string OutputFormatCompact = "compact"; private const string OutputFormatCsv = "csv"; private const string OutputFormatTsv = "tsv"; + private const string OutputFormatIssueDrafts = "issue-drafts"; private const string OutputFormatDot = "dot"; private const string OutputFormatGraphMl = "graphml"; private const string OutputFormatJsonGraph = "json-graph"; @@ -445,7 +449,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine(previewOptionError); return CommandExitCodes.UsageError; } - var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true); + var options = ParseArgs(cmdArgs, jsonDefault: false, allowNamedQuery: true, allowIssueDraftsFormat: true); if (TryWriteUnsupportedOptionError("search", cmdArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand("search"), options.Query)) return CommandExitCodes.UsageError; if (TryWriteParseError(options, "search")) @@ -455,6 +459,99 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) Console.Error.WriteLine(exactError); return CommandExitCodes.UsageError; } + if (options.OpenIssuesPath != null && options.OutputFormat != OutputFormatIssueDrafts) + { + WriteUsageError( + "--open-issues can only be used with `cdidx search --recipe --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.ListRecipes) + { + if (options.Query != null || options.RecipeName != null || options.ExtraNames.Count > 0) + { + WriteUsageError( + "--list-recipes cannot be combined with a query, --recipe, or extra positional arguments.", + GetUsageLineOrThrow("search"), + "Run `cdidx search --list-recipes` to list built-in audit recipes."); + return CommandExitCodes.UsageError; + } + if (options.OutputFormat is not OutputFormatText and not OutputFormatJson) + { + WriteUsageError( + "--format count/compact/csv/tsv/lsp/qf/sarif/issue-drafts is not supported with --list-recipes.", + GetUsageLineOrThrow("search"), + "Use plain text output or `--json` / `--format json` for the recipe list."); + return CommandExitCodes.UsageError; + } + if (options.JsonOutputFormat == JsonOutputFormatArray) + { + WriteUsageError( + "--json=array is not supported with --list-recipes because recipe-list output is a JSON object.", + GetUsageLineOrThrow("search"), + "Use plain `--json` for the recipe-list object."); + return CommandExitCodes.UsageError; + } + + 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 --format issue-drafts`."); + return CommandExitCodes.UsageError; + } + if (options.RecipeName != null) + { + if (options.Query != null || options.ExtraNames.Count > 0) + { + WriteUsageError( + "--recipe expands into its own curated query set and cannot be combined with a search query.", + GetUsageLineOrThrow("search"), + "Remove the positional query, or run a plain `cdidx search ` without --recipe."); + return CommandExitCodes.UsageError; + } + if (options.CountOnly) + { + WriteUsageError( + "--count is not supported with --recipe.", + GetUsageLineOrThrow("search"), + "Use `cdidx search --recipe --json` for per-query result counts."); + return CommandExitCodes.UsageError; + } + if (options.Prefix) + { + WriteUsageError( + "--prefix is not supported with --recipe because each recipe query defines its own match mode.", + GetUsageLineOrThrow("search"), + "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) + { + WriteUsageError( + "--format count/compact/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."); + return CommandExitCodes.UsageError; + } + if (options.JsonOutputFormat == JsonOutputFormatArray) + { + WriteUsageError( + "--json=array is not supported with --recipe because recipe output is grouped by query.", + GetUsageLineOrThrow("search"), + "Use plain `--json` for the grouped recipe object."); + return CommandExitCodes.UsageError; + } + + if (options.OutputFormat == OutputFormatIssueDrafts) + return RunSearchRecipeIssueDrafts(options, jsonOptions, exact); + + return RunSearchRecipe(options, jsonOptions, exact); + } if (exact && options.Prefix) { WriteValidationError( @@ -644,15 +741,276 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) }); } - private static List BuildSearchDisplayRows(List results, QueryCommandOptions options, bool exact) + private static int WriteSearchRecipeList(QueryCommandOptions options, JsonSerializerOptions jsonOptions) + { + var recipes = SearchAuditRecipes.All + .Select(ToSearchRecipeListItem) + .ToList(); + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new SearchRecipeListJsonResult(JsonOutputContract.ApiVersion, recipes.Count, recipes), + CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeListJsonResult)); + return CommandExitCodes.Success; + } + + foreach (var recipe in recipes) + { + Console.WriteLine($"{recipe.Name}: {recipe.Description}"); + Console.WriteLine($" labels: {string.Join(", ", recipe.RecommendedLabels)}"); + foreach (var query in recipe.Queries) + { + var mode = query.ExactSubstring ? "exact-substring" : "fts"; + Console.WriteLine($" - {query.Name}: {query.Query} ({mode})"); + Console.WriteLine($" {query.Description}"); + Console.WriteLine($" false positives: {query.FalsePositiveGuidance}"); + } + } + + return CommandExitCodes.Success; + } + + private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) + { + if (!SearchAuditRecipes.TryGet(options.RecipeName!, out var recipe)) + { + var available = string.Join(", ", SearchAuditRecipes.All.Select(r => r.Name)); + WriteUsageError( + $"unknown search recipe '{options.RecipeName}'.", + GetUsageLineOrThrow("search"), + $"Use `cdidx search --list-recipes` to see available recipes: {available}."); + return CommandExitCodes.UsageError; + } + + return WithDb(options, jsonOptions, reader => + { + var queryResults = CollectSearchRecipeQueryResults(reader, recipe, options, userExact, out var total); + + if (options.Json) + { + Console.WriteLine(JsonSerializer.Serialize( + new SearchRecipeRunJsonResult( + JsonOutputContract.ApiVersion, + ToSearchRecipeListItem(recipe), + recipe.Queries.Count, + total, + queryResults), + CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeRunJsonResult)); + return CommandExitCodes.Success; + } + + Console.WriteLine($"Recipe: {recipe.Name}"); + Console.WriteLine(recipe.Description); + Console.WriteLine(); + foreach (var queryResult in queryResults) + { + Console.WriteLine($"[{queryResult.Name}] {queryResult.Query}"); + Console.WriteLine(queryResult.Description); + Console.WriteLine($"labels: {string.Join(", ", queryResult.RecommendedLabels)}"); + Console.WriteLine($"false positives: {queryResult.FalsePositiveGuidance}"); + Console.WriteLine($"results: {queryResult.Count}"); + foreach (var result in queryResult.Results) + { + Console.WriteLine($"{result.Path}:{result.ChunkStartLine}-{result.ChunkEndLine}"); + foreach (var line in result.Snippet.Split('\n', StringSplitOptions.None)) + Console.WriteLine($" {line}"); + } + Console.WriteLine(); + } + + Console.Error.WriteLine($"({total} recipe results across {recipe.Queries.Count} queries)"); + return CommandExitCodes.Success; + }); + } + + private static int RunSearchRecipeIssueDrafts(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) + { + if (!SearchAuditRecipes.TryGet(options.RecipeName!, out var recipe)) + { + var available = string.Join(", ", SearchAuditRecipes.All.Select(r => r.Name)); + WriteUsageError( + $"unknown search recipe '{options.RecipeName}'.", + GetUsageLineOrThrow("search"), + $"Use `cdidx search --list-recipes` to see available recipes: {available}."); + return CommandExitCodes.UsageError; + } + 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 queryResults = CollectSearchRecipeQueryResults(reader, recipe, options, userExact, out var total); + var drafts = queryResults + .Where(queryResult => queryResult.Count > 0) + .Select(queryResult => ToSearchIssueDraft(recipe, queryResult, preflight)) + .ToList(); + Console.WriteLine(JsonSerializer.Serialize( + new SearchIssueDraftExportJsonResult( + JsonOutputContract.ApiVersion, + ToSearchRecipeListItem(recipe), + recipe.Queries.Count, + total, + drafts.Count, + new SuggestionIssueDraftPreflightSummaryJsonResult( + preflight.Checked, + preflight.Source, + preflight.OpenIssueCount), + drafts), + CliJsonSerializerContextFactory.Create(jsonOptions).SearchIssueDraftExportJsonResult)); + return CommandExitCodes.Success; + }); + } + + private static List CollectSearchRecipeQueryResults( + DbReader reader, + SearchAuditRecipe recipe, + QueryCommandOptions options, + bool userExact, + out int total) + { + var queryResults = new List(); + total = 0; + foreach (var recipeQuery in recipe.Queries) + { + 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, + guardFilters: options.GuardFilters, + guardWindow: options.GuardWindow); + var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query); + total += rows.Count; + queryResults.Add(new SearchRecipeQueryResultJsonResult( + recipeQuery.Name, + recipeQuery.Query, + recipeQuery.Description, + recipeQuery.RecommendedLabels, + recipeQuery.FalsePositiveGuidance, + exact, + rows.Count, + rows.Select(row => row.Compact).ToList())); + } + + return queryResults; + } + + private static SearchIssueDraftJsonResult ToSearchIssueDraft( + SearchAuditRecipe recipe, + SearchRecipeQueryResultJsonResult queryResult, + IssueDuplicatePreflight preflight) + { + var labels = queryResult.RecommendedLabels + .Where(label => !string.IsNullOrWhiteSpace(label)) + .Select(label => label.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(label => label, StringComparer.OrdinalIgnoreCase) + .ToList(); + var title = BuildSearchIssueDraftTitle(recipe, queryResult); + 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( + $"{recipe.Name}/{queryResult.Name}", + title, + labels, + evidencePaths, + BuildSearchIssueDraftBody(recipe, queryResult, evidencePaths), + new SearchIssueDraftSourceJsonResult( + recipe.Name, + queryResult.Name, + 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 BuildSearchIssueDraftBody( + SearchAuditRecipe recipe, + SearchRecipeQueryResultJsonResult queryResult, + IReadOnlyList evidencePaths) + { + var sb = new StringBuilder(); + sb.AppendLine("## Summary"); + sb.AppendLine(queryResult.Description); + sb.AppendLine(); + sb.AppendLine("## Recipe"); + sb.AppendLine(recipe.Name); + 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("## False-positive guidance"); + sb.AppendLine(queryResult.FalsePositiveGuidance); + sb.AppendLine(); + sb.AppendLine("## Search metadata"); + sb.AppendLine($"- draft_id: `{recipe.Name}/{queryResult.Name}`"); + sb.AppendLine($"- recipe_query: `{queryResult.Name}`"); + 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, + recipe.RecommendedLabels, + recipe.Queries.Select(query => new SearchRecipeQueryListItemJsonResult( + query.Name, + query.Query, + query.Description, + query.RecommendedLabels, + query.FalsePositiveGuidance, + query.ExactSubstring)).ToList()); + + private static List BuildSearchDisplayRows(List results, QueryCommandOptions options, bool exact, string? queryOverride = null) { var rows = new List(results.Count); var seenMatchLocations = !exact || options.NoDedup ? null : new HashSet(StringComparer.Ordinal); + var displayQuery = queryOverride ?? options.Query!; foreach (var result in results) { var compact = SearchSnippetFormatter.ToCompactResult( result, - options.Query!, + displayQuery, options.SnippetLines, exact, options.MaxLineWidth, @@ -5148,6 +5506,7 @@ public static QueryCommandOptions ParseArgs( bool jsonDefault, bool allowNamedQuery = false, bool allowStatusCheck = false, + bool allowIssueDraftsFormat = false, bool validateDefaultLimit = true, bool validateDefaultSnippetLines = true, bool validateDefaultMaxLineWidth = true) @@ -5223,6 +5582,9 @@ public static QueryCommandOptions ParseArgs( bool impactDeprecatedDepthUsed = false; List? mapSections = null; bool dependencyCycles = false; + string? recipeName = null; + bool listRecipes = false; + string? openIssuesPath = null; bool languagesIndexedOnly = false; var languageCapabilities = new List(); @@ -5404,9 +5766,17 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) parsedOutputFormat != OutputFormatGraphMl) json = true; } + else if (allowIssueDraftsFormat && string.Equals(formatValue, OutputFormatIssueDrafts, StringComparison.OrdinalIgnoreCase)) + { + outputFormat = OutputFormatIssueDrafts; + json = true; + } else { - AddParseError($"Error: --format must be one of text, json, count, compact, csv, tsv, lsp, qf, or sarif; got '{formatValue}'."); + var allowedFormats = allowIssueDraftsFormat + ? "text, json, count, compact, csv, tsv, lsp, qf, sarif, or issue-drafts" + : "text, json, count, compact, csv, tsv, lsp, qf, or sarif"; + AddParseError($"Error: --format must be one of {allowedFormats}; got '{formatValue}'."); } } else @@ -5459,6 +5829,27 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(queryError!); break; + case "--recipe": + if (TryReadStringOptionValue(args, ref i, "--recipe", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var recipeValue, out var recipeError)) + { + WarnIfDuplicateSingleValueOption("--recipe", recipeValue!); + recipeName = recipeValue; + } + else + AddParseError(recipeError!); + break; + case "--list-recipes": + listRecipes = true; + break; + case "--open-issues": + if (TryReadStringOptionValue(args, ref i, "--open-issues", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var openIssuesValue, out var openIssuesError)) + { + WarnIfDuplicateSingleValueOption("--open-issues", openIssuesValue!); + openIssuesPath = openIssuesValue; + } + else + AddParseError(openIssuesError!); + 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!); @@ -6042,6 +6433,9 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) ExtraNames = extraNames, MapSections = mapSections, DependencyCycles = dependencyCycles, + RecipeName = recipeName, + ListRecipes = listRecipes, + OpenIssuesPath = openIssuesPath, LanguagesIndexedOnly = languagesIndexedOnly, LanguageCapabilities = languageCapabilities, ParseError = parseErrors == null ? null : string.Join(Environment.NewLine, parseErrors), @@ -8437,6 +8831,8 @@ 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.", + ["--open-issues"] = "pass an open-issues JSON file, e.g. `--open-issues open-issues.json`; only valid with `search --recipe --format issue-drafts`.", ["--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.", ["--visibility"] = "pass one or more of public, protected, internal, private, e.g. `--visibility public,internal`.", ["--exclude-visibility"] = "pass one or more of public, protected, internal, private to exclude, e.g. `--exclude-visibility private`.", @@ -8847,6 +9243,9 @@ public sealed class QueryCommandOptions public List ExtraNames { get; init; } = []; public List? MapSections { get; init; } public bool DependencyCycles { get; init; } + public string? RecipeName { get; init; } + public bool ListRecipes { get; init; } + public string? OpenIssuesPath { get; init; } public bool LanguagesIndexedOnly { get; init; } public List LanguageCapabilities { get; init; } = []; public string? ParseError { get; init; } diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs new file mode 100644 index 0000000000..7e8afde3d2 --- /dev/null +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -0,0 +1,137 @@ +using System.Text.Json.Serialization; + +namespace CodeIndex.Cli; + +internal static class SearchAuditRecipes +{ + private static readonly List Recipes = + [ + new( + "risky-code", + "Reusable audit searches for risky code patterns that often need manual triage.", + [ + new( + "unbounded-json-parse", + "JsonDocument.Parse", + "Find direct JSON parsing calls that may need input size limits or streaming alternatives.", + ["audit", "bug"], + "False positives include tests, deliberately bounded callers, and parsing of already-small generated payloads."), + new( + "full-materialization", + "ReadToEnd", + "Find full stream/string materialization that may need bounded reads or incremental processing.", + ["audit", "performance"], + "False positives include bounded in-memory test fixtures and tiny diagnostic payloads."), + new( + "max-value-probe", + "int.MaxValue", + "Find sentinel or unbounded limit probes that may hide huge allocation or traversal paths.", + ["audit", "bug"], + "False positives include defensive upper-bound constants that are never passed to allocation or query limits."), + new( + "raw-diagnostic-echo", + "ex.Message", + "Find raw exception-message echoes that may need redaction before CLI, JSON, MCP, or GitHub output.", + ["audit", "security"], + "False positives include messages that are already sanitized by the surrounding writer."), + new( + "cancellation-gap", + "CancellationToken.None", + "Find async or stream paths that may be ignoring caller cancellation.", + ["audit", "bug"], + "False positives include intentionally fire-and-forget work and APIs that have no meaningful caller cancellation token.") + ]) + ]; + + internal static IReadOnlyList All => Recipes; + + internal static bool TryGet(string name, out SearchAuditRecipe recipe) + { + recipe = Recipes.FirstOrDefault(r => string.Equals(r.Name, name, StringComparison.OrdinalIgnoreCase))!; + return recipe != null; + } +} + +internal sealed record SearchAuditRecipe( + string Name, + string Description, + List Queries) +{ + public List RecommendedLabels => + Queries + .SelectMany(query => query.RecommendedLabels) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(label => label, StringComparer.OrdinalIgnoreCase) + .ToList(); +} + +internal sealed record SearchAuditRecipeQuery( + string Name, + string Query, + string Description, + List RecommendedLabels, + string FalsePositiveGuidance, + bool ExactSubstring = true); + +internal sealed record SearchRecipeListJsonResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("recipes")] List Recipes); + +internal sealed record SearchRecipeListItemJsonResult( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("recommended_labels")] List RecommendedLabels, + [property: JsonPropertyName("queries")] List Queries); + +internal sealed record SearchRecipeQueryListItemJsonResult( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("query")] string Query, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("recommended_labels")] List RecommendedLabels, + [property: JsonPropertyName("false_positive_guidance")] string FalsePositiveGuidance, + [property: JsonPropertyName("exact_substring")] bool ExactSubstring); + +internal sealed record SearchRecipeRunJsonResult( + [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 Queries); + +internal sealed record SearchRecipeQueryResultJsonResult( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("query")] string Query, + [property: JsonPropertyName("description")] string Description, + [property: JsonPropertyName("recommended_labels")] List RecommendedLabels, + [property: JsonPropertyName("false_positive_guidance")] string FalsePositiveGuidance, + [property: JsonPropertyName("exact_substring")] bool ExactSubstring, + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("results")] List Results); + +internal sealed record SearchIssueDraftExportJsonResult( + [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("count")] int Count, + [property: JsonPropertyName("duplicate_preflight")] SuggestionIssueDraftPreflightSummaryJsonResult DuplicatePreflight, + [property: JsonPropertyName("drafts")] List Drafts); + +internal sealed record SearchIssueDraftJsonResult( + [property: JsonPropertyName("draft_id")] string DraftId, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("labels")] List Labels, + [property: JsonPropertyName("evidence_paths")] List EvidencePaths, + [property: JsonPropertyName("body")] string Body, + [property: JsonPropertyName("source")] SearchIssueDraftSourceJsonResult Source, + [property: JsonPropertyName("duplicate_preflight")] SuggestionIssueDraftDuplicatePreflightJsonResult DuplicatePreflight); + +internal sealed record SearchIssueDraftSourceJsonResult( + [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, + [property: JsonPropertyName("exact_substring")] bool ExactSubstring, + [property: JsonPropertyName("result_count")] int ResultCount); diff --git a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs index 7028939719..36bc58ce36 100644 --- a/src/CodeIndex/Cli/SuggestionsCommandRunner.cs +++ b/src/CodeIndex/Cli/SuggestionsCommandRunner.cs @@ -1,7 +1,6 @@ using System.Globalization; using System.Text; using System.Text.Json; -using System.Text.Json.Nodes; using System.Text.Json.Serialization; using CodeIndex.Database; using CodeIndex.Models; @@ -11,8 +10,8 @@ namespace CodeIndex.Cli; internal static class SuggestionsCommandRunner { private const string Usage = "Usage: cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--format ] [--open-issues ]"; - internal const int MaxOpenIssuesJsonBytes = 8 * 1024 * 1024; - internal const int MaxOpenIssuesJsonDepth = 32; + internal const int MaxOpenIssuesJsonBytes = IssueDuplicatePreflight.MaxOpenIssuesJsonBytes; + internal const int MaxOpenIssuesJsonDepth = IssueDuplicatePreflight.MaxOpenIssuesJsonDepth; public static int Run(string[] args, JsonSerializerOptions jsonOptions) { @@ -629,271 +628,6 @@ private static bool TryReadValue(string[] args, ref int i, string option, out st return true; } - private sealed class IssueDuplicatePreflight - { - private static readonly HashSet StopTitleTokens = new(StringComparer.OrdinalIgnoreCase) - { - "ai", - "suggestion", - "suggestions", - "cdidx", - "the", - "and", - "or", - "for", - "with", - "from", - "into", - "that", - "this", - }; - - private readonly List _issues; - - private IssueDuplicatePreflight(bool isChecked, string? source, List issues) - { - Checked = isChecked; - Source = source; - _issues = issues; - } - - public bool Checked { get; } - public string? Source { get; } - public int OpenIssueCount => _issues.Count; - - public static bool TryLoad(string? path, out IssueDuplicatePreflight preflight, out string? error) - { - error = null; - if (string.IsNullOrWhiteSpace(path)) - { - preflight = new IssueDuplicatePreflight(false, null, []); - return true; - } - - try - { - var fullPath = Path.GetFullPath(path); - var json = DataDirectorySecurity.ReadTextWithinLimit(fullPath, MaxOpenIssuesJsonBytes); - if (json == null) - { - preflight = new IssueDuplicatePreflight(false, null, []); - error = $"--open-issues file '{path}' exceeds maximum supported size of {MaxOpenIssuesJsonBytes} bytes."; - return false; - } - - var root = JsonNode.Parse( - json, - documentOptions: new JsonDocumentOptions { MaxDepth = MaxOpenIssuesJsonDepth }); - preflight = new IssueDuplicatePreflight(true, fullPath, ParseOpenIssues(root)); - return true; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or JsonException) - { - preflight = new IssueDuplicatePreflight(false, null, []); - error = $"could not read --open-issues file '{path}': {ex.Message}"; - return false; - } - } - - public List FindMatches(string draftTitle, IReadOnlyList draftLabels) - { - if (!Checked || _issues.Count == 0) - return []; - - var draftLabelSet = draftLabels.ToHashSet(StringComparer.OrdinalIgnoreCase); - var normalizedDraftTitle = NormalizeTitleText(draftTitle); - var draftTokens = TokenizeTitle(draftTitle); - var matches = new List(); - foreach (var issue in _issues) - { - var issueLabels = issue.Labels - .Where(label => !string.IsNullOrWhiteSpace(label)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - var overlappingLabels = issueLabels - .Where(draftLabelSet.Contains) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToList(); - var normalizedIssueTitle = NormalizeTitleText(issue.Title); - var score = 0.0; - string? reason = null; - if (normalizedIssueTitle.Length > 0 && normalizedIssueTitle == normalizedDraftTitle) - { - reason = "title_exact"; - score = 1.0; - } - else if (overlappingLabels.Count > 0) - { - score = ScoreTitleSimilarity(draftTokens, TokenizeTitle(issue.Title)); - if (score >= 0.45) - { - reason = "title_label_similarity"; - } - else if (normalizedIssueTitle.Length > 16 - && normalizedDraftTitle.Length > 16 - && (normalizedIssueTitle.Contains(normalizedDraftTitle, StringComparison.Ordinal) - || normalizedDraftTitle.Contains(normalizedIssueTitle, StringComparison.Ordinal))) - { - reason = "title_label_contains"; - score = Math.Max(score, 0.45); - } - } - - if (reason == null) - continue; - - matches.Add(new SuggestionIssueDraftDuplicateMatchJsonResult( - issue.Number, - issue.Title, - issue.Url, - issueLabels, - overlappingLabels, - reason, - Math.Round(score, 3))); - } - - return matches - .OrderByDescending(match => match.Score) - .ThenBy(match => match.Number ?? int.MaxValue) - .Take(5) - .ToList(); - } - - private static List ParseOpenIssues(JsonNode? root) - { - var array = root as JsonArray - ?? root?["issues"] as JsonArray - ?? root?["items"] as JsonArray; - if (array == null) - return []; - - var issues = new List(); - foreach (var item in array) - { - var title = TryReadString(item?["title"]); - if (string.IsNullOrWhiteSpace(title)) - continue; - issues.Add(new OpenIssue( - TryReadInt(item?["number"]), - title, - TryReadString(item?["url"]) ?? TryReadString(item?["html_url"]), - ReadLabels(item?["labels"]))); - } - - return issues; - } - - private static List ReadLabels(JsonNode? labelsNode) - { - if (labelsNode is not JsonArray labels) - return []; - - var result = new List(); - foreach (var labelNode in labels) - { - var label = TryReadString(labelNode) ?? TryReadString(labelNode?["name"]); - if (!string.IsNullOrWhiteSpace(label)) - result.Add(label.Trim()); - } - - return result.Distinct(StringComparer.OrdinalIgnoreCase).ToList(); - } - - private static string? TryReadString(JsonNode? node) - { - if (node == null) - return null; - try - { - return node.GetValue(); - } - catch (InvalidOperationException) - { - return null; - } - } - - private static int? TryReadInt(JsonNode? node) - { - if (node == null) - return null; - try - { - return node.GetValue(); - } - catch (InvalidOperationException) - { - var value = TryReadString(node); - return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) - ? parsed - : null; - } - } - - private static string NormalizeTitleText(string title) - { - var builder = new StringBuilder(title.Length); - var previousWasSpace = true; - foreach (var c in title) - { - if (char.IsLetterOrDigit(c)) - { - builder.Append(char.ToLowerInvariant(c)); - previousWasSpace = false; - } - else if (!previousWasSpace) - { - builder.Append(' '); - previousWasSpace = true; - } - } - - return builder.ToString().Trim(); - } - - private static HashSet TokenizeTitle(string title) - { - var tokens = new HashSet(StringComparer.OrdinalIgnoreCase); - var current = new StringBuilder(); - foreach (var c in title) - { - if (char.IsLetterOrDigit(c)) - { - current.Append(char.ToLowerInvariant(c)); - continue; - } - - AddToken(tokens, current); - } - - AddToken(tokens, current); - return tokens; - } - - private static void AddToken(HashSet tokens, StringBuilder current) - { - if (current.Length == 0) - return; - var token = current.ToString(); - current.Clear(); - if (token.Length < 3 || StopTitleTokens.Contains(token)) - return; - tokens.Add(token); - } - - private static double ScoreTitleSimilarity(HashSet left, HashSet right) - { - if (left.Count == 0 || right.Count == 0) - return 0.0; - - var intersection = left.Count(right.Contains); - var union = left.Count + right.Count - intersection; - return union == 0 ? 0.0 : intersection / (double)union; - } - - private sealed record OpenIssue(int? Number, string Title, string? Url, List Labels); - } - private sealed class Options { public string? Id { get; set; } diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 40ecf7486b..eba20cc57e 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -115,7 +115,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx references |--query |-- ", output); Assert.Contains("cdidx callers |--query |-- ", output); Assert.Contains("cdidx callees |--query |-- ", output); - Assert.Contains("cdidx search |--query |-- [--db ] [--json[=ndjson|array]] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank]", output); + Assert.Contains("cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]", output); Assert.Contains("cdidx definition |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx references |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]", output); Assert.Contains("cdidx inspect |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--max-line-width ] [--exact|--exact-name]", output); @@ -268,7 +268,7 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() { var output = CaptureFullUsageOutput(showBanner: false); - Assert.Contains("cdidx search |--query |-- [--db ] [--json[=ndjson|array]] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank]", output); + Assert.Contains("cdidx search |--query |-- |--recipe |--list-recipes [--db ] [--json[=ndjson|array]] [--format ] [--open-issues ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]", output); Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); Assert.Contains("cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 085c12d121..69dcabf1ed 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -213,6 +213,219 @@ public void RunSearch_JsonFormatRejectsUnknownValue_Issue1850() Assert.Contains("--json format must be one of ndjson or array", stderr); } + [Fact] + public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--list-recipes", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + var recipe = root + .GetProperty("recipes") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "risky-code"); + var query = recipe + .GetProperty("queries") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "raw-diagnostic-echo"); + + Assert.Equal(1, root.GetProperty("count").GetInt32()); + Assert.Contains(recipe.GetProperty("recommended_labels").EnumerateArray(), label => label.GetString() == "audit"); + Assert.Equal("ex.Message", query.GetProperty("query").GetString()); + Assert.True(query.GetProperty("exact_substring").GetBoolean()); + Assert.Contains("redaction", query.GetProperty("description").GetString(), StringComparison.OrdinalIgnoreCase); + Assert.Contains("False positives", query.GetProperty("false_positive_guidance").GetString(), StringComparison.OrdinalIgnoreCase); + } + + [Theory] + [InlineData("count")] + [InlineData("csv")] + public void RunSearch_ListRecipesRejectsUnsupportedFormattedOutputs_Issue3144(string format) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--list-recipes", "--format", format], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--format count/compact/csv/tsv/lsp/qf/sarif/issue-drafts is not supported with --list-recipes", stderr); + } + + [Fact] + public void RunSearch_ListRecipesRejectsJsonArray_Issue3144() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--list-recipes", "--json=array"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--json=array is not supported with --list-recipes", stderr); + } + + [Fact] + public void RunSearch_RecipeJsonRunsBuiltInQueries_Issue3144() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_json"); + 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, CancellationToken token) + { + JsonDocument.Parse("{}"); + reader.ReadToEnd(); + Console.WriteLine(ex.Message); + _ = CancellationToken.None; + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code", "--db", dbPath, "--lang", "csharp", "--limit", "2", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + var unboundedJsonParse = root + .GetProperty("queries") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "unbounded-json-parse"); + + Assert.Equal("risky-code", root.GetProperty("recipe").GetProperty("name").GetString()); + Assert.Equal(5, root.GetProperty("query_count").GetInt32()); + Assert.True(root.GetProperty("result_count").GetInt32() >= 4); + Assert.Equal(1, unboundedJsonParse.GetProperty("count").GetInt32()); + Assert.Equal("JsonDocument.Parse", unboundedJsonParse.GetProperty("query").GetString()); + Assert.Equal("src/app.cs", unboundedJsonParse.GetProperty("results")[0].GetProperty("path").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Theory] + [InlineData("count")] + [InlineData("compact")] + public void RunSearch_RecipeRejectsUnsupportedFormattedOutputs_Issue3144(string format) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code", "--format", format], + _jsonOptions)); + + 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); + } + + [Fact] + public void RunSearch_RecipeRejectsJsonArray_Issue3144() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code", "--json=array"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--json=array is not supported with --recipe", stderr); + } + + [Fact] + public void RunSearch_RecipeIssueDraftsIncludeLabelsEvidenceAndDuplicatePreflight_Issue3145() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_issue_drafts"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var openIssuesPath = Path.Combine(projectRoot, "open-issues.json"); + File.WriteAllText( + openIssuesPath, + """ + [ + { + "number": 3145, + "title": "Search audit recipe risky-code: unbounded-json-parse", + "labels": [{"name": "audit"}, {"name": "bug"}], + "url": "https://example.test/issues/3145" + } + ] + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + """ + using System.Text.Json; + + public sealed class App + { + public void Run() + { + JsonDocument.Parse("{}"); + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code", "--db", dbPath, "--format", "issue-drafts", "--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 duplicatePreflight = draft.GetProperty("duplicate_preflight"); + var match = Assert.Single(duplicatePreflight.GetProperty("matches").EnumerateArray()); + var body = draft.GetProperty("body").GetString(); + + Assert.Equal(1, root.GetProperty("count").GetInt32()); + Assert.True(root.GetProperty("duplicate_preflight").GetProperty("checked").GetBoolean()); + Assert.Equal(1, root.GetProperty("duplicate_preflight").GetProperty("open_issue_count").GetInt32()); + Assert.Equal("Search audit recipe risky-code: unbounded-json-parse", draft.GetProperty("title").GetString()); + Assert.Contains(draft.GetProperty("labels").EnumerateArray(), label => label.GetString() == "audit"); + Assert.Contains(draft.GetProperty("labels").EnumerateArray(), label => label.GetString() == "bug"); + 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.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()); + Assert.Equal(3145, match.GetProperty("number").GetInt32()); + Assert.Equal("title_exact", match.GetProperty("reason").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_IssueDraftsRequireRecipe_Issue3145() + { + var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["Authenticate", "--format", "issue-drafts"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("--format issue-drafts requires --recipe", stderr); + } + [Fact] public void RunSearch_ProfileEmitsSqlPhasesAndQueryPlan_Issue1643() {