From 8505427d8bc08d33682e1d4fe0818385cf0b408b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 01:05:40 +0900 Subject: [PATCH 1/4] Add focused search audit recipes (#3787, #3714, #3731, #3710, #3694, #3693, #3670, #3696) --- USER_GUIDE.md | 65 +++++--- src/CodeIndex/Cli/CliFlagSchema.cs | 1 + src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Cli/JsonOutputContracts.cs | 1 + src/CodeIndex/Cli/QueryCommandRunner.cs | 108 ++++++++++++- src/CodeIndex/Cli/SearchAuditRecipes.cs | 196 ++++++++++++++++++++--- 6 files changed, 323 insertions(+), 50 deletions(-) diff --git a/USER_GUIDE.md b/USER_GUIDE.md index dfea97244e..740a7abedf 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -948,6 +948,7 @@ 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 --show-excluded --json # include recipe scope/exclusion diagnostics 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 cdidx search --named-query pack="dotnet pack" --named-query push="nuget push" --format compact # named ad hoc batch with compact snippets @@ -986,20 +987,30 @@ The MCP `search` tool exposes the same mode as camelCase arguments: 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. +Built-in recipes include `risky-code`, `json-parse-apis`, +`dotnet-risk-patterns`, `xml-parser-security`, `filesystem-traversal`, and +the opt-in broad `broad-token-audit` recipe. `--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. +Recipe and named-query JSON include per-query counts, `top_files`, and +`truncated` metadata. Recipe JSON and compact output also return `next_cursor` +when a single selected recipe query is truncated. Add `--show-excluded` to a +recipe run when you need the effective path scope and exclusion diagnostics in +JSON output. +Recipe runs support text output, `--json` / `--format json`, `--format compact`, +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. The MCP `search` tool exposes the same recipe surface with `{"listRecipes":true}` for discovery and `{"recipe":"risky-code"}` for -execution. Set `CDIDX_SEARCH_RECIPE_PATHS` to one or more JSON files separated -by the platform path separator to add configured recipe sources; each file may -be a recipe array or `{ "recipes": [...] }`, and invalid sources are reported as -bounded `recipe_source_diagnostics`. +execution. MCP recipe runs apply the same default source scope as the CLI; pass +`{"auditScope":"all"}` when intentionally auditing docs, tests, changelog, and +recipe definitions. Set `CDIDX_SEARCH_RECIPE_PATHS` to one or more JSON files +separated by the platform path separator to add configured recipe sources; each +file may be a recipe array or `{ "recipes": [...] }`, and invalid sources are +reported as bounded `recipe_source_diagnostics`. 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 @@ -1351,10 +1362,11 @@ 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. Quote shell globs such as `--path 'src/**'` so the shell passes one literal pattern. | | `--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`. Use `recipe/query` form, such as `risky-code/raw-diagnostic-echo`, to run one child query directly. Recipe runs default to `--audit-scope source`, applying the recipe's production-code path and exclusion metadata before normal search filters and snippet controls; `--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. | +| `--recipe ` | `search` | Run a reusable audit recipe such as `risky-code`, `json-parse-apis`, `dotnet-risk-patterns`, `xml-parser-security`, or `filesystem-traversal`. Use `recipe/query` form, such as `risky-code/raw-diagnostic-echo`, to run one child query directly. Recipe runs default to `--audit-scope source`, applying the recipe's production-code path and exclusion metadata before normal search filters and snippet controls; `--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 ` / `--exclude-query ` | `search --recipe ` | Include or exclude child recipe queries by name. Repeatable and comma-separated; names are listed by `cdidx search --list-recipes`. | | `--cursor ` | `search --recipe ` | Fetch the next page for one selected recipe child query. Use the `next_cursor` returned by recipe JSON or compact output. | | `--audit-scope ` | `search --recipe ` | Choose recipe path scope. `source` is the default and suppresses tests, docs, changelog text, and recipe definitions using recipe metadata; `all` intentionally searches every indexed path unless other filters exclude it. JSON recipe output reports the effective scope, path filters, and exclusions. | +| `--show-excluded` | `search --recipe ` | Include `scope.excluded_diagnostics` in recipe output so broad audits can see which default include patterns, default exclusions, user exclusions, and test filtering were applied. | | `--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. | | `--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 | @@ -3449,6 +3461,7 @@ 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 --show-excluded --json # recipe scope / exclusion diagnostics を含める 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 cdidx search --named-query pack="dotnet pack" --named-query push="nuget push" --format compact # 名前付き ad hoc batch と compact snippet @@ -3481,21 +3494,30 @@ MCP `search` tool では同じ mode を camelCase 引数 `requireBefore`, `requi `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`、 +組み込み recipe には `risky-code`、`json-parse-apis`、`dotnet-risk-patterns`、`xml-parser-security`、 +`filesystem-traversal`、`broad-token-audit` があります。`--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 を出力し、query ごとの count、 +`top_files`、`truncated` metadata を含みます。named-query JSON も count、`top_files`、 +`truncated` の per-query metadata を返します。recipe の JSON / compact output は、単一の +recipe query が truncated された場合に `next_cursor` も返します。`--format compact` は +summary、query count、query ごとの count、`truncated` flag、該当する場合の `next_cursor` +を返します。 +`--show-excluded` を recipe と併用すると、有効な path scope と除外診断を出力に含めます。 +recipe run が対応する形式は text output、`--json` / `--format json`、`--format compact`、 `--format issue-drafts` です。`--list-recipes` は text または JSON に対応します。 その他の search export format と `--json=array` は、recipe output が query または list metadata ごとに grouped されるため usage error で拒否します。 MCP `search` tool では `{"listRecipes":true}` で recipe を発見し、 -`{"recipe":"risky-code"}` で実行できます。`CDIDX_SEARCH_RECIPE_PATHS` に -platform path separator 区切りの JSON file を指定すると、設定済み recipe source を -追加できます。各 file は recipe array または `{ "recipes": [...] }` を受け付け、 -不正な source は bounded な `recipe_source_diagnostics` として報告されます。 +`{"recipe":"risky-code"}` で実行できます。MCP の recipe run も CLI と同じ既定の source +scope を適用します。docs、tests、changelog、recipe definitions を意図的に audit する場合は +`{"auditScope":"all"}` を指定してください。`CDIDX_SEARCH_RECIPE_PATHS` に platform path +separator 区切りの JSON file を指定すると、設定済み recipe source を追加できます。各 file は +recipe array または `{ "recipes": [...] }` を受け付け、不正な source は bounded な +`recipe_source_diagnostics` として報告されます。 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` @@ -3843,10 +3865,11 @@ 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 で結合)。`--path 'src/**'` のように shell glob を引用し、shell が 1 つの literal pattern として渡すようにする。 | | `--query ` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | クエリを明示的なリテラルとして渡す。クエリが `-` で始まる場合に有用。`find` 以外のクエリ系コマンドでは `-- ` も1トークンのクエリエスケープとして受け付け、その後のオプション解析を続ける。 | -| `--recipe ` | `search` | `risky-code` などの再利用可能な audit recipe を実行する。`risky-code/raw-diagnostic-echo` のような `recipe/query` 形式で child query を1つだけ直接実行できる。Recipe 実行は既定で `--audit-scope source` になり、recipe の本番コード向け path / exclusion metadata を適用したうえで、通常の search filter と snippet control を選択された各 query に適用する。`--limit` / `--top` は child query ごとの上限になる。text、`--json` / `--format json`、`--format compact`、`--format issue-drafts` に対応し、issue draft には再実行コマンドを含める。 | +| `--recipe ` | `search` | `risky-code`、`json-parse-apis`、`dotnet-risk-patterns`、`xml-parser-security`、`filesystem-traversal` などの再利用可能な audit recipe を実行する。`risky-code/raw-diagnostic-echo` のような `recipe/query` 形式で child query を1つだけ直接実行できる。Recipe 実行は既定で `--audit-scope source` になり、recipe の本番コード向け path / exclusion metadata を適用したうえで、通常の search filter と snippet control を選択された各 query に適用する。`--limit` / `--top` は child query ごとの上限になる。text、`--json` / `--format json`、`--format compact`、`--format issue-drafts` に対応し、issue draft には再実行コマンドを含める。 | | `--include-query ` / `--exclude-query ` | `search --recipe ` | recipe 内の child query を名前で含める、または除外する。繰り返し指定とカンマ区切りに対応し、名前は `cdidx search --list-recipes` で確認できる。 | | `--cursor ` | `search --recipe ` | 選択した recipe child query の次ページを取得する。recipe JSON または compact output が返す `next_cursor` を指定する。 | | `--audit-scope ` | `search --recipe ` | recipe の path scope を選ぶ。既定の `source` は recipe metadata により tests、docs、changelog text、recipe 定義を抑制する。`all` は他の filter で除外しない限り、すべての indexed path を意図的に検索する。Recipe の JSON 出力には有効な scope、path filter、exclusion が含まれる。 | +| `--show-excluded` | `search --recipe ` | recipe output に `scope.excluded_diagnostics` を含め、広い audit で default include pattern、default exclusion、user exclusion、test filter の適用状況を確認できるようにする。 | | `--list-recipes` | `search` | 利用可能な search audit recipe を query text、推奨 label、exact-match mode、false-positive guidance、対応 format、filter support、limit semantics 付きで一覧表示する。 | | `--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` | テストらしいパスを除外し、本番コードを優先 | diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index e531d0dcda..fb4504b461 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -285,6 +285,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--exclude-query", ValuePlaceholder = "", 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 = "--audit-scope", ValuePlaceholder = "", Description = "Search recipes: use production source defaults or include all indexed paths", Commands = Set("search") }, + new() { Name = "--show-excluded", Description = "Search recipes: include effective scope and exclusion diagnostics in recipe output", Commands = Set("search") }, new() { Name = "--named-query", ValuePlaceholder = "=", Description = "Search: add one named ad hoc batch query", Commands = Set("search") }, new() { Name = "--open-issues", ValuePlaceholder = "", Description = "Preflight issue drafts against open issue JSON or GitHub open issues", Commands = Set("search", "suggestions") }, new() { Name = "--repo", ValuePlaceholder = "", Description = "Issue-drafts: GitHub repository for --open-issues github", Commands = Set("search", "suggestions") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 91f9001762..e220f14d3a 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -88,7 +88,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("index-commits", "cdidx index --commits [commit-ref ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-changed-between", "cdidx index --changed-between [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-files", "cdidx index --files [path ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), - ("search", "cdidx search |--query |-- |--recipe |--list-recipes|--named-query = [--named-query = ...] [--include-query ] [--exclude-query ] [--cursor ] [--audit-scope ] [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--repo ] [--issue-title ] [--issue-label <label>] [--verbose] [--limit <n>|--top <n>|--max-results <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--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|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--show-excluded] [--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>|--max-results <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--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 48033c457b..21e41f984f 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -570,6 +570,7 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(SearchRecipeQueryResultJsonResult))] [JsonSerializable(typeof(SearchRecipeRunJsonResult))] [JsonSerializable(typeof(SearchRecipeScopeJsonResult))] +[JsonSerializable(typeof(SearchRecipeExcludedDiagnosticJsonResult))] [JsonSerializable(typeof(SearchIssueDraftExportJsonResult))] [JsonSerializable(typeof(SearchIssueDraftJsonResult))] [JsonSerializable(typeof(SearchIssueDraftSourceJsonResult))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 0bb63c1f63..aac65b2a3b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -95,6 +95,9 @@ public static partial class QueryCommandRunner private const int SearchOriginFilterOverFetchFactor = 50; private const int SearchOriginFilterMaxCandidates = 10_000; private const int SearchOriginFilterMaxPages = 50; + private const int SearchEnvelopeMinCandidates = 200; + private const int SearchEnvelopeOverFetchFactor = 50; + private const int SearchEnvelopeMaxCandidates = 10_000; private const string HotspotsGroupedByNameKind = "name_kind"; private const string HotspotsGroupedBySymbol = "symbol"; private const string HotspotsGroupedByFile = "file"; @@ -401,6 +404,14 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) "Use `--audit-scope source` for the production-code default or `--audit-scope all` when intentionally auditing docs, tests, and recipe definitions."); return CommandExitCodes.UsageError; } + if (options.ShowExcluded && options.RecipeName == null) + { + WriteUsageError( + "--show-excluded is only supported with `cdidx search --recipe <name>`.", + GetUsageLineOrThrow("search"), + "Use it with a recipe run to include the effective scope and exclusion diagnostics in JSON output."); + return CommandExitCodes.UsageError; + } if ((options.IssueTitle != null || options.IssueLabels.Count > 0) && options.OutputFormat != OutputFormatIssueDrafts) { WriteUsageError( @@ -1150,6 +1161,18 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp if (scope.ExcludePaths.Count > 0) Console.WriteLine($"Excludes: {string.Join(", ", scope.ExcludePaths)}"); Console.WriteLine($"Exclude tests: {scope.ExcludeTests.ToString().ToLowerInvariant()}"); + if (scope.ExcludedDiagnostics is { Count: > 0 }) + { + Console.WriteLine("Excluded diagnostics:"); + foreach (var diagnostic in scope.ExcludedDiagnostics) + { + var patterns = diagnostic.Patterns.Count == 0 + ? string.Empty + : $" ({string.Join(", ", diagnostic.Patterns)})"; + Console.WriteLine($" - {diagnostic.Reason}: applied={diagnostic.Applied.ToString().ToLowerInvariant()}{patterns}"); + Console.WriteLine($" {diagnostic.Description}"); + } + } Console.WriteLine(); foreach (var queryResult in queryResults) { @@ -1255,6 +1278,8 @@ private static int RunSearchIssueDrafts(QueryCommandOptions options, JsonSeriali "Review the evidence paths and surrounding code before filing.", exact, rows.Count, + BuildSearchRecipeTopFiles(rows), + false, null, rows.Select(row => row.Compact).ToList()); var drafts = rows.Count == 0 @@ -1294,7 +1319,7 @@ private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryR var exact = userExact || recipeQuery.ExactSubstring; var results = reader.Search( recipeQuery.Query, - options.Limit, + FetchLimitForSearchEnvelope(options.Limit), options.Lang, false, scope.PathPatterns, @@ -1309,6 +1334,7 @@ private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryR guardFilters: options.GuardFilters, guardWindow: options.GuardWindow); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false); + var truncated = TrimSearchRowsToRequestedLimit(rows, options.Limit); total += rows.Count; queryResults.Add(new SearchRecipeQueryResultJsonResult( recipeQuery.Name, @@ -1318,7 +1344,9 @@ private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryR recipeQuery.FalsePositiveGuidance, exact, rows.Count, - rows.Count > 0 ? FormatSearchCursor(rows[^1].Result) : null, + BuildSearchRecipeTopFiles(rows), + truncated, + truncated && rows.Count > 0 ? FormatSearchCursor(rows[^1].Result) : null, rows.Select(row => row.Compact).ToList())); } @@ -1340,7 +1368,7 @@ private static List<SearchRecipeCompactQueryResultJsonResult> CollectSearchRecip var exact = userExact || recipeQuery.ExactSubstring; var results = reader.Search( recipeQuery.Query, - options.Limit, + FetchLimitForSearchEnvelope(options.Limit), options.Lang, false, scope.PathPatterns, @@ -1355,6 +1383,7 @@ private static List<SearchRecipeCompactQueryResultJsonResult> CollectSearchRecip guardFilters: options.GuardFilters, guardWindow: options.GuardWindow); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query); + var truncated = TrimSearchRowsToRequestedLimit(rows, options.Limit); total += rows.Count; queryResults.Add(new SearchRecipeCompactQueryResultJsonResult( recipeQuery.Name, @@ -1362,7 +1391,8 @@ private static List<SearchRecipeCompactQueryResultJsonResult> CollectSearchRecip recipeQuery.Description, rows.Count, BuildSearchRecipeTopFiles(rows), - rows.Count > 0 ? FormatSearchCursor(rows[^1].Result) : null, + truncated, + truncated && rows.Count > 0 ? FormatSearchCursor(rows[^1].Result) : null, rows.Select(row => new SearchRecipeCompactResultJsonResult( row.Result.Path, row.Result.Lang, @@ -1398,7 +1428,42 @@ private static SearchRecipeScopeJsonResult BuildSearchRecipeScope(SearchAuditRec excludePaths, excludeTests, [.. recipe.DefaultPathPatterns], - [.. recipe.DefaultExcludePaths]); + [.. recipe.DefaultExcludePaths], + options.ShowExcluded ? BuildSearchRecipeExcludedDiagnostics(recipe, options, scopeName, excludeTests) : null); + } + + private static List<SearchRecipeExcludedDiagnosticJsonResult> BuildSearchRecipeExcludedDiagnostics( + SearchAuditRecipe recipe, + QueryCommandOptions options, + string scopeName, + bool excludeTests) + { + var diagnostics = new List<SearchRecipeExcludedDiagnosticJsonResult>(); + var sourceScope = string.Equals(scopeName, SearchAuditRecipes.DefaultAuditScope, StringComparison.OrdinalIgnoreCase); + diagnostics.Add(new SearchRecipeExcludedDiagnosticJsonResult( + "recipe_default_path_patterns", + sourceScope && options.PathPatterns.Count == 0 && recipe.DefaultPathPatterns.Count > 0, + [.. recipe.DefaultPathPatterns], + "Default source-scope include patterns applied when a recipe runs without user --path filters.")); + diagnostics.Add(new SearchRecipeExcludedDiagnosticJsonResult( + "recipe_default_exclude_paths", + sourceScope && recipe.DefaultExcludePaths.Count > 0, + [.. recipe.DefaultExcludePaths], + "Default source-scope exclusions suppress recipe definitions, tests, docs, changelog text, and agent/workflow metadata.")); + if (options.ExcludePaths.Count > 0) + { + diagnostics.Add(new SearchRecipeExcludedDiagnosticJsonResult( + "user_exclude_paths", + true, + [.. options.ExcludePaths], + "User-provided --exclude-path filters are applied after recipe defaults.")); + } + diagnostics.Add(new SearchRecipeExcludedDiagnosticJsonResult( + "exclude_tests", + excludeTests, + [], + "The test-file classifier is enabled for this recipe scope; exact excluded paths depend on indexed file metadata.")); + return diagnostics; } private static void AddDistinct(List<string> target, IEnumerable<string> values) @@ -1419,6 +1484,27 @@ private static List<SearchRecipeTopFileJsonResult> BuildSearchRecipeTopFiles(IRe .Take(10) .ToList(); + private static int FetchLimitForSearchEnvelope(int limit) + { + if (limit >= int.MaxValue) + return int.MaxValue; + if (limit <= 0) + return 1; + + var requested = (long)limit + 1; + var overFetched = requested * SearchEnvelopeOverFetchFactor; + var candidateLimit = Math.Max(SearchEnvelopeMinCandidates, Math.Max(requested, overFetched)); + return (int)Math.Min(SearchEnvelopeMaxCandidates, Math.Min(int.MaxValue, candidateLimit)); + } + + private static bool TrimSearchRowsToRequestedLimit(List<SearchDisplayRow> rows, int limit) + { + if (rows.Count <= limit) + return false; + rows.RemoveRange(limit, rows.Count - limit); + return true; + } + private static List<SearchNamedBatchQueryResultJsonResult> CollectSearchNamedBatchQueryResults( DbReader reader, QueryCommandOptions options, @@ -1431,7 +1517,7 @@ private static List<SearchNamedBatchQueryResultJsonResult> CollectSearchNamedBat { var results = reader.Search( namedQuery.Query, - options.Limit, + FetchLimitForSearchEnvelope(options.Limit), options.Lang, options.RawFts, options.PathPatterns, @@ -1445,6 +1531,7 @@ private static List<SearchNamedBatchQueryResultJsonResult> CollectSearchNamedBat guardFilters: options.GuardFilters, guardWindow: options.GuardWindow); var rows = BuildSearchDisplayRows(results, options, userExact, namedQuery.Query); + var truncated = TrimSearchRowsToRequestedLimit(rows, options.Limit); AttachExactSubstringHint( rows.Select(row => row.Compact), SearchQueryAdvisor.BuildExactSubstringHint(namedQuery.Query, options.RawFts, userExact, options.Prefix)); @@ -1454,6 +1541,9 @@ private static List<SearchNamedBatchQueryResultJsonResult> CollectSearchNamedBat namedQuery.Query, userExact, rows.Count, + BuildSearchRecipeTopFiles(rows), + truncated, + null, rows.Select(row => row.Compact).ToList())); } @@ -7467,6 +7557,7 @@ public static QueryCommandOptions ParseArgs( string? recipeName = null; var includeRecipeQueries = new List<string>(); var excludeRecipeQueries = new List<string>(); + bool showExcluded = false; bool listRecipes = false; string? openIssuesPath = null; string auditScope = SearchAuditRecipes.DefaultAuditScope; @@ -7815,6 +7906,9 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(excludeQueryError!); break; + case "--show-excluded": + showExcluded = true; + break; case "--list-recipes": listRecipes = true; break; @@ -8603,6 +8697,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) RecipeName = recipeName, IncludeRecipeQueries = includeRecipeQueries, ExcludeRecipeQueries = excludeRecipeQueries, + ShowExcluded = showExcluded, ListRecipes = listRecipes, OpenIssuesPath = openIssuesPath, AuditScope = auditScope, @@ -12057,6 +12152,7 @@ public sealed class QueryCommandOptions public string? RecipeName { get; init; } public List<string> IncludeRecipeQueries { get; init; } = []; public List<string> ExcludeRecipeQueries { get; init; } = []; + public bool ShowExcluded { get; init; } public bool ListRecipes { get; init; } public string? OpenIssuesPath { get; init; } public string AuditScope { get; init; } = SearchAuditRecipes.DefaultAuditScope; diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 6e65e9e265..cffbc808b6 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -20,10 +20,26 @@ internal static class SearchAuditRecipes private const int MaxExternalLabelLength = 64; private const int MaxRecipeDiagnosticCount = 64; private const int MaxRecipeDiagnosticLength = 512; + private static readonly string[] DefaultSourcePathPatterns = ["src/**"]; + private static readonly string[] DefaultSourceExcludePaths = + [ + "src/CodeIndex/Cli/SearchAuditRecipes.cs", + "tests/**", + "docs/**", + "CHANGELOG.md", + "changelog.d/**", + "README.md", + "USER_GUIDE.md", + "DEVELOPER_GUIDE.md", + "TESTING_GUIDE.md", + "AGENT_GUIDE.md", + ".codex/**", + ".github/**" + ]; private static readonly List<SearchAuditRecipe> BuiltInRecipes = [ - new( + SourceScopedRecipe( "risky-code", "Reusable audit searches for risky code patterns that often need manual triage.", [ @@ -145,32 +161,153 @@ internal static class SearchAuditRecipes ExactSubstring: false), new( "token-term", + "auth token", + "Find auth-token contexts without the broad parser, syntax, LSP, and cancellation-token noise from the bare token term.", + ["audit", "security"], + "False positives include documentation and tests; use the broad-token-audit recipe or an ad hoc `token` search when you intentionally need lexical-token coverage.", + ExactSubstring: false) + ]), + SourceScopedRecipe( + "json-parse-apis", + "Audit JSON parse and deserialize API families that may need payload bounds, streaming, or serializer-option review.", + [ + new( + "json-document-parse", + "JsonDocument.Parse", + "Find DOM parsing via JsonDocument.Parse that may need input-size limits or streaming alternatives.", + ["audit", "bug"], + "False positives include deliberately bounded callers and parsing of already-small generated payloads."), + new( + "json-node-parse", + "JsonNode.Parse", + "Find mutable DOM parsing via JsonNode.Parse that may need input-size limits, depth limits, or streaming alternatives.", + ["audit", "bug"], + "False positives include tests, bounded configuration files, and already-size-limited payloads."), + new( + "json-serializer-deserialize", + "JsonSerializer.Deserialize", + "Find serializer materialization paths that may need payload bounds, streaming, or explicit JsonSerializerOptions review.", + ["audit", "bug"], + "False positives include bounded local files, test fixtures, and deserialization of tiny protocol envelopes."), + new( + "json-async-deserialize", + "DeserializeAsyncEnumerable", + "Find streaming JSON deserialization paths that may need cancellation, item limits, or backpressure review.", + ["audit", "performance"], + "False positives include already-cancelable readers with explicit item budgets.") + ]), + SourceScopedRecipe( + "dotnet-risk-patterns", + "Audit common .NET reliability and security patterns that regularly need manual review.", + [ + new( + "sqlite-addwithvalue", + "AddWithValue", + "Find SQLite parameter binding that may need explicit DbType or size review instead of AddWithValue inference.", + ["audit", "bug"], + "False positives include test-only SQL snippets and values whose inferred SQLite type is intentionally unconstrained."), + new( + "regex-construction", + "new Regex", + "Find direct regex construction that may need a timeout, non-backtracking mode, or bounded input review.", + ["audit", "performance"], + "False positives include precompiled bounded patterns with explicit timeouts or tiny trusted inputs."), + new( + "cancellation-token-none", + "CancellationToken.None", + "Find production paths that ignore caller cancellation and may need a propagated token.", + ["audit", "bug"], + "False positives include intentionally detached background work and APIs without a meaningful caller token."), + new( + "sync-over-async", + "GetAwaiter().GetResult", + "Find sync-over-async waits that may deadlock or hide cancellation and timeout behavior.", + ["audit", "bug"], + "False positives include process-exit boundaries and test helpers that intentionally bridge sync APIs.") + ]), + SourceScopedRecipe( + "xml-parser-security", + "Audit XML parser APIs and DTD/entity settings for XXE and external-resolution regressions.", + [ + new( + "xml-reader-settings", + "XmlReaderSettings", + "Find XML reader settings that should keep DtdProcessing disabled or ignored and avoid external entity resolution.", + ["audit", "security"], + "Expected safe settings include `DtdProcessing.Ignore` or `Prohibit` and no external resolver; tests and safe fixture parsers may be false positives."), + new( + "dtd-processing", + "DtdProcessing", + "Find DTD handling changes that may re-enable entity expansion or unsafe external document access.", + ["audit", "security"], + "Review for `Ignore` or `Prohibit`; `Parse` requires strong justification, bounded input, and resolver controls."), + new( + "xml-resolver", + "XmlResolver", + "Find XML resolver configuration that may allow network or filesystem entity resolution.", + ["audit", "security"], + "Safe paths usually set the resolver to null or use a tightly bounded resolver.") + ]), + SourceScopedRecipe( + "filesystem-traversal", + "Audit directory traversal and enumeration APIs for cancellation, budget, long-path, and exception-taxonomy behavior.", + [ + new( + "enumerate-files", + "Directory.EnumerateFiles", + "Find lazy file enumeration paths that may need cancellation checks, traversal budgets, long-path handling, and permission error taxonomy.", + ["audit", "performance", "security"], + "False positives include tiny fixed directories and traversal already bounded by project-root containment."), + new( + "enumerate-directories", + "Directory.EnumerateDirectories", + "Find directory enumeration paths that may need depth limits, cancellation, symlink/reparse handling, and permission recovery.", + ["audit", "performance", "security"], + "False positives include shallow temp fixture setup and already-budgeted traversal helpers."), + new( + "enumerate-file-system-entries", + "Directory.EnumerateFileSystemEntries", + "Find broad filesystem entry traversal that may need explicit exception handling and pruning policy.", + ["audit", "performance", "security"], + "False positives include isolated test cleanup and known-small directories."), + new( + "enumeration-options", + "EnumerationOptions", + "Find traversal option configuration for recurse behavior, inaccessible paths, attributes, and reparse-point policy.", + ["audit", "performance", "security"], + "Review option combinations against cancellation, budget, long-path, and permission behavior.") + ]), + AllScopedRecipe( + "broad-token-audit", + "Opt-in broad token search for audits that intentionally need lexical, parser, LSP, cancellation, and auth-token coverage.", + [ + new( + "token-term-broad", "token", - "Find token-related code paths that may need lexical-token versus auth-token triage.", + "Find every token mention when a broad token audit is explicitly requested.", ["audit", "security"], - "False positives include parser/tokenizer code, syntax tokens, and non-auth identifiers.", + "This intentionally includes parser/tokenizer code, syntax tokens, LSP tokens, cancellation tokens, docs, and tests.", ExactSubstring: false) ]) - { - DefaultPathPatterns = ["src/**"], - DefaultExcludePaths = - [ - "src/CodeIndex/Cli/SearchAuditRecipes.cs", - "tests/**", - "docs/**", - "CHANGELOG.md", - "changelog.d/**", - "README.md", - "USER_GUIDE.md", - "DEVELOPER_GUIDE.md", - "TESTING_GUIDE.md", - "AGENT_GUIDE.md", - ".codex/**", - ".github/**" - ] - } ]; + private static SearchAuditRecipe SourceScopedRecipe( + string name, + string description, + List<SearchAuditRecipeQuery> queries) => new(name, description, queries) + { + DefaultPathPatterns = [.. DefaultSourcePathPatterns], + DefaultExcludePaths = [.. DefaultSourceExcludePaths], + }; + + private static SearchAuditRecipe AllScopedRecipe( + string name, + string description, + List<SearchAuditRecipeQuery> queries) => new(name, description, queries) + { + DefaultScope = AllAuditScope, + }; + internal static IReadOnlyList<SearchAuditRecipe> All => Load().Recipes; internal static SearchAuditRecipeRegistry Load() @@ -561,6 +698,9 @@ internal sealed record SearchNamedBatchQueryResultJsonResult( [property: JsonPropertyName("query")] string Query, [property: JsonPropertyName("exact_substring")] bool ExactSubstring, [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("top_files")] List<SearchRecipeTopFileJsonResult> TopFiles, + [property: JsonPropertyName("truncated")] bool Truncated, + [property: JsonPropertyName("next_cursor")] string? NextCursor, [property: JsonPropertyName("results")] List<CompactSearchResult> Results); internal sealed record SearchRecipeQueryResultJsonResult( @@ -571,6 +711,8 @@ internal sealed record SearchRecipeQueryResultJsonResult( [property: JsonPropertyName("false_positive_guidance")] string FalsePositiveGuidance, [property: JsonPropertyName("exact_substring")] bool ExactSubstring, [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("top_files")] List<SearchRecipeTopFileJsonResult> TopFiles, + [property: JsonPropertyName("truncated")] bool Truncated, [property: JsonPropertyName("next_cursor")] string? NextCursor, [property: JsonPropertyName("results")] List<CompactSearchResult> Results); @@ -588,6 +730,7 @@ internal sealed record SearchRecipeCompactQueryResultJsonResult( [property: JsonPropertyName("description")] string Description, [property: JsonPropertyName("count")] int Count, [property: JsonPropertyName("top_files")] List<SearchRecipeTopFileJsonResult> TopFiles, + [property: JsonPropertyName("truncated")] bool Truncated, [property: JsonPropertyName("next_cursor")] string? NextCursor, [property: JsonPropertyName("results")] List<SearchRecipeCompactResultJsonResult> Results); @@ -639,4 +782,13 @@ internal sealed record SearchRecipeScopeJsonResult( [property: JsonPropertyName("exclude_paths")] List<string> ExcludePaths, [property: JsonPropertyName("exclude_tests")] bool ExcludeTests, [property: JsonPropertyName("recipe_default_path_patterns")] List<string> RecipeDefaultPathPatterns, - [property: JsonPropertyName("recipe_default_exclude_paths")] List<string> RecipeDefaultExcludePaths); + [property: JsonPropertyName("recipe_default_exclude_paths")] List<string> RecipeDefaultExcludePaths, + [property: JsonPropertyName("excluded_diagnostics")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + List<SearchRecipeExcludedDiagnosticJsonResult>? ExcludedDiagnostics); + +internal sealed record SearchRecipeExcludedDiagnosticJsonResult( + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("applied")] bool Applied, + [property: JsonPropertyName("patterns")] List<string> Patterns, + [property: JsonPropertyName("description")] string Description); From e8dc95b40d3ac93f7c1ea555e71ea5c845825c90 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Sat, 20 Jun 2026 01:10:03 +0900 Subject: [PATCH 2/4] Complete search audit recipe coverage (#3787, #3714, #3731, #3710, #3694, #3693, #3670, #3696) --- changelog.d/unreleased/3787-3714.added.md | 31 ++ src/CodeIndex/Database/DbSearchReader.cs | 8 + src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 2 +- src/CodeIndex/Mcp/McpToolDefinitions.cs | 1 + src/CodeIndex/Mcp/McpToolHandlers.cs | 76 ++++- tests/CodeIndex.Tests/ConsoleUiTests.cs | 4 +- tests/CodeIndex.Tests/McpServerTests.cs | 53 +++ .../QueryCommandRunnerSearchTests.cs | 314 +++++++++++++++++- 8 files changed, 481 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/3787-3714.added.md diff --git a/changelog.d/unreleased/3787-3714.added.md b/changelog.d/unreleased/3787-3714.added.md new file mode 100644 index 0000000000..38238c4741 --- /dev/null +++ b/changelog.d/unreleased/3787-3714.added.md @@ -0,0 +1,31 @@ +--- +category: added +issues: + - 3787 + - 3714 + - 3731 + - 3710 + - 3694 + - 3693 + - 3670 + - 3696 +affected: + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Database/DbSearchReader.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolArgumentContracts.cs + - USER_GUIDE.md +--- + +## English + +- **Search audit recipes now cover focused .NET, JSON, XML, filesystem, and token-review audits (#3787, #3714, #3731, #3710, #3694, #3693, #3670, #3696)** — `cdidx search --recipe` and MCP recipe runs add grouped API-family recipes, honor the same default source scope, narrow the default token audit, keep a broad token fallback, report bounded per-query top-file/truncation metadata, preserve recipe pagination across deduplicated overlap rows, and can include opt-in exclusion diagnostics with `--show-excluded`. + +## 日本語 + +- **検索監査レシピが .NET、JSON、XML、ファイルシステム、トークン確認の焦点を絞った監査を扱うようになりました (#3787, #3714, #3731, #3710, #3694, #3693, #3670, #3696)** — `cdidx search --recipe` と MCP のレシピ実行は API ファミリーごとにまとめたレシピを追加し、同じ既定のソース範囲を適用し、既定のトークン監査を絞り込み、広いトークン予備検索を維持し、クエリごとの上限付き `top_files` / `truncated` メタデータを返し、重複排除された重なり行をまたいだレシピのページネーションを維持し、`--show-excluded` で任意の除外診断を含められるようになりました。 diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index 0c33d2172c..8239e3c2d5 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -1218,13 +1218,21 @@ private static List<SearchResult> DeduplicateOverlappingResults(List<SearchResul var keptMatchLines = new Dictionary<string, HashSet<int>>(StringComparer.Ordinal); var keptIntervals = new Dictionary<string, IntervalSet>(StringComparer.Ordinal); var deduped = new List<SearchResult>(); + SearchResult? previousKept = null; + var lastRawNextOffset = 0; foreach (var r in results) { + lastRawNextOffset = r.NextOffset; if (!AddSearchResultDedupCoverage(r, matchContext, keptMatchLines, keptIntervals)) continue; + if (previousKept != null) + previousKept.NextOffset = Math.Max(previousKept.NextOffset, r.NextOffset - 1); deduped.Add(r); + previousKept = r; } + if (previousKept != null) + previousKept.NextOffset = Math.Max(previousKept.NextOffset, lastRawNextOffset); return deduped; } diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs index 906bb314bb..2f2f988952 100644 --- a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs +++ b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs @@ -14,7 +14,7 @@ public partial class McpServer private static IReadOnlySet<string> GetAllowedToolArguments(string toolName) => toolName switch { - "search" => new HashSet<string>(StringComparer.Ordinal) { "query", "recipe", "listRecipes", "limit", "lang", "snippetLines", "snippetFocus", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "countOnly", "format", "project", "solution" }, + "search" => new HashSet<string>(StringComparer.Ordinal) { "query", "recipe", "listRecipes", "auditScope", "limit", "lang", "snippetLines", "snippetFocus", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "countOnly", "format", "project", "solution" }, "definition" => new HashSet<string>(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "visibility", "excludeVisibility", "includeBody", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, "references" => new HashSet<string>(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "lspCompatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, "callers" or "callees" => new HashSet<string>(StringComparer.Ordinal) { "query", "kind", "rawKinds", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 2105d47983..585ee31462 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -29,6 +29,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["query"] = new JsonObject { ["type"] = "string", ["description"] = "Search query text. Append `*` to a token to make that token a prefix phrase (`計算*` matches `計算する`)." }, ["recipe"] = new JsonObject { ["type"] = "string", ["description"] = "Run a named search audit recipe instead of a single query. Use `listRecipes:true` to discover available recipe names." }, ["listRecipes"] = new JsonObject { ["type"] = "boolean", ["description"] = "List built-in and configured search audit recipes without running a search.", ["default"] = false }, + ["auditScope"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "source", "all" }, ["description"] = "Recipe runs only: source applies the recipe's production-code default path/exclusion scope; all searches every indexed path unless other filters exclude it." }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 20). Responses include `truncated` and `more_available` when more rows exist.", ["default"] = QueryCommandRunner.DefaultQueryLimit }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language (e.g. csharp, python, javascript)" }, ["snippetLines"] = new JsonObject { ["type"] = "integer", ["description"] = "Max snippet lines per result (default: 8, max: 20)", ["default"] = 8, ["minimum"] = 1, ["maximum"] = SearchSnippetFormatter.MaxSnippetLines }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 8e7276b9b0..d31ee62a10 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -874,7 +874,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or "solution" or "symbol" or "groupBy" or "category" or "language" or "severity" or "explain" or "snippetFocus" or "bucket" or "minConfidence" or "extension" or "alias" or "description" or "context" or "toolInvocationContext" or "db" or - "followSymlinks" or "recipe" => "string", + "followSymlinks" or "recipe" or "auditScope" => "string", "minEntrypointConfidence" => "number", "queries" or "evidencePaths" or "evidence_paths" => "array", _ => string.Empty, @@ -1092,8 +1092,25 @@ private JsonArray ToJsonArray<TSource, TResult>(IEnumerable<TSource> items, Func return array; } + private const int SearchEnvelopeMinCandidates = 200; + private const int SearchEnvelopeOverFetchFactor = 50; + private const int SearchEnvelopeMaxCandidates = 10_000; + private static int FetchLimitForEnvelope(int limit) => limit >= int.MaxValue ? int.MaxValue : limit + 1; + private static int FetchLimitForSearchRecipeEnvelope(int limit) + { + if (limit >= int.MaxValue) + return int.MaxValue; + if (limit <= 0) + return 1; + + var requested = (long)limit + 1; + var overFetched = requested * SearchEnvelopeOverFetchFactor; + var candidateLimit = Math.Max(SearchEnvelopeMinCandidates, Math.Max(requested, overFetched)); + return (int)Math.Min(SearchEnvelopeMaxCandidates, Math.Min(int.MaxValue, candidateLimit)); + } + private static bool TrimToRequestedLimit<T>(List<T> results, int limit) { if (results.Count <= limit) @@ -1674,6 +1691,9 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) return ExecuteSearchRecipe(id, args, recipeName.Trim()); } + if (args?["auditScope"] is not null) + return CreateToolErrorResponse(id, "'auditScope' is only supported with recipe execution."); + if (!TryReadRequiredStringParameter(args, "query", out var query, out var requiredError)) return CreateToolErrorResponse(id, requiredError!); if (query.Length > QueryLimits.MaxQueryLength) @@ -1871,6 +1891,8 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue<bool>() ?? false; + if (!TryResolveMcpRecipeAuditScope(args, recipe, ref pathPatterns, excludePaths, ref excludeTests, out var auditScope, out var auditScopeError)) + return CreateToolErrorResponse(id, auditScopeError!); if (!TryReadSinceArgument(args, out var since, out var sinceError)) return CreateToolErrorResponse(id, sinceError!); var deduplicate = !(args?["noDedup"]?.GetValue<bool>() ?? false); @@ -1899,7 +1921,7 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe { results = reader.Search( recipeQuery.Query, - limit, + FetchLimitForSearchRecipeEnvelope(limit), lang, false, pathPatterns, @@ -1925,6 +1947,7 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe var compactResults = SearchSnippetFormatter .ToCompactResults(results, queryContext, snippetLines, exact, maxLineWidth, exposeLiteralHighlights: exact) .ToList(); + var truncated = TrimToRequestedLimit(compactResults, limit); foreach (var compact in compactResults) SearchSnippetFormatter.ApplyOutputMetadata(compact, snippetLines, maxLineWidth, exact, rawFts: false); total += compactResults.Count; @@ -1937,6 +1960,8 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe ["false_positive_guidance"] = recipeQuery.FalsePositiveGuidance, ["exact_substring"] = exact, ["count"] = compactResults.Count, + ["top_files"] = BuildTopFileHistogram(compactResults, result => result.Path), + ["truncated"] = truncated, ["results"] = ToJsonArray(compactResults) }); } @@ -1950,7 +1975,9 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe ["snippetLines"] = snippetLines, ["maxLineWidth"] = maxLineWidth, ["lang"] = lang, + ["audit_scope"] = auditScope, ["path"] = PathEcho(pathPatterns), + ["excludePaths"] = PathEcho(excludePaths), ["excludeTests"] = excludeTests, ["queries"] = queryResults }; @@ -1964,6 +1991,48 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe }); } + private static bool TryResolveMcpRecipeAuditScope( + JsonNode? args, + SearchAuditRecipe recipe, + ref List<string>? pathPatterns, + List<string> excludePaths, + ref bool excludeTests, + out string auditScope, + out string? error) + { + var requestedScope = args?["auditScope"]?.GetValue<string>(); + auditScope = string.IsNullOrWhiteSpace(requestedScope) + ? recipe.DefaultScope + : requestedScope.Trim(); + error = null; + + if (!string.Equals(auditScope, SearchAuditRecipes.DefaultAuditScope, StringComparison.Ordinal) + && !string.Equals(auditScope, SearchAuditRecipes.AllAuditScope, StringComparison.Ordinal)) + { + error = "'auditScope' must be either 'source' or 'all'."; + return false; + } + + if (string.Equals(auditScope, SearchAuditRecipes.DefaultAuditScope, StringComparison.Ordinal)) + { + if ((pathPatterns is null || pathPatterns.Count == 0) && recipe.DefaultPathPatterns.Count > 0) + pathPatterns = [.. recipe.DefaultPathPatterns]; + AddDistinct(excludePaths, recipe.DefaultExcludePaths); + excludeTests = true; + } + + return true; + } + + private static void AddDistinct(List<string> target, IEnumerable<string> values) + { + foreach (var value in values) + { + if (!target.Contains(value, StringComparer.Ordinal)) + target.Add(value); + } + } + private JsonArray ToSearchRecipeArray(IEnumerable<SearchAuditRecipe> recipes) => new(recipes.Select(recipe => ToSearchRecipeJson(recipe)).ToArray<JsonNode?>()); @@ -1973,6 +2042,9 @@ private JsonObject ToSearchRecipeJson(SearchAuditRecipe recipe) ["name"] = recipe.Name, ["description"] = recipe.Description, ["recommended_labels"] = ToJsonArray(recipe.RecommendedLabels), + ["default_scope"] = recipe.DefaultScope, + ["default_path_patterns"] = ToJsonArray(recipe.DefaultPathPatterns), + ["default_exclude_paths"] = ToJsonArray(recipe.DefaultExcludePaths), ["queries"] = new JsonArray(recipe.Queries.Select(query => new JsonObject { ["name"] = query.Name, diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 6be009d244..e6fa58acbf 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|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--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>|--max-results <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--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|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--show-excluded] [--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>|--max-results <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--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] [--format <text|json|compact>] [--pretty] [--compact] [--fields <csv>] [--body-only] [--verbose] [--limit <n>|--top <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--body] [--body-start <line>] [--body-lines <n>] [--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|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--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>|--max-results <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--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|--named-query <name>=<query> [--named-query <name>=<query> ...] [--include-query <name>] [--exclude-query <name>] [--cursor <cursor>] [--audit-scope <source|all>] [--show-excluded] [--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>|--max-results <n>] [--lang <lang>] [--path <glob>] [--exclude-path <glob>] [--exclude-tests] [--exclude-comments] [--exclude-strings] [--exclude-fixtures] [--snippet-lines <n>] [--snippet-focus <leftmost|quality|proximity>] [--max-line-width <n>] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--group-by <file|symbol>] [--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] [--format <text|json|count>] [--verbose] [--limit <n>|--top <n>] [--sort <hotspot|references|size|complexity|path>] [--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/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 0e043c3daf..0460d7363e 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -4884,6 +4884,9 @@ public void ToolsCall_Search_ListRecipesReturnsBuiltIns_Issue3545() Assert.True(structured["count"]!.GetValue<int>() >= 1); var recipes = structured["recipes"]!.AsArray(); var risky = recipes.Single(recipe => recipe!["name"]!.GetValue<string>() == "risky-code")!; + Assert.Equal("source", risky["default_scope"]!.GetValue<string>()); + Assert.Contains(risky["default_path_patterns"]!.AsArray(), path => path!.GetValue<string>() == "src/**"); + Assert.Contains(risky["default_exclude_paths"]!.AsArray(), path => path!.GetValue<string>() == "tests/**"); Assert.Contains(risky["queries"]!.AsArray(), query => query!["name"]!.GetValue<string>() == "unbounded-json-parse"); } @@ -4906,6 +4909,56 @@ public void ToolsCall_Search_RunRecipeReturnsGroupedResults_Issue3545() Assert.Equal("src/json.cs", jsonParseQuery["results"]![0]!["path"]!.GetValue<string>()); } + [Fact] + public void ToolsCall_Search_RunRecipeAppliesDefaultSourceScopeAndAuditScopeAll_Issue3714() + { + using var env = EnvironmentVariableScope.Capture("CDIDX_SEARCH_RECIPE_PATHS"); + env.Set("CDIDX_SEARCH_RECIPE_PATHS", null); + InsertIndexedFile("src/json.cs", "csharp", "var doc = JsonDocument.Parse(payload);\n"); + InsertIndexedFile("src/json-extra.cs", "csharp", "var doc = JsonDocument.Parse(otherPayload);\n"); + InsertIndexedFile("docs/json.md", "markdown", "Document JsonDocument.Parse usage.\n"); + InsertIndexedFile("tests/JsonTests.cs", "csharp", "var doc = JsonDocument.Parse(payload);\n"); + + var sourceRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"recipe":"json-parse-apis","limit":1}}}""")!; + var sourceResponse = _server.HandleMessage(sourceRequest)!; + + Assert.Null(sourceResponse["error"]); + var sourceStructured = sourceResponse["result"]!["structuredContent"]!; + var sourceQuery = sourceStructured["queries"]!.AsArray() + .Single(query => query!["name"]!.GetValue<string>() == "json-document-parse")!; + var sourcePaths = sourceQuery["results"]!.AsArray() + .Select(result => result!["path"]!.GetValue<string>()) + .ToList(); + + Assert.Equal("source", sourceStructured["audit_scope"]!.GetValue<string>()); + Assert.Equal("src/**", sourceStructured["path"]!.GetValue<string>()); + Assert.Contains(sourceStructured["excludePaths"]!.AsArray(), path => path!.GetValue<string>() == "tests/**"); + Assert.True(sourceStructured["excludeTests"]!.GetValue<bool>()); + var sourcePath = Assert.Single(sourcePaths); + Assert.StartsWith("src/json", sourcePath, StringComparison.Ordinal); + Assert.Equal(1, sourceQuery["count"]!.GetValue<int>()); + Assert.True(sourceQuery["truncated"]!.GetValue<bool>()); + Assert.Equal(sourcePath, sourceQuery["top_files"]![0]!["path"]!.GetValue<string>()); + + var allRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{"recipe":"json-parse-apis","auditScope":"all","limit":10}}}""")!; + var allResponse = _server.HandleMessage(allRequest)!; + + Assert.Null(allResponse["error"]); + var allStructured = allResponse["result"]!["structuredContent"]!; + var allQuery = allStructured["queries"]!.AsArray() + .Single(query => query!["name"]!.GetValue<string>() == "json-document-parse")!; + var allPaths = allQuery["results"]!.AsArray() + .Select(result => result!["path"]!.GetValue<string>()) + .ToList(); + + Assert.Equal("all", allStructured["audit_scope"]!.GetValue<string>()); + Assert.Null(allStructured["path"]); + Assert.False(allStructured["excludeTests"]!.GetValue<bool>()); + Assert.Contains("src/json.cs", allPaths); + Assert.Contains("docs/json.md", allPaths); + Assert.Contains("tests/JsonTests.cs", allPaths); + } + [Fact] public void ToolsCall_Search_ListRecipesIncludesConfiguredSources_Issue3545() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index a829d6f95f..49664ca14e 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -562,6 +562,11 @@ public void RunSearch_NamedQueriesReturnGroupedCompactResults_Issue3481() "release/pack.md", "markdown", "Run dotnet pack before publishing."); + TestProjectHelper.InsertIndexedFile( + dbPath, + "release/pack-extra.md", + "markdown", + "Run dotnet pack after signing."); TestProjectHelper.InsertIndexedFile( dbPath, "release/push.md", @@ -569,7 +574,7 @@ public void RunSearch_NamedQueriesReturnGroupedCompactResults_Issue3481() "Run nuget push after package validation."); var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - ["--named-query=pack=dotnet pack", "--named-query=push=nuget push", "--db", dbPath, "--format", "compact"], + ["--named-query=pack=dotnet pack", "--named-query=push=nuget push", "--db", dbPath, "--format", "compact", "--limit", "1"], _jsonOptions)); Assert.Equal(CommandExitCodes.Success, exitCode); @@ -582,7 +587,12 @@ public void RunSearch_NamedQueriesReturnGroupedCompactResults_Issue3481() var pack = Assert.Single(queries, query => query.GetProperty("name").GetString() == "pack"); Assert.Equal("dotnet pack", pack.GetProperty("query").GetString()); var packResult = Assert.Single(pack.GetProperty("results").EnumerateArray()); - Assert.Equal("release/pack.md", packResult.GetProperty("path").GetString()); + var packPath = packResult.GetProperty("path").GetString(); + Assert.NotNull(packPath); + Assert.True(pack.GetProperty("truncated").GetBoolean()); + Assert.Equal(JsonValueKind.Null, pack.GetProperty("next_cursor").ValueKind); + Assert.Equal(packPath, pack.GetProperty("top_files")[0].GetProperty("path").GetString()); + Assert.StartsWith("release/pack", packPath, StringComparison.Ordinal); Assert.Contains("dotnet pack", packResult.GetProperty("snippet").GetString(), StringComparison.Ordinal); Assert.NotEmpty(packResult.GetProperty("match_lines").EnumerateArray()); } @@ -1033,12 +1043,36 @@ public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() .GetProperty("recipes") .EnumerateArray() .Single(item => item.GetProperty("name").GetString() == "risky-code"); + var jsonRecipe = root + .GetProperty("recipes") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "json-parse-apis"); + var dotnetRecipe = root + .GetProperty("recipes") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "dotnet-risk-patterns"); + var xmlRecipe = root + .GetProperty("recipes") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "xml-parser-security"); + var traversalRecipe = root + .GetProperty("recipes") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "filesystem-traversal"); + var broadTokenRecipe = root + .GetProperty("recipes") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "broad-token-audit"); var query = recipe .GetProperty("queries") .EnumerateArray() .Single(item => item.GetProperty("name").GetString() == "raw-diagnostic-echo"); + var tokenQuery = recipe + .GetProperty("queries") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "token-term"); - Assert.Equal(1, root.GetProperty("count").GetInt32()); + Assert.True(root.GetProperty("count").GetInt32() >= 6); Assert.Contains(recipe.GetProperty("recommended_labels").EnumerateArray(), label => label.GetString() == "audit"); Assert.Equal("source", recipe.GetProperty("default_scope").GetString()); Assert.Contains(recipe.GetProperty("default_path_patterns").EnumerateArray(), path => path.GetString() == "src/**"); @@ -1052,6 +1086,19 @@ public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() 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); + Assert.Equal("auth token", tokenQuery.GetProperty("query").GetString()); + Assert.Contains("broad-token-audit", tokenQuery.GetProperty("false_positive_guidance").GetString(), StringComparison.Ordinal); + Assert.Contains(jsonRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "json-node-parse"); + Assert.Contains(jsonRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "json-serializer-deserialize"); + Assert.Contains(jsonRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "json-async-deserialize"); + Assert.Contains(dotnetRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "sqlite-addwithvalue"); + Assert.Contains(dotnetRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "sync-over-async"); + Assert.Contains(xmlRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "dtd-processing"); + Assert.Contains(traversalRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "enumerate-files"); + Assert.Equal("all", broadTokenRecipe.GetProperty("default_scope").GetString()); + Assert.Equal(0, broadTokenRecipe.GetProperty("default_path_patterns").GetArrayLength()); + Assert.Equal(0, broadTokenRecipe.GetProperty("default_exclude_paths").GetArrayLength()); + Assert.Contains(broadTokenRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "token-term-broad"); } [Theory] @@ -1136,6 +1183,167 @@ public void Run(Exception ex, CancellationToken token) } } + [Fact] + public void RunSearch_JsonParseRecipeGroupsApiFamilies_Issues3710_3714() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_json_parse_recipe"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/json.cs", + "csharp", + """ + using System.Text.Json; + using System.Text.Json.Nodes; + + public sealed class JsonAudit + { + public void Run(string text) + { + JsonNode.Parse(text); + JsonSerializer.Deserialize<object>(text); + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "json-parse-apis", "--db", dbPath, "--json", "--limit", "5"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + var queries = root.GetProperty("queries").EnumerateArray().ToList(); + var jsonNode = queries.Single(item => item.GetProperty("name").GetString() == "json-node-parse"); + var serializer = queries.Single(item => item.GetProperty("name").GetString() == "json-serializer-deserialize"); + + Assert.Equal("json-parse-apis", root.GetProperty("recipe").GetProperty("name").GetString()); + Assert.Equal(4, root.GetProperty("query_count").GetInt32()); + Assert.Equal(1, jsonNode.GetProperty("count").GetInt32()); + Assert.Equal("JsonNode.Parse", jsonNode.GetProperty("query").GetString()); + Assert.Equal("src/json.cs", jsonNode.GetProperty("top_files")[0].GetProperty("path").GetString()); + Assert.False(jsonNode.GetProperty("truncated").GetBoolean()); + Assert.Equal(1, serializer.GetProperty("count").GetInt32()); + Assert.Equal("JsonSerializer.Deserialize", serializer.GetProperty("query").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_FocusedAuditRecipesFindDotnetXmlAndFilesystemApis_Issues3731_3694_3693() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_focused_audit_recipes"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/audit.cs", + "csharp", + """ + using System.Data.Common; + using System.IO; + using System.Xml; + + public sealed class AuditPatterns + { + public void Run(DbCommand command) + { + command.Parameters.AddWithValue("@id", 1); + var settings = new XmlReaderSettings { DtdProcessing = DtdProcessing.Ignore }; + foreach (var path in Directory.EnumerateFiles("src")) { } + } + } + """); + + var dotnet = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "dotnet-risk-patterns", "--db", dbPath, "--json", "--limit", "5"], + _jsonOptions)); + var xml = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "xml-parser-security", "--db", dbPath, "--json", "--limit", "5"], + _jsonOptions)); + var filesystem = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "filesystem-traversal", "--db", dbPath, "--json", "--limit", "5"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, dotnet.Result); + Assert.Equal(CommandExitCodes.Success, xml.Result); + Assert.Equal(CommandExitCodes.Success, filesystem.Result); + Assert.Equal(string.Empty, dotnet.Stderr); + Assert.Equal(string.Empty, xml.Stderr); + Assert.Equal(string.Empty, filesystem.Stderr); + + using var dotnetDocument = ParseJsonOutput(dotnet.Stdout); + using var xmlDocument = ParseJsonOutput(xml.Stdout); + using var filesystemDocument = ParseJsonOutput(filesystem.Stdout); + + AssertRecipeQueryHit(dotnetDocument.RootElement, "sqlite-addwithvalue", "AddWithValue"); + AssertRecipeQueryHit(xmlDocument.RootElement, "dtd-processing", "DtdProcessing"); + AssertRecipeQueryHit(filesystemDocument.RootElement, "enumerate-files", "Directory.EnumerateFiles"); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + + static void AssertRecipeQueryHit(JsonElement root, string queryName, string queryText) + { + var query = root + .GetProperty("queries") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == queryName); + + Assert.Equal(queryText, query.GetProperty("query").GetString()); + Assert.Equal(1, query.GetProperty("count").GetInt32()); + Assert.Equal("src/audit.cs", query.GetProperty("results")[0].GetProperty("path").GetString()); + } + } + + [Fact] + public void RunSearch_BroadTokenRecipeDefaultsToAllScope_Issue3670() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_broad_token_all_scope"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/parser.cs", "csharp", "public sealed class Parser { string token = \"src\"; }"); + TestProjectHelper.InsertIndexedFile(dbPath, "docs/token.md", "markdown", "Document token review notes."); + TestProjectHelper.InsertIndexedFile(dbPath, "tests/TokenTests.cs", "csharp", "public sealed class TokenTests { string token = \"test\"; }"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "broad-token-audit", "--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 paths = root + .GetProperty("queries") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "token-term-broad") + .GetProperty("results") + .EnumerateArray() + .Select(item => item.GetProperty("path").GetString()) + .ToList(); + + Assert.Equal("all", root.GetProperty("scope").GetProperty("name").GetString()); + Assert.Contains("src/parser.cs", paths); + Assert.Contains("docs/token.md", paths); + Assert.Contains("tests/TokenTests.cs", paths); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_RecipeSourceScopeSuppressesDefinitionsDocsChangelogAndTests_Issues3440_3448() { @@ -1162,6 +1370,9 @@ public void RunSearch_RecipeSourceScopeSuppressesDefinitionsDocsChangelogAndTest var sourceScope = CaptureConsole(() => QueryCommandRunner.RunSearch( ["--recipe", "risky-code", "--db", dbPath, "--json", "--limit", "10"], _jsonOptions)); + var diagnosticScope = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code", "--db", dbPath, "--json", "--limit", "10", "--show-excluded"], + _jsonOptions)); var allScope = CaptureConsole(() => QueryCommandRunner.RunSearch( ["--recipe", "risky-code", "--db", dbPath, "--json", "--limit", "10", "--audit-scope", "all"], _jsonOptions)); @@ -1177,6 +1388,19 @@ public void RunSearch_RecipeSourceScopeSuppressesDefinitionsDocsChangelogAndTest Assert.Equal("source", sourceDocument.RootElement.GetProperty("scope").GetProperty("name").GetString()); Assert.Equal("src/app.cs", sourceResult.GetProperty("path").GetString()); + Assert.False(sourceDocument.RootElement.GetProperty("scope").TryGetProperty("excluded_diagnostics", out _)); + + Assert.Equal(CommandExitCodes.Success, diagnosticScope.Result); + Assert.Equal(string.Empty, diagnosticScope.Stderr); + using var diagnosticDocument = ParseJsonOutput(diagnosticScope.Stdout); + var diagnostics = diagnosticDocument.RootElement + .GetProperty("scope") + .GetProperty("excluded_diagnostics") + .EnumerateArray() + .ToList(); + var defaultExcludes = diagnostics.Single(item => item.GetProperty("reason").GetString() == "recipe_default_exclude_paths"); + Assert.True(defaultExcludes.GetProperty("applied").GetBoolean()); + Assert.Contains(defaultExcludes.GetProperty("patterns").EnumerateArray(), path => path.GetString() == "docs/**"); Assert.Equal(CommandExitCodes.Success, allScope.Result); Assert.Equal(string.Empty, allScope.Stderr); @@ -1408,6 +1632,17 @@ public void Run(Exception ex) Assert.False(firstResult.TryGetProperty("snippet", out _)); Assert.False(string.IsNullOrWhiteSpace(nextCursor)); + var (jsonExitCode, jsonStdout, jsonStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/raw-diagnostic-echo", "--db", dbPath, "--json", "--limit", "1"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, jsonExitCode); + Assert.Equal(string.Empty, jsonStderr); + using var jsonDocument = ParseJsonOutput(jsonStdout); + var jsonQuery = Assert.Single(jsonDocument.RootElement.GetProperty("queries").EnumerateArray()); + Assert.True(jsonQuery.GetProperty("truncated").GetBoolean()); + Assert.False(string.IsNullOrWhiteSpace(jsonQuery.GetProperty("next_cursor").GetString())); + var (secondExitCode, secondStdout, secondStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( ["--recipe", "risky-code/raw-diagnostic-echo", "--db", dbPath, "--format", "compact", "--limit", "1", "--cursor", nextCursor!], _jsonOptions)); @@ -1425,6 +1660,67 @@ public void Run(Exception ex) } } + [Fact] + public void RunSearch_RecipeCursorSkipsDedupedEnvelopeRows_Issue3392() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_cursor_dedup"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/overlap.cs", "csharp", "Console.WriteLine(ex.Message);\n"); + ReplaceChunks( + dbPath, + "src/overlap.cs", + new ChunkRecord { ChunkIndex = 0, StartLine = 1, EndLine = 20, Content = "Console.WriteLine(ex.Message);\n" }, + new ChunkRecord { ChunkIndex = 1, StartLine = 1, EndLine = 20, Content = "Console.WriteLine(ex.Message);\n" }, + new ChunkRecord { ChunkIndex = 2, StartLine = 40, EndLine = 60, Content = "Console.WriteLine(ex.Message);\n" }); + + 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 firstQuery = Assert.Single(firstDocument.RootElement.GetProperty("queries").EnumerateArray()); + var firstResult = Assert.Single(firstQuery.GetProperty("results").EnumerateArray()); + var nextCursor = firstQuery.GetProperty("next_cursor").GetString(); + + Assert.True(firstQuery.GetProperty("truncated").GetBoolean()); + Assert.Equal(1, firstResult.GetProperty("start_line").GetInt32()); + 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.Equal(40, secondResult.GetProperty("start_line").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + + static void ReplaceChunks(string dbPath, string path, params ChunkRecord[] chunks) + { + using var db = new DbContext(dbPath); + using var cmd = db.Connection.CreateCommand(); + cmd.CommandText = "SELECT id FROM files WHERE path = @path"; + cmd.Parameters.AddWithValue("@path", path); + var fileId = (long)(cmd.ExecuteScalar() ?? throw new InvalidOperationException($"Missing indexed file {path}.")); + var writer = new DbWriter(db.Connection); + writer.DeleteFileData(fileId); + foreach (var chunk in chunks) + chunk.FileId = fileId; + writer.InsertChunks(chunks); + } + } + [Fact] public void RunSearch_RecipeCursorRequiresSingleChildQuery_Issue3392() { @@ -1463,6 +1759,18 @@ public void RunSearch_RecipeRejectsJsonArray_Issue3144() Assert.Contains("--json=array is not supported with --recipe", stderr); } + [Fact] + public void RunSearch_ShowExcludedRequiresRecipe_Issue3696() + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["JsonDocument.Parse", "--show-excluded"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--show-excluded is only supported", stderr); + } + [Fact] public void RunSearch_RecipeIssueDraftsIncludeLabelsEvidenceAndDuplicatePreflight_Issue3145() { From 25a2d00459f8ac7dc99abdf43a6043ff53562945 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Sat, 20 Jun 2026 01:19:37 +0900 Subject: [PATCH 3/4] Fix search audit recipe formatting (#3787, #3714) --- src/CodeIndex/Cli/SearchAuditRecipes.cs | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index cffbc808b6..d896727f5c 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -295,18 +295,18 @@ private static SearchAuditRecipe SourceScopedRecipe( string name, string description, List<SearchAuditRecipeQuery> queries) => new(name, description, queries) - { - DefaultPathPatterns = [.. DefaultSourcePathPatterns], - DefaultExcludePaths = [.. DefaultSourceExcludePaths], - }; + { + DefaultPathPatterns = [.. DefaultSourcePathPatterns], + DefaultExcludePaths = [.. DefaultSourceExcludePaths], + }; private static SearchAuditRecipe AllScopedRecipe( string name, string description, List<SearchAuditRecipeQuery> queries) => new(name, description, queries) - { - DefaultScope = AllAuditScope, - }; + { + DefaultScope = AllAuditScope, + }; internal static IReadOnlyList<SearchAuditRecipe> All => Load().Recipes; From df5beb7a90218ec1efc5715afa89adeb873c39fb Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Sat, 20 Jun 2026 02:04:38 +0900 Subject: [PATCH 4/4] Fix compact recipe cursor test contract (#3787, #3714) --- tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 49664ca14e..0de1e076d5 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1687,7 +1687,7 @@ public void RunSearch_RecipeCursorSkipsDedupedEnvelopeRows_Issue3392() var nextCursor = firstQuery.GetProperty("next_cursor").GetString(); Assert.True(firstQuery.GetProperty("truncated").GetBoolean()); - Assert.Equal(1, firstResult.GetProperty("start_line").GetInt32()); + Assert.Equal(1, firstResult.GetProperty("chunk_start_line").GetInt32()); Assert.False(string.IsNullOrWhiteSpace(nextCursor)); var (secondExitCode, secondStdout, secondStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( @@ -1699,7 +1699,7 @@ public void RunSearch_RecipeCursorSkipsDedupedEnvelopeRows_Issue3392() using var secondDocument = ParseJsonOutput(secondStdout); var secondResult = Assert.Single(Assert.Single(secondDocument.RootElement.GetProperty("queries").EnumerateArray()).GetProperty("results").EnumerateArray()); - Assert.Equal(40, secondResult.GetProperty("start_line").GetInt32()); + Assert.Equal(40, secondResult.GetProperty("chunk_start_line").GetInt32()); } finally {