From db403b4513a56286b83b48b1f6d59ea11c688c95 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 20:45:49 +0900 Subject: [PATCH 1/4] Fix search origin include and exclude filters --- USER_GUIDE.md | 14 ++- changelog.d/unreleased/3680.fixed.md | 19 +++ src/CodeIndex/Cli/CliFlagSchema.cs | 2 + src/CodeIndex/Cli/ConsoleUi.cs | 2 + src/CodeIndex/Cli/QueryCommandRunner.cs | 111 ++++++++++++++---- src/CodeIndex/Cli/SearchAuditRecipes.cs | 3 + .../QueryCommandRunnerSearchTests.cs | 42 +++++++ 7 files changed, 163 insertions(+), 30 deletions(-) create mode 100644 changelog.d/unreleased/3680.fixed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 28708a6008..a259d74529 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -956,7 +956,7 @@ cdidx search --named-query pack="dotnet pack" --named-query push="nuget push" -- cdidx search "catch (Exception" --group-by file --count --json # rank broad audit hits by file cdidx search "JsonDocument.Parse" --group-by symbol --count --json # rank broad audit hits by enclosing symbol cdidx search "catch (Exception" --count-by origin --json # count broad audit hits by match origin -cdidx search "Directory.Delete" --match-origin code --result-kind call_site --json=array # focus on code call sites +cdidx search "Directory.Delete" --origin code --exclude-origin comment --result-kind call_site --json=array # focus on code call sites cdidx search "Authorization" --format grouped --per-file-limit 2 # file-grouped JSON with representative matches cdidx search "throw new Exception" --search-fields path,line,symbol,origin --results-only # projected result-only NDJSON cdidx search "TODO" --first-per-file --sample 25 --json=ndjson --max-json-bytes 65536 # bounded audit sample @@ -1153,7 +1153,7 @@ When `definition --body` is combined with `--json`, `body_content` is capped to `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, and `find` also share repeatable `--path ` glob-style path filters (multiple values are OR'd together), repeatable `--exclude-path `, and `--exclude-tests`. Use `*` and `?` to match path segments, and plain text still behaves like a substring filter when you do not include wildcards. Search results prefer source files over tests and docs, and `search` boosts files whose symbol names or paths match the query exactly. `search --json`, `search --format compact`, named search batches, and MCP `search` return compact match-centered snippets instead of whole chunks. Each result includes `chunk_start_line`, `chunk_end_line`, `snippet_start_line`, `snippet_end_line`, `snippet`, `match_lines`, `highlights`, `context_before`, `context_after`, `truncated_line_count`, `dropped_match_line_count`, and `truncation_context`, plus optional `enclosing_symbol_name`, `enclosing_symbol_kind`, `enclosing_symbol_start_line`, `enclosing_symbol_end_line`, and `enclosing_container_name` when the match line is inside an indexed symbol. Use `--snippet-lines ` to shrink or widen the excerpt window (default: 8, max: 20), and `--max-line-width ` to clamp each line around the strongest match when a minified / transpiled file would otherwise return a single huge line (default: 512, max: 4096; `0` disables clamping). `--snippet-focus ` controls that long-line focus; `quality` is the default, `leftmost` keeps the legacy earliest-match behavior, and `proximity` favors dense multi-token clusters. Clamped lines are marked with `...(+N)...` in the snippet and expose `highlights[].truncated` / `highlights[].original_line_length` in JSON / MCP output. -Search JSON also exposes `match_origins`, `match_facets`, and `result_kinds` so tools can distinguish matches in code, comments, string literals, regex literals, CLI help text, declarations, identifiers, and likely call sites. Each highlight includes its own `match_origins`; `--exclude-comments`, `--exclude-strings`, `--match-origin`, and `--result-kind` use those facets to hide or keep specific match classes. Broad audit output can be reduced with `--unique path|symbol|origin`, `--count-by path|symbol|origin`, `--format grouped`, `--first-per-file`, `--sample `, `--search-fields `, `--results-only`, and `--max-json-bytes `. +Search JSON also exposes `match_origins`, `match_facets`, and `result_kinds` so tools can distinguish matches in code, comments, string literals, regex literals, CLI help text, declarations, identifiers, and likely call sites. Each highlight includes its own `match_origins`; `--exclude-comments`, `--exclude-strings`, `--origin` / `--match-origin`, `--exclude-origin`, and `--result-kind` use those facets to hide or keep specific match classes. Broad audit output can be reduced with `--unique path|symbol|origin`, `--count-by path|symbol|origin`, `--format grouped`, `--first-per-file`, `--sample `, `--search-fields `, `--results-only`, and `--max-json-bytes `. The same facets expose `test_file`, `test_symbol`, and `test_fixture` booleans at result, highlight, and match-facet levels. `test_fixture` marks string-like matches inside likely test files or indexed test methods, and `--exclude-fixtures` hides fixture-only matches while keeping real code matches. ### Resolve a definition @@ -1399,7 +1399,8 @@ same source location. | `--exclude-comments` | `search` | Exclude matches whose only retained origin is a comment | | `--exclude-strings` | `search` | Exclude matches whose only retained origin is a string literal, regex literal, or CLI help text | | `--exclude-fixtures` | `search` | Exclude matches whose only retained facet is a test fixture string | -| `--match-origin ` | `search` | Keep only matches from selected origins such as `code`, `comment`, `string_literal`, `regex_literal`, or `help_text`; repeat or comma-separate values | +| `--origin ` / `--match-origin ` | `search` | Keep only matches from selected origins such as `code`, `comment`, `string_literal`, `regex_literal`, or `help_text`; repeat or comma-separate values | +| `--exclude-origin ` | `search` | Drop matches from selected origins while keeping other origins in the same result; repeat or comma-separate values | | `--result-kind ` | `search` | Keep only projected result kinds such as `call_site`, `declaration`, `identifier`, `comment`, or `string_literal` | | `--unique ` / `--count-by ` | `search` | Emit unique aggregation rows or count aggregation rows for broad audits | | `--format grouped` / `--per-file-limit ` | `search` | Return file-grouped JSON with bounded representative matches per file | @@ -3508,7 +3509,7 @@ cdidx search --named-query pack="dotnet pack" --named-query push="nuget push" -- cdidx search "catch (Exception" --group-by file --count --json # 広い audit hit を file 別にランク付け cdidx search "JsonDocument.Parse" --group-by symbol --count --json # 広い audit hit を enclosing symbol 別にランク付け cdidx search "catch (Exception" --count-by origin --json # 広い audit hit を match origin 別に集計 -cdidx search "Directory.Delete" --match-origin code --result-kind call_site --json=array # コード上の呼び出し候補に絞る +cdidx search "Directory.Delete" --origin code --exclude-origin comment --result-kind call_site --json=array # コード上の呼び出し候補に絞る cdidx search "Authorization" --format grouped --per-file-limit 2 # file grouped JSON と代表 match cdidx search "throw new Exception" --search-fields path,line,symbol,origin --results-only # projection 付き result-only NDJSON cdidx search "TODO" --first-per-file --sample 25 --json=ndjson --max-json-bytes 65536 # 上限付き audit sample @@ -3694,7 +3695,7 @@ function CreateUser src/Services/UserService.cs: `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files` は共通で繰り返し指定できる `--path ` の glob 形式パスフィルタ(複数値は OR で結合)、繰り返し指定できる `--exclude-path `、`--exclude-tests` に対応しています。`*` と `?` でパスパターンを指定でき、ワイルドカードを含めない場合は従来どおり部分文字列として扱われます。検索結果は tests や docs より source を優先し、`search` はシンボル名やパスがクエリと正確に一致するファイルを上に出します。 `search --json`、`search --format compact`、名前付き search batch、MCP の `search` は、チャンク全文ではなく一致中心の軽量スニペットを返します。各結果には `chunk_start_line`、`chunk_end_line`、`snippet_start_line`、`snippet_end_line`、`snippet`、`match_lines`、`highlights`、`context_before`、`context_after`、`truncated_line_count`、`dropped_match_line_count`、`truncation_context` が含まれ、マッチ行がインデックス済みシンボル範囲内にある場合は `enclosing_symbol_name`、`enclosing_symbol_kind`、`enclosing_symbol_start_line`、`enclosing_symbol_end_line`、`enclosing_container_name` も含まれます。抜粋の長さは `--snippet-lines ` で調整でき(デフォルト: 8、最大: 20)、minified / transpiled で 1 行が極端に長いファイルでは `--max-line-width ` を使って各行を最も強い一致周辺へクランプできます(`0` でクランプ解除、デフォルト: 512、最大: 4096)。長い行の焦点は `--snippet-focus ` で制御でき、`quality` がデフォルト、`leftmost` は従来の最左一致、`proximity` は近接した複数トークンを優先します。クランプされた行はスニペット内に `...(+N)...` マーカーが入り、JSON / MCP 出力では `highlights[].truncated` / `highlights[].original_line_length` でも検出できます。 -検索 JSON には `match_origins`、`match_facets`、`result_kinds` も含まれ、コード、コメント、文字列リテラル、正規表現リテラル、CLI ヘルプ文言、宣言、識別子、呼び出し候補のどこで一致したかをツール側で区別できます。各 highlight にも個別の `match_origins` が付き、`--exclude-comments`、`--exclude-strings`、`--match-origin`、`--result-kind` はこの facet を使って特定の一致種別を隠す、または保持します。広い audit 出力は `--unique path|symbol|origin`、`--count-by path|symbol|origin`、`--format grouped`、`--first-per-file`、`--sample `、`--search-fields `、`--results-only`、`--max-json-bytes ` で小さくできます。 +検索 JSON には `match_origins`、`match_facets`、`result_kinds` も含まれ、コード、コメント、文字列リテラル、正規表現リテラル、CLI ヘルプ文言、宣言、識別子、呼び出し候補のどこで一致したかをツール側で区別できます。各 highlight にも個別の `match_origins` が付き、`--exclude-comments`、`--exclude-strings`、`--origin` / `--match-origin`、`--exclude-origin`、`--result-kind` はこの facet を使って特定の一致種別を隠す、または保持します。広い audit 出力は `--unique path|symbol|origin`、`--count-by path|symbol|origin`、`--format grouped`、`--first-per-file`、`--sample `、`--search-fields `、`--results-only`、`--max-json-bytes ` で小さくできます。 同じ facet は result、highlight、match-facet の各レベルで `test_file`、`test_symbol`、`test_fixture` boolean も返します。`test_fixture` はテストらしいファイルまたはインデックス済み test method 内の文字列系一致を示し、`--exclude-fixtures` は実コードの一致を残したまま fixture だけの一致を隠します。 ### 定義を引く @@ -3940,7 +3941,8 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--exclude-comments` | `search` | 保持される一致 origin がコメントだけの検索結果を除外する | | `--exclude-strings` | `search` | 保持される一致 origin が文字列リテラル、正規表現リテラル、CLI ヘルプ文言だけの検索結果を除外する | | `--exclude-fixtures` | `search` | 保持される facet がテスト fixture 文字列だけの検索結果を除外する | -| `--match-origin ` | `search` | `code`、`comment`、`string_literal`、`regex_literal`、`help_text` など、選択した origin の一致だけを保持する。繰り返し指定とカンマ区切りに対応 | +| `--origin ` / `--match-origin ` | `search` | `code`、`comment`、`string_literal`、`regex_literal`、`help_text` など、選択した origin の一致だけを保持する。繰り返し指定とカンマ区切りに対応 | +| `--exclude-origin ` | `search` | 選択した origin の一致を除外し、同じ結果内の他 origin の一致は保持する。繰り返し指定とカンマ区切りに対応 | | `--result-kind ` | `search` | `call_site`、`declaration`、`identifier`、`comment`、`string_literal` など、projection された result kind だけを保持する | | `--unique ` / `--count-by ` | `search` | 広い audit 向けに unique aggregation row または count aggregation row を出力する | | `--format grouped` / `--per-file-limit ` | `search` | file ごとに grouped JSON を返し、各 file の代表 match 数を制限する | diff --git a/changelog.d/unreleased/3680.fixed.md b/changelog.d/unreleased/3680.fixed.md new file mode 100644 index 0000000000..cb31096450 --- /dev/null +++ b/changelog.d/unreleased/3680.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3680 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - USER_GUIDE.md + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +--- + +## English + +- **Exact substring searches can now filter both included and excluded match origins (#3680)** — `cdidx search` accepts `--origin` as a short alias for `--match-origin` and adds repeatable `--exclude-origin` filters for origins such as `code`, `comment`, `string_literal`, `regex_literal`, and `help_text`. + +## 日本語 + +- **exact substring 検索で match origin の include / exclude を指定できるようにしました (#3680)** — `cdidx search` は `--match-origin` の短い別名として `--origin` を受け付け、`code`、`comment`、`string_literal`、`regex_literal`、`help_text` などを除外する `--exclude-origin` を繰り返し指定できます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 6977b2858c..6f118e6ad6 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -314,7 +314,9 @@ private static IReadOnlyList BuildAll() new() { Name = "--guard-window", ValuePlaceholder = "", Description = "Search: line window for require/reject guard queries", Commands = Set("search") }, new() { Name = "--unique", ValuePlaceholder = "", Description = "Search: emit unique aggregation rows", Commands = Set("search") }, new() { Name = "--count-by", ValuePlaceholder = "", Description = "Search: count matches grouped by path, symbol, or origin", Commands = Set("search") }, + new() { Name = "--origin", ValuePlaceholder = "", Description = "Search: alias for --match-origin; keep only matches from selected origins", Commands = Set("search") }, new() { Name = "--match-origin", ValuePlaceholder = "", Description = "Search: keep only matches from selected origins; repeat or comma-separate values", Commands = Set("search") }, + new() { Name = "--exclude-origin", ValuePlaceholder = "", Description = "Search: drop matches from selected origins; repeat or comma-separate values", Commands = Set("search") }, new() { Name = "--result-kind", ValuePlaceholder = "", Description = "Search: keep only projected result kinds; repeat or comma-separate values", Commands = Set("search") }, new() { Name = "--search-fields", ValuePlaceholder = "", Description = "Search: project JSON/NDJSON result fields for audit pipelines", Commands = Set("search") }, new() { Name = "--results-only", Description = "Search: emit result-only NDJSON without stream done records", Commands = Set("search") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 2702a0903d..c6b32b6ed2 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -1043,6 +1043,8 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(" --exclude-comments search only: suppress comment-only matches"); Console.WriteLine(" --exclude-strings search only: suppress string, regex, and help-text matches"); Console.WriteLine(" --exclude-fixtures search only: suppress fixture-only matches in tests"); + WriteHelpLine(" --origin/--match-origin search only: keep only matches from selected origins (code, comment, string_literal, regex_literal, help_text, unknown; repeatable or comma-separated)"); + WriteHelpLine(" --exclude-origin search only: drop matches from selected origins while keeping other origins in the same result"); Console.WriteLine(" --include-generated Include generated files in query results"); Console.WriteLine(" --snippet-lines search/find snippet length (1-20, default: search 8; find 1)"); Console.WriteLine(" --snippet-focus search only: long-line focus mode (leftmost|quality|proximity, default: quality)"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 58ee4f5789..709b2e1062 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -104,6 +104,7 @@ public static partial class QueryCommandRunner private const int SearchEnvelopeMinCandidates = 200; private const int SearchEnvelopeOverFetchFactor = 50; private const int SearchEnvelopeMaxCandidates = 10_000; + private const string SearchFilterNoMatchSentinel = "\0__cdidx_no_match__"; private const string HotspotsGroupedByNameKind = "name_kind"; private const string HotspotsGroupedBySymbol = "symbol"; private const string HotspotsGroupedByFile = "file"; @@ -193,7 +194,9 @@ public static partial class QueryCommandRunner "--group-by", "--unique", "--count-by", + "--origin", "--match-origin", + "--exclude-origin", "--result-kind", "--sample", "--per-file-limit", @@ -2406,12 +2409,14 @@ private static List BuildSearchDisplayRows( QueryCommandOptions options, bool exact, string? queryOverride = null, - bool? rawFtsOverride = null) + bool? rawFtsOverride = null, + SearchAuditRecipeQuery? recipeQuery = null) { var rows = new List(results.Count); var seenMatchLocations = options.NoDedup ? null : new HashSet(StringComparer.Ordinal); var displayQuery = queryOverride ?? options.Query!; var rawFts = rawFtsOverride ?? options.RawFts; + var facetFilters = BuildSearchDisplayFacetFilters(options, recipeQuery); var effectiveRawFts = rawFts && !exact; var queryContext = effectiveRawFts ? SearchSnippetFormatter.PrepareRawFtsQueryContext(displayQuery) @@ -2427,7 +2432,7 @@ private static List BuildSearchDisplayRows( result.Lang, options.SnippetFocus, exposeLiteralHighlights: exact); - var preferredOriginFilterLine = GetPreferredSearchOriginFilterLine(compact, options); + var preferredOriginFilterLine = GetPreferredSearchOriginFilterLine(compact, facetFilters); if (preferredOriginFilterLine.HasValue && !IsLineWithinSnippet(compact, preferredOriginFilterLine.Value)) { compact = SearchSnippetFormatter.ToCompactResult( @@ -2446,11 +2451,11 @@ private static List BuildSearchDisplayRows( if (!effectiveRawFts && compact.MatchLines.Count == 0 && compact.Highlights.Count == 0) continue; - if (!ApplySearchOriginFilters(compact, options)) + if (!ApplySearchOriginFilters(compact, facetFilters)) continue; compact.ResultKinds = BuildSearchResultKinds(result, compact, displayQuery); - if (!ApplySearchResultKindFilters(compact, options)) + if (!ApplySearchResultKindFilters(compact, facetFilters)) continue; if (seenMatchLocations != null && compact.MatchLines.Count > 0) @@ -2482,13 +2487,50 @@ private static List BuildSearchDisplayRows( return rows; } - private static int? GetPreferredSearchOriginFilterLine(CompactSearchResult compact, QueryCommandOptions options) + private sealed record SearchDisplayFacetFilters( + bool ExcludeComments, + bool ExcludeStrings, + bool ExcludeFixtures, + List MatchOrigins, + List ExcludeOrigins, + List ResultKinds); + + private static SearchDisplayFacetFilters BuildSearchDisplayFacetFilters(QueryCommandOptions options, SearchAuditRecipeQuery? recipeQuery) + => new( + options.ExcludeComments, + options.ExcludeStrings, + options.ExcludeFixtures, + CombineInclusiveSearchFilters(options.MatchOrigins, recipeQuery?.MatchOrigins), + CombineExclusiveSearchFilters(options.ExcludeOrigins, recipeQuery?.ExcludeOrigins), + CombineInclusiveSearchFilters(options.ResultKinds, recipeQuery?.ResultKinds)); + + private static List CombineInclusiveSearchFilters(IReadOnlyList optionValues, IReadOnlyList? recipeValues) + { + if (recipeValues is not { Count: > 0 }) + return [.. optionValues]; + if (optionValues.Count == 0) + return [.. recipeValues]; + + var intersected = optionValues + .Where(value => recipeValues.Contains(value, StringComparer.Ordinal)) + .Distinct(StringComparer.Ordinal) + .ToList(); + return intersected.Count == 0 ? [SearchFilterNoMatchSentinel] : intersected; + } + + private static List CombineExclusiveSearchFilters(IReadOnlyList optionValues, IReadOnlyList? recipeValues) + => optionValues + .Concat(recipeValues ?? Array.Empty()) + .Distinct(StringComparer.Ordinal) + .ToList(); + + private static int? GetPreferredSearchOriginFilterLine(CompactSearchResult compact, SearchDisplayFacetFilters filters) { - if (!HasSearchOriginFilters(options) || compact.MatchFacets.Count == 0) + if (!HasSearchOriginFilters(filters) || compact.MatchFacets.Count == 0) return null; return compact.MatchFacets - .Where(facet => !IsSearchFacetExcluded(facet, options)) + .Where(facet => !IsSearchFacetExcluded(facet, filters)) .Select(facet => (int?)facet.Line) .OrderBy(line => line) .FirstOrDefault(); @@ -2577,15 +2619,15 @@ private static QueryCountResult CountFilteredSearchResults(DbReader reader, Quer rows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count()); } - private static bool ApplySearchOriginFilters(CompactSearchResult compact, QueryCommandOptions options) + private static bool ApplySearchOriginFilters(CompactSearchResult compact, SearchDisplayFacetFilters filters) { - if (!HasSearchOriginFilters(options)) + if (!HasSearchOriginFilters(filters)) return true; if (compact.MatchFacets.Count == 0) - return options.MatchOrigins.Count == 0; + return filters.MatchOrigins.Count == 0; var keptFacets = compact.MatchFacets - .Where(facet => !IsSearchFacetExcluded(facet, options)) + .Where(facet => !IsSearchFacetExcluded(facet, filters)) .ToList(); if (keptFacets.Count == 0) return false; @@ -2630,23 +2672,33 @@ private static bool ApplySearchOriginFilters(CompactSearchResult compact, QueryC } private static bool HasSearchOriginFilters(QueryCommandOptions options) - => options.ExcludeComments || options.ExcludeStrings || options.ExcludeFixtures || options.MatchOrigins.Count > 0 || options.ResultKinds.Count > 0; + => HasSearchOriginFilters(BuildSearchDisplayFacetFilters(options, recipeQuery: null)); + + private static bool HasSearchOriginFilters(SearchDisplayFacetFilters filters) + => filters.ExcludeComments || + filters.ExcludeStrings || + filters.ExcludeFixtures || + filters.MatchOrigins.Count > 0 || + filters.ExcludeOrigins.Count > 0 || + filters.ResultKinds.Count > 0; - private static bool IsSearchFacetExcluded(SearchMatchFacet facet, QueryCommandOptions options) + private static bool IsSearchFacetExcluded(SearchMatchFacet facet, SearchDisplayFacetFilters filters) { - if (options.ExcludeComments && string.Equals(facet.Origin, SearchMatchClassifier.Comment, StringComparison.Ordinal)) + if (filters.ExcludeComments && string.Equals(facet.Origin, SearchMatchClassifier.Comment, StringComparison.Ordinal)) return true; - if (options.ExcludeStrings && SearchMatchClassifier.IsStringLikeOrigin(facet.Origin)) + if (filters.ExcludeStrings && SearchMatchClassifier.IsStringLikeOrigin(facet.Origin)) return true; - if (options.ExcludeFixtures && facet.TestFixture) + if (filters.ExcludeFixtures && facet.TestFixture) return true; - if (options.MatchOrigins.Count > 0 && !options.MatchOrigins.Contains(facet.Origin, StringComparer.Ordinal)) + if (filters.MatchOrigins.Count > 0 && !filters.MatchOrigins.Contains(facet.Origin, StringComparer.Ordinal)) + return true; + if (filters.ExcludeOrigins.Count > 0 && filters.ExcludeOrigins.Contains(facet.Origin, StringComparer.Ordinal)) return true; return false; } - private static bool ApplySearchResultKindFilters(CompactSearchResult compact, QueryCommandOptions options) - => options.ResultKinds.Count == 0 || compact.ResultKinds.Any(kind => options.ResultKinds.Contains(kind, StringComparer.Ordinal)); + private static bool ApplySearchResultKindFilters(CompactSearchResult compact, SearchDisplayFacetFilters filters) + => filters.ResultKinds.Count == 0 || compact.ResultKinds.Any(kind => filters.ResultKinds.Contains(kind, StringComparer.Ordinal)); private static List BuildSearchResultKinds(SearchResult result, CompactSearchResult compact, string query) { @@ -8233,6 +8285,7 @@ public static QueryCommandOptions ParseArgs( string? uniqueBy = null; string? countBy = null; var matchOrigins = new List(); + var excludeOrigins = new List(); var resultKinds = new List(); List? searchFields = null; bool firstPerFile = false; @@ -9011,12 +9064,20 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(countByError!); break; + case "--origin": case "--match-origin": - if (TryReadStringOptionValue(args, ref i, "--match-origin", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var originValue, out var originError)) - AddSearchMatchOrigins(originValue!, matchOrigins, AddParseError); + var originOptionName = normalizedArg; + if (TryReadStringOptionValue(args, ref i, originOptionName, inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var originValue, out var originError)) + AddSearchMatchOrigins(originOptionName, originValue!, matchOrigins, AddParseError); else AddParseError(originError!); break; + case "--exclude-origin": + if (TryReadStringOptionValue(args, ref i, "--exclude-origin", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var excludedOriginValue, out var excludedOriginError)) + AddSearchMatchOrigins("--exclude-origin", excludedOriginValue!, excludeOrigins, AddParseError); + else + AddParseError(excludedOriginError!); + break; case "--result-kind": if (TryReadStringOptionValue(args, ref i, "--result-kind", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var resultKindValue, out var resultKindError)) AddSearchResultKinds(resultKindValue!, resultKinds, AddParseError); @@ -9518,6 +9579,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) UniqueBy = uniqueBy, CountBy = countBy, MatchOrigins = matchOrigins, + ExcludeOrigins = excludeOrigins, ResultKinds = resultKinds, SearchFields = searchFields, FirstPerFile = firstPerFile, @@ -9795,15 +9857,15 @@ private static List ParseMapSections(string rawValue, Action add return fields; } - private static void AddSearchMatchOrigins(string rawValue, List origins, Action addParseError) + private static void AddSearchMatchOrigins(string optionName, string rawValue, List origins, Action addParseError) { - if (!ValidateCsvBounds("--match-origin", rawValue, MaxSearchProjectionFieldsCsvLength, MaxSearchProjectionFieldsCsvEntries, addParseError)) + if (!ValidateCsvBounds(optionName, rawValue, MaxSearchProjectionFieldsCsvLength, MaxSearchProjectionFieldsCsvEntries, addParseError)) return; foreach (var rawOrigin in rawValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { if (!TryNormalizeSearchMatchOrigin(rawOrigin, out var origin)) { - addParseError($"Error: unsupported --match-origin value '{ConsoleUi.FormatBoundedValue(rawOrigin)}'. Use code, comment, string_literal, regex_literal, help_text, or unknown."); + addParseError($"Error: unsupported {optionName} value '{ConsoleUi.FormatBoundedValue(rawOrigin)}'. Use code, comment, string_literal, regex_literal, help_text, or unknown."); continue; } if (!origins.Contains(origin, StringComparer.Ordinal)) @@ -13217,6 +13279,7 @@ public sealed class QueryCommandOptions public string? UniqueBy { get; init; } public string? CountBy { get; init; } public List MatchOrigins { get; init; } = []; + public List ExcludeOrigins { get; init; } = []; public List ResultKinds { get; init; } = []; public List? SearchFields { get; init; } public bool FirstPerFile { get; init; } diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 34300c272f..10c86eecd6 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -804,6 +804,9 @@ internal sealed record SearchAuditRecipeQuery( public string Severity { get; init; } = SearchAuditRecipes.DefaultQuerySeverity; public List PathPatterns { get; init; } = []; public List ExcludePaths { get; init; } = []; + public List MatchOrigins { get; init; } = []; + public List ExcludeOrigins { get; init; } = []; + public List ResultKinds { get; init; } = []; } internal sealed record SearchRecipeListJsonResult( diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 9f3d4d425f..00feebb8f5 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -6288,6 +6288,48 @@ void Run() } } + [Fact] + public void RunSearch_OriginAliasAndExcludeOriginFilterExactSubstring_Issue3680() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_origin_exclude_filter"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/code.cs", + "csharp", + """ + public class Demo + { + void Run() + { + Directory.Delete(path); + } + } + """); + TestProjectHelper.InsertIndexedFile(dbPath, "src/comment.cs", "csharp", "// Directory.Delete(path) only in a comment\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/string.cs", "csharp", "var text = \"Directory.Delete(path)\";\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["Directory.Delete", "--db", dbPath, "--exact-substring", "--origin", "code", "--exclude-origin", "comment,string_literal", "--json=array"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var row = Assert.Single(document.RootElement.EnumerateArray()); + Assert.Equal("src/code.cs", row.GetProperty("path").GetString()); + Assert.Contains("code", row.GetProperty("match_origins").EnumerateArray().Select(value => value.GetString())); + Assert.DoesNotContain("comment", row.GetProperty("match_origins").EnumerateArray().Select(value => value.GetString())); + Assert.DoesNotContain("string_literal", row.GetProperty("match_origins").EnumerateArray().Select(value => value.GetString())); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_CountByOriginReturnsAggregatedJson_Issue3729() { From ab0da7c9911e4d78555aa8e786446388de760987 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 20:51:26 +0900 Subject: [PATCH 2/4] Filter catch audit recipes by code origin --- changelog.d/unreleased/3709.fixed.md | 18 +++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 16 +++- src/CodeIndex/Cli/SearchAuditRecipes.cs | 19 ++++- src/CodeIndex/Mcp/McpToolHandlers.cs | 23 ++++++ .../QueryCommandRunnerSearchTests.cs | 79 +++++++++++++++++++ 5 files changed, 151 insertions(+), 4 deletions(-) create mode 100644 changelog.d/unreleased/3709.fixed.md diff --git a/changelog.d/unreleased/3709.fixed.md b/changelog.d/unreleased/3709.fixed.md new file mode 100644 index 0000000000..cf00ab9439 --- /dev/null +++ b/changelog.d/unreleased/3709.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3709 +affected: + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +--- + +## English + +- **Catch-block audit recipes now filter by structured match origin (#3709)** — `empty-catch-review` and `broad-exception-catch` declare `match_origins: ["code"]`, recipe JSON exposes facet metadata, and CLI/MCP recipe execution drops comment-only catch matches. + +## 日本語 + +- **catch-block 監査 recipe が構造化された match origin で絞り込むようになりました (#3709)** — `empty-catch-review` と `broad-exception-catch` が `match_origins: ["code"]` を宣言し、recipe JSON に facet metadata を出力します。CLI/MCP の recipe 実行では comment-only の catch 一致を除外します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 709b2e1062..f0ff22f4a0 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -1725,6 +1725,9 @@ private static int RunSearchIssueDrafts(QueryCommandOptions options, JsonSeriali SearchAuditRecipes.DefaultQuerySeverity, [], [], + [], + [], + [], rows.Count, options.Limit, 0, @@ -1787,7 +1790,7 @@ private static List CollectSearchRecipeQueryR guardFilters: options.GuardFilters, guardWindow: options.GuardWindow, requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery)); - var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false); + var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false, recipeQuery: recipeQuery); var availableCount = rows.Count; var truncated = TrimSearchRowsToRequestedLimit(rows, options.Limit); var minimumOmitted = truncated ? Math.Max(1, availableCount - rows.Count) : 0; @@ -1802,6 +1805,9 @@ private static List CollectSearchRecipeQueryR recipeQuery.Severity, [.. recipeQuery.PathPatterns], [.. recipeQuery.ExcludePaths], + [.. recipeQuery.MatchOrigins], + [.. recipeQuery.ExcludeOrigins], + [.. recipeQuery.ResultKinds], rows.Count, options.Limit, minimumOmitted, @@ -1845,7 +1851,7 @@ private static List CollectSearchRecip guardFilters: options.GuardFilters, guardWindow: options.GuardWindow, requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery)); - var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query); + var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, recipeQuery: recipeQuery); var availableCount = rows.Count; var truncated = TrimSearchRowsToRequestedLimit(rows, options.Limit); var minimumOmitted = truncated ? Math.Max(1, availableCount - rows.Count) : 0; @@ -1857,6 +1863,9 @@ private static List CollectSearchRecip recipeQuery.Severity, [.. recipeQuery.PathPatterns], [.. recipeQuery.ExcludePaths], + [.. recipeQuery.MatchOrigins], + [.. recipeQuery.ExcludeOrigins], + [.. recipeQuery.ResultKinds], rows.Count, options.Limit, minimumOmitted, @@ -2402,6 +2411,9 @@ private static string BuildAdHocSearchIssueDraftBody( query.Severity, [.. query.PathPatterns], [.. query.ExcludePaths], + [.. query.MatchOrigins], + [.. query.ExcludeOrigins], + [.. query.ResultKinds], query.ExactSubstring)).ToList()); private static List BuildSearchDisplayRows( diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 10c86eecd6..20afca9bfb 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -95,13 +95,19 @@ internal static class SearchAuditRecipes "catch", "Find catch blocks that may be empty, overly broad, or swallowing diagnostic context.", ["audit", "bug"], - "False positives include catch blocks that rethrow, translate exceptions safely, or intentionally ignore best-effort cleanup failures."), + "False positives include catch blocks that rethrow, translate exceptions safely, or intentionally ignore best-effort cleanup failures.") + { + MatchOrigins = ["code"], + }, new( "broad-exception-catch", "catch (Exception", "Find broad C# exception catches that may need narrower exception types or explicit recovery boundaries.", ["audit", "bug"], - "False positives include top-level command boundaries that intentionally normalize all recoverable failures."), + "False positives include top-level command boundaries that intentionally normalize all recoverable failures.") + { + MatchOrigins = ["code"], + }, new( "process-start-info", "ProcessStartInfo", @@ -852,6 +858,9 @@ internal sealed record SearchRecipeQueryListItemJsonResult( [property: JsonPropertyName("severity")] string Severity, [property: JsonPropertyName("path_patterns")] List PathPatterns, [property: JsonPropertyName("exclude_paths")] List ExcludePaths, + [property: JsonPropertyName("match_origins")] List MatchOrigins, + [property: JsonPropertyName("exclude_origins")] List ExcludeOrigins, + [property: JsonPropertyName("result_kinds")] List ResultKinds, [property: JsonPropertyName("exact_substring")] bool ExactSubstring); internal sealed record SearchRecipeRunJsonResult( @@ -897,6 +906,9 @@ internal sealed record SearchRecipeQueryResultJsonResult( [property: JsonPropertyName("severity")] string Severity, [property: JsonPropertyName("path_patterns")] List PathPatterns, [property: JsonPropertyName("exclude_paths")] List ExcludePaths, + [property: JsonPropertyName("match_origins")] List MatchOrigins, + [property: JsonPropertyName("exclude_origins")] List ExcludeOrigins, + [property: JsonPropertyName("result_kinds")] List ResultKinds, [property: JsonPropertyName("count")] int Count, [property: JsonPropertyName("result_limit")] int ResultLimit, [property: JsonPropertyName("minimum_omitted_result_count")] int MinimumOmittedResultCount, @@ -921,6 +933,9 @@ internal sealed record SearchRecipeCompactQueryResultJsonResult( [property: JsonPropertyName("severity")] string Severity, [property: JsonPropertyName("path_patterns")] List PathPatterns, [property: JsonPropertyName("exclude_paths")] List ExcludePaths, + [property: JsonPropertyName("match_origins")] List MatchOrigins, + [property: JsonPropertyName("exclude_origins")] List ExcludeOrigins, + [property: JsonPropertyName("result_kinds")] List ResultKinds, [property: JsonPropertyName("count")] int Count, [property: JsonPropertyName("result_limit")] int ResultLimit, [property: JsonPropertyName("minimum_omitted_result_count")] int MinimumOmittedResultCount, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index d4286b1549..daff4b8c83 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -1165,6 +1165,25 @@ private JsonArray BuildTopFileHistogram(IEnumerable results, Func 0 && + !result.MatchOrigins.Any(origin => recipeQuery.MatchOrigins.Contains(origin, StringComparer.Ordinal))) + { + return false; + } + + if (recipeQuery.ExcludeOrigins.Count > 0 && + result.MatchOrigins.Count > 0 && + result.MatchOrigins.All(origin => recipeQuery.ExcludeOrigins.Contains(origin, StringComparer.Ordinal))) + { + return false; + } + + return recipeQuery.ResultKinds.Count == 0 || + result.ResultKinds.Any(kind => recipeQuery.ResultKinds.Contains(kind, StringComparer.Ordinal)); + } + private JsonObject BuildCountOnlyPayload(int count, int? total, bool truncated, IEnumerable histogramSource, Func pathSelector) { var payload = new JsonObject @@ -1945,6 +1964,7 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe var queryContext = SearchSnippetFormatter.PrepareQueryContext(recipeQuery.Query); var compactResults = SearchSnippetFormatter .ToCompactResults(results, queryContext, snippetLines, exact, maxLineWidth, exposeLiteralHighlights: exact) + .Where(result => MatchesRecipeFacetMetadata(result, recipeQuery)) .ToList(); var truncated = TrimToRequestedLimit(compactResults, limit); foreach (var compact in compactResults) @@ -1958,6 +1978,9 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe ["recommended_labels"] = ToJsonArray(recipeQuery.RecommendedLabels), ["false_positive_guidance"] = recipeQuery.FalsePositiveGuidance, ["exact_substring"] = exact, + ["match_origins"] = ToJsonArray(recipeQuery.MatchOrigins), + ["exclude_origins"] = ToJsonArray(recipeQuery.ExcludeOrigins), + ["result_kinds"] = ToJsonArray(recipeQuery.ResultKinds), ["count"] = compactResults.Count, ["top_files"] = BuildTopFileHistogram(compactResults, result => result.Path), ["truncated"] = truncated, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 00feebb8f5..d95227a6ad 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1089,6 +1089,10 @@ public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() .GetProperty("queries") .EnumerateArray() .Single(item => item.GetProperty("name").GetString() == "token-term"); + var emptyCatchQuery = recipe + .GetProperty("queries") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "empty-catch-review"); Assert.True(root.GetProperty("count").GetInt32() >= 6); Assert.Contains(recipe.GetProperty("recommended_labels").EnumerateArray(), label => label.GetString() == "audit"); @@ -1104,6 +1108,9 @@ 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.Contains(emptyCatchQuery.GetProperty("match_origins").EnumerateArray(), origin => origin.GetString() == "code"); + Assert.Equal(0, emptyCatchQuery.GetProperty("exclude_origins").GetArrayLength()); + Assert.Equal(0, emptyCatchQuery.GetProperty("result_kinds").GetArrayLength()); Assert.Equal("auth token", tokenQuery.GetProperty("query").GetString()); Assert.Contains("broad-token-audit", tokenQuery.GetProperty("false_positive_guidance").GetString(), StringComparison.Ordinal); Assert.Contains(recipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "file-read-all-text"); @@ -1123,6 +1130,78 @@ public void RunSearch_ListRecipesJsonIncludesBuiltInAuditMetadata_Issue3144() Assert.Contains(broadTokenRecipe.GetProperty("queries").EnumerateArray(), item => item.GetProperty("name").GetString() == "token-term-broad"); } + [Fact] + public void RunSearch_CatchRecipeFiltersCommentOnlyCatchMatches_Issue3709() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_catch_origin_filter"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/code.cs", + "csharp", + """ + public sealed class App + { + public void Run() + { + try + { + Work(); + } + catch + { + } + } + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/comment.cs", + "csharp", + """ + public sealed class Notes + { + // catch appears only in a comment and should not satisfy the recipe. + public void Run() { } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/empty-catch-review", "--db", dbPath, "--lang", "csharp", "--limit", "10", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var query = Assert.Single(document.RootElement.GetProperty("queries").EnumerateArray()); + Assert.Equal("empty-catch-review", query.GetProperty("name").GetString()); + Assert.Contains(query.GetProperty("match_origins").EnumerateArray(), origin => origin.GetString() == "code"); + Assert.Equal(1, query.GetProperty("count").GetInt32()); + var result = Assert.Single(query.GetProperty("results").EnumerateArray()); + Assert.Equal("src/code.cs", result.GetProperty("path").GetString()); + Assert.Contains(result.GetProperty("match_origins").EnumerateArray(), origin => origin.GetString() == "code"); + Assert.DoesNotContain(query.GetProperty("top_files").EnumerateArray(), file => file.GetProperty("path").GetString() == "src/comment.cs"); + + var (commentExitCode, commentStdout, commentStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/empty-catch-review", "--db", dbPath, "--lang", "csharp", "--limit", "10", "--origin", "comment", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, commentExitCode); + Assert.Equal(string.Empty, commentStderr); + using var commentDocument = ParseJsonOutput(commentStdout); + Assert.Equal(0, commentDocument.RootElement.GetProperty("result_count").GetInt32()); + var commentQuery = Assert.Single(commentDocument.RootElement.GetProperty("queries").EnumerateArray()); + Assert.Equal(0, commentQuery.GetProperty("count").GetInt32()); + Assert.Empty(commentQuery.GetProperty("results").EnumerateArray()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_ExternalRecipeDefaultsApplyToScope_Issue3807() { From e5b37fafb7f3d72f1a19b80fa388fdb6c0ec8ad9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 20 Jun 2026 21:04:33 +0900 Subject: [PATCH 3/4] Add same-line scope for search guards --- USER_GUIDE.md | 17 +- changelog.d/unreleased/3730.fixed.md | 26 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 1 + src/CodeIndex/Cli/ConsoleUi.cs | 3 +- src/CodeIndex/Cli/ProgramRunner.cs | 1 + src/CodeIndex/Cli/QueryCommandRunner.cs | 53 +++- src/CodeIndex/Database/DbSearchReader.cs | 228 ++++++++++++++---- src/CodeIndex/Mcp/McpToolArgumentContracts.cs | 2 +- src/CodeIndex/Mcp/McpToolDefinitions.cs | 1 + src/CodeIndex/Mcp/McpToolHandlers.cs | 36 ++- src/CodeIndex/Models/QueryResults.cs | 8 + tests/CodeIndex.Tests/ConsoleUiTests.cs | 4 +- .../QueryCommandRunnerSearchGuardTests.cs | 58 +++++ 13 files changed, 374 insertions(+), 64 deletions(-) create mode 100644 changelog.d/unreleased/3730.fixed.md diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a259d74529..185d568e8f 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -945,6 +945,7 @@ 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 "DangerousCall" --exact-substring --require-before "GuardBefore" --guard-scope same-line --json=array # require a guard earlier on the same line cdidx search --list-recipes # show reusable audit recipes cdidx search --recipe risky-code --json # run a curated audit query set and return grouped JSON cdidx search --recipe risky-code/raw-diagnostic-echo --json # run one child query from a recipe @@ -978,10 +979,13 @@ into smaller searches or use narrower text. Guard-aware search filters primary `search` matches by nearby literal guards: `--require-before` / `--require-after` keep matches only when the guard query appears in the selected line window, while `--reject-before` / `--reject-after` -drop matches when the guard query appears. JSON search results include +drop matches when the guard query appears. Add `--guard-scope same-line` to +limit before/after checks to the same source line and the primary match column +ordering instead of nearby lines. JSON search results include `guard_evidence` for matched guards and `guard_checks` for each guard evaluated on a returned match. Guard evidence includes the guard name, pattern, -before/after relationship, 1-based span, origin category, and source line. +before/after relationship, scope (`window` or `same_line`), 1-based span, +origin category, and source line. Each `guard_checks[]` entry includes a compact pass/fail summary. Guarded searches inspect a bounded candidate set before pagination; if a guarded query is too broad to satisfy the requested page within that budget, CLI and MCP @@ -989,7 +993,7 @@ return a validation error. Narrow with more specific query text, `--lang`, `--path`, `--exclude-tests`, or a smaller MCP cursor offset. The MCP `search` tool exposes the same mode as camelCase arguments: `requireBefore`, `requireAfter`, `rejectBefore`, `rejectAfter`, and -`guardWindow`. +`guardWindow` / `guardScope`. Machine-readable search exports include enough context for downstream tools to triage results without reparsing human text. `--format csv` and `--format tsv` @@ -3498,6 +3502,7 @@ 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 "DangerousCall" --exact-substring --require-before "GuardBefore" --guard-scope same-line --json=array # 同じ行の前方 guard を要求 cdidx search --list-recipes # 再利用可能な audit recipe を表示 cdidx search --recipe risky-code --json # curated audit query set を実行し、grouped JSON を返す cdidx search --recipe risky-code/raw-diagnostic-echo --json # recipe 内の child query を1つだけ実行 @@ -3530,10 +3535,12 @@ literal-safe な `search` query は 1000 文字、128 whitespace term までで より狭い text にしてください。 guard-aware search は primary の `search` 一致を近傍の literal guard で絞り込みます: `--require-before` / `--require-after` は指定行窓内に guard query がある場合だけ残し、 -`--reject-before` / `--reject-after` は guard query がある一致を落とします。JSON の検索結果には +`--reject-before` / `--reject-after` は guard query がある一致を落とします。 +`--guard-scope same-line` を追加すると、近傍行ではなく同じソース行内で primary match の列位置を基準に +before / after を評価します。JSON の検索結果には 一致した guard の `guard_evidence` と、返却された一致に対して評価した各 guard の `guard_checks` が含まれます。guard evidence には guard 名、pattern、before/after の関係、 -1-based span、origin category、ソース行、簡潔な pass/fail summary が入ります。 +scope(`window` または `same_line`)、1-based span、origin category、ソース行、簡潔な pass/fail summary が入ります。 guard filter を使う検索は pagination 前に上限付きの候補集合だけを調べます。その budget 内で 要求ページを満たせないほど query が広い場合、CLI/MCP は validation error を返します。 query text、`--lang`、`--path`、`--exclude-tests` で絞り込むか、MCP cursor の offset を小さくしてください。 diff --git a/changelog.d/unreleased/3730.fixed.md b/changelog.d/unreleased/3730.fixed.md new file mode 100644 index 0000000000..bdd1088c53 --- /dev/null +++ b/changelog.d/unreleased/3730.fixed.md @@ -0,0 +1,26 @@ +--- +category: fixed +issues: + - 3730 +affected: + - src/CodeIndex/Database/DbSearchReader.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Mcp/McpToolArgumentContracts.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - USER_GUIDE.md + - tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs +--- + +## English + +- **Search guards can now run on the same line as the primary match (#3730)** — `cdidx search` and MCP `search` add `--guard-scope same-line` / `guardScope: "same-line"` so require/reject guards use the primary match column ordering, and JSON guard checks/evidence now report `scope`. + +## 日本語 + +- **search guard を primary match と同じ行だけで評価できるようにしました (#3730)** — `cdidx search` と MCP `search` に `--guard-scope same-line` / `guardScope: "same-line"` を追加し、require/reject guard が primary match の列順を使って判定します。JSON の guard checks/evidence には `scope` も出力されます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 6f118e6ad6..8528c97563 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -312,6 +312,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--reject-before", ValuePlaceholder = "", Description = "Search: reject primary matches with a nearby guard query before them", Commands = Set("search") }, new() { Name = "--reject-after", ValuePlaceholder = "", Description = "Search: reject primary matches with a nearby guard query after them", Commands = Set("search") }, new() { Name = "--guard-window", ValuePlaceholder = "", Description = "Search: line window for require/reject guard queries", Commands = Set("search") }, + new() { Name = "--guard-scope", ValuePlaceholder = "", Description = "Search: evaluate guard queries in the line window or on the same line as the primary match", Commands = Set("search") }, new() { Name = "--unique", ValuePlaceholder = "", Description = "Search: emit unique aggregation rows", Commands = Set("search") }, new() { Name = "--count-by", ValuePlaceholder = "", Description = "Search: count matches grouped by path, symbol, or origin", Commands = Set("search") }, new() { Name = "--origin", ValuePlaceholder = "", Description = "Search: alias for --match-origin; keep only matches from selected origins", Commands = Set("search") }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index c6b32b6ed2..902a0fccfa 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 ] [--show-excluded] [--db ] [--json[=ndjson|array]] [--pretty] [--format ] [--open-issues ] [--repo ] [--duplicate-confidence |--duplicate-threshold ] [--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>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>] [--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>] [--guard-scope <window|same-line>]"), ("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]"), @@ -1074,6 +1074,7 @@ private static void PrintFlagReference(Action<string> WriteHelpLine) Console.WriteLine(" --no-dedup search only: return every raw overlapping chunk hit (debug/density)"); WriteHelpLine($" --require-before/--require-after <query> search only: keep primary matches only when the guard query appears within --guard-window lines before/after the match (default {DbReader.DefaultSearchGuardWindow}, max {DbReader.MaxSearchGuardWindow})"); WriteHelpLine(" --reject-before/--reject-after <query> search only: drop primary matches when the guard query appears within the same before/after window; useful for finding API calls missing nearby checks"); + WriteHelpLine(" --guard-scope <window|same-line> search only: evaluate guards in the line window (default) or only on the same line before/after the primary match"); WriteHelpLine(" --bytes files: sort by size and show raw byte counts in human output; map: show raw byte counts; JSON always keeps raw integer bytes"); Console.WriteLine(" --min-entrypoint-confidence <n> map only: omit entrypoint candidates below this 0.0..1.0 confidence"); WriteHelpLine(" --max-hops <n> Max BFS hops for impact analysis, inclusive (default: 5; --max-hops 2 returns callers at hop 1 and 2; --max-hops 0 resolves the symbol without traversing callers)"); diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index 47e0c81d1b..4ff90ca77c 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -963,6 +963,7 @@ private static bool TryClassifySearchValueTakingOption(string arg, out bool hasI "--reject-before", "--reject-after", "--guard-window", + "--guard-scope", "--project", "--solution", "--exclude-path", diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index f0ff22f4a0..b4292db44c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -174,6 +174,7 @@ public static partial class QueryCommandRunner "--reject-before", "--reject-after", "--guard-window", + "--guard-scope", "--project", "--solution", "--exclude-path", @@ -958,7 +959,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) private static int RunGroupedSearchCount(DbReader reader, QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool exact, SearchQueryHint? exactSubstringHint) { - var results = reader.Search(options.Query!, int.MaxValue, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow); + var results = reader.Search(options.Query!, int.MaxValue, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow, guardScope: options.GuardScope); var displayRows = BuildSearchDisplayRows(results, options, exact); var groups = BuildSearchGroupedCounts(options.GroupBy!, displayRows); var fileCount = displayRows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count(); @@ -1089,7 +1090,7 @@ private static void WriteSearchGroupedCounts(string groupBy, List<SearchGroupedC private static int RunSearchAggregation(DbReader reader, QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool exact, SearchQueryHint? exactSubstringHint) { - var results = reader.Search(options.Query!, int.MaxValue, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow); + var results = reader.Search(options.Query!, int.MaxValue, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow, guardScope: options.GuardScope); var rows = BuildSearchDisplayRows(results, options, exact); var groupBy = NormalizeSearchAggregationKey(options.CountBy ?? options.UniqueBy!); var groups = BuildSearchGroupedCounts(groupBy, rows); @@ -1713,7 +1714,8 @@ private static int RunSearchIssueDrafts(QueryCommandOptions options, JsonSeriali options.Prefix, !options.NoVisibilityRank, guardFilters: options.GuardFilters, - guardWindow: options.GuardWindow); + guardWindow: options.GuardWindow, + guardScope: options.GuardScope); var rows = BuildSearchDisplayRows(results, options, exact); var queryResult = new SearchRecipeQueryResultJsonResult( "ad-hoc", @@ -1789,6 +1791,7 @@ private static List<SearchRecipeQueryResultJsonResult> CollectSearchRecipeQueryR cursor: options.SearchCursor, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow, + guardScope: options.GuardScope, requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery)); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false, recipeQuery: recipeQuery); var availableCount = rows.Count; @@ -1850,6 +1853,7 @@ private static List<SearchRecipeCompactQueryResultJsonResult> CollectSearchRecip cursor: options.SearchCursor, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow, + guardScope: options.GuardScope, requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery)); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, recipeQuery: recipeQuery); var availableCount = rows.Count; @@ -2054,7 +2058,8 @@ private static List<SearchNamedBatchQueryResultJsonResult> CollectSearchNamedBat options.Prefix, !options.NoVisibilityRank, guardFilters: options.GuardFilters, - guardWindow: options.GuardWindow); + guardWindow: options.GuardWindow, + guardScope: options.GuardScope); var rows = BuildSearchDisplayRows(results, options, userExact, namedQuery.Query); var truncated = TrimSearchRowsToRequestedLimit(rows, options.Limit); AttachExactSubstringHint( @@ -2284,6 +2289,8 @@ private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, Q AddReplayValueOption(args, BuildSearchGuardReplayOptionName(guardFilter), guardFilter.Query); if (options.GuardFilters.Count > 0 && options.GuardWindow != DbReader.DefaultSearchGuardWindow) AddReplayValueOption(args, "--guard-window", options.GuardWindow.ToString(CultureInfo.InvariantCulture)); + if (options.GuardFilters.Count > 0 && options.GuardScope != SearchGuardScope.Window) + AddReplayValueOption(args, "--guard-scope", FormatSearchGuardScope(options.GuardScope)); AddReplayValueOption(args, "--snippet-lines", options.SnippetLines.ToString(CultureInfo.InvariantCulture)); AddReplayValueOption(args, "--snippet-focus", FormatSearchSnippetFocusMode(options.SnippetFocus)); AddReplayValueOption(args, "--max-line-width", options.MaxLineWidth.ToString(CultureInfo.InvariantCulture)); @@ -2620,7 +2627,7 @@ private static int GetSearchDisplayCandidateLimit(QueryCommandOptions options) } private static List<SearchResult> ReadSearchResults(DbReader reader, QueryCommandOptions options, bool exact, int limit, SearchCursor? cursor = null, int? guardRequestedLimit = null) - => reader.Search(options.Query!, limit, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, cursor, options.GuardFilters, options.GuardWindow, guardRequestedLimit); + => reader.Search(options.Query!, limit, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, cursor, options.GuardFilters, options.GuardWindow, guardRequestedLimit, guardScope: options.GuardScope); private static QueryCountResult CountFilteredSearchResults(DbReader reader, QueryCommandOptions options, bool exact) { @@ -8223,6 +8230,27 @@ private static bool TryNormalizeSearchAuditScope(string value, out string scope) return false; } + private static bool TryNormalizeSearchGuardScope(string value, out SearchGuardScope scope) + { + switch (value.Trim().ToLowerInvariant().Replace("_", "-")) + { + case "window": + scope = SearchGuardScope.Window; + return true; + case "same-line": + case "sameline": + scope = SearchGuardScope.SameLine; + return true; + default: + scope = SearchGuardScope.Window; + return false; + } + } + + private static string FormatSearchGuardScope(SearchGuardScope scope) + => scope == SearchGuardScope.SameLine ? "same-line" : "window"; + + public static QueryCommandOptions ParseArgs( string[] args, bool jsonDefault, @@ -8280,6 +8308,7 @@ public static QueryCommandOptions ParseArgs( bool prefix = false; var guardFilters = new List<SearchGuardFilter>(); var guardWindow = DbReader.DefaultSearchGuardWindow; + var guardScope = SearchGuardScope.Window; bool excludeComments = false; bool excludeStrings = false; bool excludeFixtures = false; @@ -8857,6 +8886,18 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) AddParseError(guardWindowError!); } break; + case "--guard-scope": + if (TryReadStringOptionValue(args, ref i, "--guard-scope", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var guardScopeValue, out var guardScopeError)) + { + WarnIfDuplicateSingleValueOption("--guard-scope", guardScopeValue!); + if (TryNormalizeSearchGuardScope(guardScopeValue!, out var parsedGuardScope)) + guardScope = parsedGuardScope; + else + AddParseError($"Error: unsupported --guard-scope value '{ConsoleUi.FormatBoundedValue(guardScopeValue!)}'. Use window or same-line."); + } + else + AddParseError(guardScopeError!); + break; case "--kind": if (TryReadStringOptionValue(args, ref i, "--kind", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var kindValue, out var kindError)) { @@ -9578,6 +9619,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) Prefix = prefix, GuardFilters = guardFilters, GuardWindow = guardWindow, + GuardScope = guardScope, ExcludeComments = excludeComments, ExcludeStrings = excludeStrings, ExcludeFixtures = excludeFixtures, @@ -13278,6 +13320,7 @@ public sealed class QueryCommandOptions public bool Prefix { get; init; } public List<SearchGuardFilter> GuardFilters { get; init; } = []; public int GuardWindow { get; init; } = DbReader.DefaultSearchGuardWindow; + public SearchGuardScope GuardScope { get; init; } = SearchGuardScope.Window; public bool ExcludeComments { get; init; } public bool ExcludeStrings { get; init; } public bool ExcludeFixtures { get; init; } diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index 9e2c6ecc3b..a8cd987167 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -109,7 +109,7 @@ private static string FormatFtsToken(string token, bool prefix) /// Full-text search across indexed chunks using FTS5. /// FTS5を使ったチャンク全文検索。 /// </summary> - public List<SearchResult> Search(string query, int limit = 20, string? lang = null, bool rawQuery = false, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, SearchCursor? cursor = null, IReadOnlyList<SearchGuardFilter>? guardFilters = null, int guardWindow = DefaultSearchGuardWindow, int? guardRequestedLimit = null, IReadOnlyList<string>? requiredPathPatterns = null) + public List<SearchResult> Search(string query, int limit = 20, string? lang = null, bool rawQuery = false, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, SearchCursor? cursor = null, IReadOnlyList<SearchGuardFilter>? guardFilters = null, int guardWindow = DefaultSearchGuardWindow, int? guardRequestedLimit = null, IReadOnlyList<string>? requiredPathPatterns = null, SearchGuardScope guardScope = SearchGuardScope.Window) { // Guard against empty/whitespace queries that would match everything // 空白のみのクエリが全件マッチするのを防止 @@ -229,7 +229,7 @@ FROM fts_chunks raw.RemoveRange(guardedCandidateLimit, raw.Count - guardedCandidateLimit); if (hasGuardFilters) - raw = FilterBySearchGuards(raw, SearchPrimaryMatchContext.Create(query, normalizedQuery, rawQuery, exact, lang), guardFilters!, guardWindow); + raw = FilterBySearchGuards(raw, SearchPrimaryMatchContext.Create(query, normalizedQuery, rawQuery, exact, lang), guardFilters!, guardWindow, guardScope); var results = deduplicate ? DeduplicateOverlappingResults(raw, SearchPrimaryMatchContext.Create(query, normalizedQuery, rawQuery, exact, lang)) : raw; if (guardCandidateLimitReached && results.Count < GetGuardedSearchRequestedPageEnd(guardedRequestedLimit, cursor)) @@ -410,7 +410,7 @@ s.id ASC private sealed record SearchEnclosingSymbol(string Name, string Kind, int StartLine, int EndLine, string? ContainerName); - public QueryCountResult CountSearchResults(string query, string? lang = null, bool rawQuery = false, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, IReadOnlyList<SearchGuardFilter>? guardFilters = null, int guardWindow = DefaultSearchGuardWindow) + public QueryCountResult CountSearchResults(string query, string? lang = null, bool rawQuery = false, IReadOnlyList<string>? pathPatterns = null, IReadOnlyList<string>? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, IReadOnlyList<SearchGuardFilter>? guardFilters = null, int guardWindow = DefaultSearchGuardWindow, SearchGuardScope guardScope = SearchGuardScope.Window) { if (string.IsNullOrWhiteSpace(query)) return new QueryCountResult(0, 0); @@ -420,7 +420,7 @@ public QueryCountResult CountSearchResults(string query, string? lang = null, bo if (guardFilters is { Count: > 0 }) { - var guardedResults = Search(query, int.MaxValue, lang, rawQuery, pathPatterns, excludePathPatterns, excludeTests, deduplicate, since, exact, prefix, visibilityRank, guardFilters: guardFilters, guardWindow: guardWindow); + var guardedResults = Search(query, int.MaxValue, lang, rawQuery, pathPatterns, excludePathPatterns, excludeTests, deduplicate, since, exact, prefix, visibilityRank, guardFilters: guardFilters, guardWindow: guardWindow, guardScope: guardScope); return new QueryCountResult(guardedResults.Count, guardedResults.Select(result => result.Path).Distinct(StringComparer.Ordinal).Count()); } @@ -522,81 +522,125 @@ private List<SearchResult> FilterBySearchGuards( List<SearchResult> results, SearchPrimaryMatchContext primaryMatchContext, IReadOnlyList<SearchGuardFilter> guardFilters, - int guardWindow) + int guardWindow, + SearchGuardScope guardScope) { guardWindow = Math.Clamp(guardWindow, 0, MaxSearchGuardWindow); var filtered = new List<SearchResult>(results.Count); var lineWindowCache = new Dictionary<SearchGuardLineWindowKey, SortedDictionary<int, string>>(); foreach (var result in results) + filtered.AddRange(FilterSearchResultByGuards(result, primaryMatchContext, guardFilters, guardWindow, guardScope, lineWindowCache)); + + return filtered; + } + + private List<SearchResult> FilterSearchResultByGuards( + SearchResult result, + SearchPrimaryMatchContext primaryMatchContext, + IReadOnlyList<SearchGuardFilter> guardFilters, + int guardWindow, + SearchGuardScope guardScope, + Dictionary<SearchGuardLineWindowKey, SortedDictionary<int, string>> lineWindowCache) + { + guardWindow = Math.Clamp(guardWindow, 0, MaxSearchGuardWindow); + var filtered = new List<SearchResult>(); + foreach (var primaryMatch in FindPrimarySearchMatchLines(result, primaryMatchContext)) { - foreach (var (focusLine, focusText) in FindPrimarySearchMatchLines(result, primaryMatchContext)) + var guardEvidence = new List<SearchGuardEvidence>(); + var guardChecks = new List<SearchGuardCheck>(guardFilters.Count); + var keep = true; + foreach (var filter in guardFilters) { - var guardEvidence = new List<SearchGuardEvidence>(); - var guardChecks = new List<SearchGuardCheck>(guardFilters.Count); - var keep = true; - foreach (var filter in guardFilters) + var evaluation = FindGuardEvidence(result.Path, primaryMatch, filter, guardWindow, guardScope, primaryMatchContext.GetEffectiveLang(result), lineWindowCache); + var matched = evaluation.Evidence != null; + var passed = filter.Role == SearchGuardRole.Require ? matched : !matched; + guardChecks.Add(CreateSearchGuardCheck(filter, guardScope, evaluation, matched, passed)); + if (!passed) { - var evaluation = FindGuardEvidence(result.Path, focusLine, filter, guardWindow, primaryMatchContext.GetEffectiveLang(result), lineWindowCache); - var matched = evaluation.Evidence != null; - var passed = filter.Role == SearchGuardRole.Require ? matched : !matched; - guardChecks.Add(CreateSearchGuardCheck(filter, evaluation, matched, passed)); - if (!passed) - { - keep = false; - break; - } - if (evaluation.Evidence != null) - guardEvidence.Add(evaluation.Evidence); + keep = false; + break; } + if (evaluation.Evidence != null) + guardEvidence.Add(evaluation.Evidence); + } - if (!keep) - continue; + if (!keep) + continue; - filtered.Add(new SearchResult - { - Path = result.Path, - Lang = result.Lang, - StartLine = focusLine, - EndLine = focusLine, - Content = focusText, - Score = result.Score, - Visibility = result.Visibility, - GuardEvidence = guardEvidence.Count == 0 ? null : guardEvidence, - GuardChecks = guardChecks.Count == 0 ? null : guardChecks, - ChunkId = result.ChunkId, - NextOffset = result.NextOffset, - }); - } + filtered.Add(new SearchResult + { + Path = result.Path, + Lang = result.Lang, + StartLine = primaryMatch.LineNumber, + EndLine = primaryMatch.LineNumber, + Content = primaryMatch.Text, + Score = result.Score, + Visibility = result.Visibility, + GuardEvidence = guardEvidence.Count == 0 ? null : guardEvidence, + GuardChecks = guardChecks.Count == 0 ? null : guardChecks, + ChunkId = result.ChunkId, + NextOffset = result.NextOffset, + }); } return filtered; } - private static List<(int LineNumber, string Text)> FindPrimarySearchMatchLines(SearchResult result, SearchPrimaryMatchContext context) + private static List<SearchPrimaryMatch> FindPrimarySearchMatchLines(SearchResult result, SearchPrimaryMatchContext context) { if (context.Terms.Length == 0) { foreach (var (lineIndex, text) in EnumerateContentLines(result.Content)) - return [(result.StartLine + lineIndex, text)]; + return [new SearchPrimaryMatch(result.StartLine + lineIndex, text, 1, 1)]; - return [(result.StartLine, string.Empty)]; + return [new SearchPrimaryMatch(result.StartLine, string.Empty, 1, 1)]; } var normalizeCSharp = context.ShouldNormalizeCSharp(result); - var matches = new List<(int LineNumber, string Text)>(); + var matches = new List<SearchPrimaryMatch>(); foreach (var (lineIndex, text) in EnumerateContentLines(result.Content)) { var line = normalizeCSharp ? CSharpVerbatimNameNormalizer.Normalize(text) : text; var lineMatches = context.RequireAllTermsOnLine ? context.Terms.All(term => line.Contains(term, context.Comparison)) : context.Terms.Any(term => line.Contains(term, context.Comparison)); - if (lineMatches) - matches.Add((result.StartLine + lineIndex, text)); + if (lineMatches && TryFindPrimaryMatchSpan(text, line, context, out var column, out var length)) + matches.Add(new SearchPrimaryMatch(result.StartLine + lineIndex, text, column, length)); } return matches; } + private sealed record SearchPrimaryMatch(int LineNumber, string Text, int Column, int Length); + + private static bool TryFindPrimaryMatchSpan(string text, string normalizedLine, SearchPrimaryMatchContext context, out int column, out int length) + { + var bestIndex = int.MaxValue; + var bestLength = 0; + foreach (var term in context.Terms) + { + var index = text.IndexOf(term, context.Comparison); + if (index < 0) + index = normalizedLine.IndexOf(term, context.Comparison); + if (index < 0 || index >= bestIndex) + continue; + + bestIndex = index; + bestLength = term.Length; + } + + if (bestIndex == int.MaxValue) + { + column = 1; + length = 1; + return false; + } + + column = bestIndex + 1; + length = Math.Max(1, Math.Min(bestLength, Math.Max(1, text.Length - bestIndex))); + return true; + } + private sealed record SearchPrimaryMatchContext( string[] Terms, bool RawQuery, @@ -636,12 +680,17 @@ private sealed record SearchGuardEvaluation(int WindowStartLine, int WindowEndLi private SearchGuardEvaluation FindGuardEvidence( string path, - int focusLine, + SearchPrimaryMatch primaryMatch, SearchGuardFilter filter, int guardWindow, + SearchGuardScope guardScope, string? lang, Dictionary<SearchGuardLineWindowKey, SortedDictionary<int, string>> lineWindowCache) { + if (guardScope == SearchGuardScope.SameLine) + return FindSameLineGuardEvidence(path, primaryMatch, filter, lang); + + var focusLine = primaryMatch.LineNumber; var windowStart = filter.Direction == SearchGuardDirection.Before ? Math.Max(1, focusLine - guardWindow) : focusLine + 1; @@ -678,6 +727,7 @@ private SearchGuardEvaluation FindGuardEvidence( { Role = role, Direction = direction, + Scope = FormatSearchGuardScope(guardScope), Query = filter.Query, Name = FormatSearchGuardName(filter), Pattern = filter.Query, @@ -699,7 +749,89 @@ private SearchGuardEvaluation FindGuardEvidence( return new SearchGuardEvaluation(windowStart, windowEnd, Evidence: null); } - private static SearchGuardCheck CreateSearchGuardCheck(SearchGuardFilter filter, SearchGuardEvaluation evaluation, bool matched, bool passed) + private SearchGuardEvaluation FindSameLineGuardEvidence( + string path, + SearchPrimaryMatch primaryMatch, + SearchGuardFilter filter, + string? lang) + { + var guardQuery = NormalizeGuardQuery(filter.Query, lang); + if (guardQuery.Length == 0) + return new SearchGuardEvaluation(primaryMatch.LineNumber, primaryMatch.LineNumber, Evidence: null); + + var candidate = string.Equals(lang, "csharp", StringComparison.OrdinalIgnoreCase) + ? CSharpVerbatimNameNormalizer.Normalize(primaryMatch.Text) + : primaryMatch.Text; + if (TryFindSameLineGuardMatch(primaryMatch, filter, guardQuery, candidate, out var matchIndex, out var matchLength)) + { + var column = matchIndex + 1; + var length = Math.Max(1, Math.Min(matchLength, primaryMatch.Text.Length - matchIndex)); + var facet = SearchMatchClassifier.Classify(path, lang, primaryMatch.LineNumber, primaryMatch.Text, column, length); + var role = FormatSearchGuardRole(filter.Role); + var direction = FormatSearchGuardDirection(filter.Direction); + return new SearchGuardEvaluation(primaryMatch.LineNumber, primaryMatch.LineNumber, new SearchGuardEvidence + { + Role = role, + Direction = direction, + Scope = FormatSearchGuardScope(SearchGuardScope.SameLine), + Query = filter.Query, + Name = FormatSearchGuardName(filter), + Pattern = filter.Query, + Relationship = direction, + Span = new SearchGuardSpan + { + Line = primaryMatch.LineNumber, + Column = column, + Length = length, + }, + Line = primaryMatch.LineNumber, + Column = column, + Length = length, + Origin = facet.Origin, + Text = primaryMatch.Text, + }); + } + + return new SearchGuardEvaluation(primaryMatch.LineNumber, primaryMatch.LineNumber, Evidence: null); + } + + private static bool TryFindSameLineGuardMatch( + SearchPrimaryMatch primaryMatch, + SearchGuardFilter filter, + string guardQuery, + string candidate, + out int matchIndex, + out int matchLength) + { + var primaryStartIndex = Math.Max(0, primaryMatch.Column - 1); + var primaryEndExclusive = primaryStartIndex + Math.Max(1, primaryMatch.Length); + var searchIndex = 0; + while (searchIndex < candidate.Length) + { + var index = candidate.IndexOf(guardQuery, searchIndex, StringComparison.OrdinalIgnoreCase); + if (index < 0) + break; + + var length = Math.Max(1, guardQuery.Length); + var before = index + length <= primaryStartIndex; + var after = index >= primaryEndExclusive; + if ((filter.Direction == SearchGuardDirection.Before && before) || + (filter.Direction == SearchGuardDirection.After && after)) + { + matchIndex = Math.Min(index, Math.Max(0, primaryMatch.Text.Length - 1)); + matchLength = length; + return true; + } + + searchIndex = index + 1; + } + + matchIndex = 0; + matchLength = 0; + return false; + } + + private static SearchGuardCheck CreateSearchGuardCheck(SearchGuardFilter filter, SearchGuardScope guardScope, SearchGuardEvaluation evaluation, bool matched, bool passed) { var role = FormatSearchGuardRole(filter.Role); var direction = FormatSearchGuardDirection(filter.Direction); @@ -708,6 +840,7 @@ private static SearchGuardCheck CreateSearchGuardCheck(SearchGuardFilter filter, { Role = role, Direction = direction, + Scope = FormatSearchGuardScope(guardScope), Query = filter.Query, Name = name, Pattern = filter.Query, @@ -743,6 +876,9 @@ private static string FormatSearchGuardSummary(string name, string pattern, bool : $"{name} {outcome}: matched {evidence.Origin} at line {evidence.Line}, column {evidence.Column}"; } + private static string FormatSearchGuardScope(SearchGuardScope scope) + => scope == SearchGuardScope.SameLine ? "same_line" : "window"; + private SortedDictionary<int, string> ReadLineWindow( string path, int startLine, diff --git a/src/CodeIndex/Mcp/McpToolArgumentContracts.cs b/src/CodeIndex/Mcp/McpToolArgumentContracts.cs index 7e98f77691..f4db6cd27e 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", "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" }, + "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", "guardScope", "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 dc27e72c5e..4c40bb1b1b 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -51,6 +51,7 @@ private JsonNode HandleToolsList(JsonNode? id) ["rejectBefore"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Drop search matches when this guard query appears within `guardWindow` lines before the primary match. Accepts a string or string array." }, ["rejectAfter"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Drop search matches when this guard query appears within `guardWindow` lines after the primary match. Accepts a string or string array." }, ["guardWindow"] = new JsonObject { ["type"] = "integer", ["description"] = $"Line window for guard queries (default: {DbReader.DefaultSearchGuardWindow}, max: {DbReader.MaxSearchGuardWindow}).", ["default"] = DbReader.DefaultSearchGuardWindow, ["minimum"] = 0, ["maximum"] = DbReader.MaxSearchGuardWindow }, + ["guardScope"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "window", "same-line" }, ["description"] = "Evaluate guard queries in the line window or only on the same line before/after the primary match.", ["default"] = "window" }, ["countOnly"] = new JsonObject { ["type"] = "boolean", ["description"] = "Return only count metadata and a small top-file histogram; omit row payloads.", ["default"] = false }, ["format"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray { "full", "count", "compact" }, ["description"] = "Response shape: full rows, count-only metadata, or compact file/line rows without snippets.", ["default"] = "full" } }, diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index daff4b8c83..ec115cd350 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -622,6 +622,29 @@ private static List<string> ReadStringOrCommaSeparatedList(JsonNode? args, strin return null; } + private JsonNode? TryReadSearchGuardScope(JsonNode? id, JsonNode? args, out SearchGuardScope guardScope) + { + guardScope = SearchGuardScope.Window; + var node = args?["guardScope"]; + if (node is null) + return null; + if (node is not JsonValue value || !value.TryGetValue<string>(out var rawScope)) + return CreateToolErrorResponse(id, "'guardScope' must be a string: window or same-line."); + + switch (rawScope.Trim().ToLowerInvariant().Replace("_", "-")) + { + case "window": + guardScope = SearchGuardScope.Window; + return null; + case "same-line": + case "sameline": + guardScope = SearchGuardScope.SameLine; + return null; + default: + return CreateToolErrorResponse(id, $"'guardScope' must be window or same-line; got '{rawScope}'."); + } + } + private static JsonObject? ValidateCommonListArguments(JsonNode? args) { foreach (var propertyName in new[] { "path", "project", "excludePaths", "names", "sections", "capability", "scopes", "visibility", "excludeVisibility", "includeSymbolKind", "excludeSymbolKind", "commits", "changedBetween", "files" }) @@ -870,7 +893,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "project" or "capability" or "scopes" or "visibility" or "excludeVisibility" or "includeSymbolKind" or "excludeSymbolKind" or "commits" or "changedBetween" or "files" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", - "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or + "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or "guardScope" 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" or "auditScope" => "string", @@ -1752,6 +1775,8 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) return CreateToolErrorResponse(id, "'prefix' cannot be combined with 'exact' / 'exactSubstring' (exact uses instr(), not FTS5 prefix phrases)."); if (TryReadSearchGuardFilters(id, args, out var guardFilters) is JsonNode guardError) return guardError; + if (TryReadSearchGuardScope(id, args, out var guardScope) is JsonNode guardScopeError) + return guardScopeError; var guardWindow = args?["guardWindow"]?.GetValue<int>() ?? DbReader.DefaultSearchGuardWindow; if (guardWindow < 0 || guardWindow > DbReader.MaxSearchGuardWindow) return CreateToolErrorResponse(id, $"'guardWindow' must be between 0 and {DbReader.MaxSearchGuardWindow}; got {guardWindow}."); @@ -1764,7 +1789,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) List<SearchResult> countResults; try { - countResults = reader.Search(query, MaxLimit, lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix, guardFilters: guardFilters, guardWindow: guardWindow); + countResults = reader.Search(query, MaxLimit, lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix, guardFilters: guardFilters, guardWindow: guardWindow, guardScope: guardScope); } catch (SearchQueryLimitException) { @@ -1793,7 +1818,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) List<SearchResult> results; try { - results = reader.Search(query, FetchLimitForEnvelope(limit), lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix, cursor: cursor, guardFilters: guardFilters, guardWindow: guardWindow); + results = reader.Search(query, FetchLimitForEnvelope(limit), lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix, cursor: cursor, guardFilters: guardFilters, guardWindow: guardWindow, guardScope: guardScope); } catch (SearchQueryLimitException) { @@ -1923,6 +1948,8 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe return CreateToolErrorResponse(id, "'cursor' is not supported for recipe execution."); if (TryReadSearchGuardFilters(id, args, out var guardFilters) is JsonNode guardError) return guardError; + if (TryReadSearchGuardScope(id, args, out var guardScope) is JsonNode guardScopeError) + return guardScopeError; var guardWindow = args?["guardWindow"]?.GetValue<int>() ?? DbReader.DefaultSearchGuardWindow; if (guardWindow < 0 || guardWindow > DbReader.MaxSearchGuardWindow) return CreateToolErrorResponse(id, $"'guardWindow' must be between 0 and {DbReader.MaxSearchGuardWindow}; got {guardWindow}."); @@ -1950,7 +1977,8 @@ private JsonNode ExecuteSearchRecipe(JsonNode? id, JsonNode? args, string recipe exact, false, guardFilters: guardFilters, - guardWindow: guardWindow); + guardWindow: guardWindow, + guardScope: guardScope); } catch (SearchQueryLimitException ex) { diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index ad80d1e801..bad5dc9bda 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -84,12 +84,19 @@ public enum SearchGuardDirection After, } +public enum SearchGuardScope +{ + Window, + SameLine, +} + public sealed record SearchGuardFilter(SearchGuardRole Role, SearchGuardDirection Direction, string Query); public sealed class SearchGuardEvidence { public string Role { get; set; } = string.Empty; public string Direction { get; set; } = string.Empty; + public string Scope { get; set; } = string.Empty; public string Query { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Pattern { get; set; } = string.Empty; @@ -106,6 +113,7 @@ public sealed class SearchGuardCheck { public string Role { get; set; } = string.Empty; public string Direction { get; set; } = string.Empty; + public string Scope { get; set; } = string.Empty; public string Query { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Pattern { get; set; } = string.Empty; diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 9bd01e4776..ce906de06a 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>] [--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>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>] [--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>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>] [--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>] [--guard-scope <window|same-line>]", 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>] [--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>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>] [--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>] [--duplicate-confidence <low|medium|high>|--duplicate-threshold <score>] [--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>] [--guard-scope <window|same-line>]", 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/QueryCommandRunnerSearchGuardTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs index f6b02fbca9..0116834381 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs @@ -17,6 +17,7 @@ public void ParseArgs_ParsesSearchGuardFlags_Issue2852() "--reject-before", "NoSizeCap", "--reject-after", "FileMode.Create", "--guard-window", "12", + "--guard-scope", "same-line", ], jsonDefault: true); Assert.Equal("RunSearch", options.Query); @@ -34,6 +35,7 @@ public void ParseArgs_ParsesSearchGuardFlags_Issue2852() Assert.Equal(SearchGuardDirection.After, options.GuardFilters[3].Direction); Assert.Equal("FileMode.Create", options.GuardFilters[3].Query); Assert.Equal(12, options.GuardWindow); + Assert.Equal(SearchGuardScope.SameLine, options.GuardScope); } [Fact] @@ -172,6 +174,7 @@ public void NonAtomic(string path) var evidence = Assert.Single(row.GetProperty("guard_evidence").EnumerateArray()); Assert.Equal("require", evidence.GetProperty("role").GetString()); Assert.Equal("after", evidence.GetProperty("direction").GetString()); + Assert.Equal("window", evidence.GetProperty("scope").GetString()); Assert.Equal("File.Move", evidence.GetProperty("query").GetString()); Assert.Equal("require-after", evidence.GetProperty("name").GetString()); Assert.Equal("File.Move", evidence.GetProperty("pattern").GetString()); @@ -190,6 +193,7 @@ public void NonAtomic(string path) var requireCheck = Assert.Single(checks, check => check.GetProperty("name").GetString() == "require-after"); Assert.True(requireCheck.GetProperty("matched").GetBoolean()); Assert.True(requireCheck.GetProperty("passed").GetBoolean()); + Assert.Equal("window", requireCheck.GetProperty("scope").GetString()); Assert.Equal(8, requireCheck.GetProperty("window_start_line").GetInt32()); Assert.Equal(9, requireCheck.GetProperty("window_end_line").GetInt32()); Assert.Contains("matched code", requireCheck.GetProperty("summary").GetString()); @@ -198,6 +202,7 @@ public void NonAtomic(string path) var rejectCheck = Assert.Single(checks, check => check.GetProperty("name").GetString() == "reject-before"); Assert.False(rejectCheck.GetProperty("matched").GetBoolean()); Assert.True(rejectCheck.GetProperty("passed").GetBoolean()); + Assert.Equal("window", rejectCheck.GetProperty("scope").GetString()); Assert.Equal("NoAtomicMarker", rejectCheck.GetProperty("pattern").GetString()); Assert.False(rejectCheck.TryGetProperty("evidence", out _)); } @@ -207,6 +212,58 @@ public void NonAtomic(string path) } } + [Fact] + public void RunSearch_GuardScopeSameLineUsesPrimaryMatchColumns_Issue3730() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_guard_same_line_scope"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + """ + public class App + { + public void Run() + { + GuardBefore(); DangerousCall(); + GuardBefore(); DangerousCall(); PostGuard(); + GuardBefore(); + DangerousCall(); + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["DangerousCall", "--db", dbPath, "--exact-substring", "--require-before", "GuardBefore", "--reject-after", "PostGuard", "--guard-scope", "same-line", "--json=array"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var row = Assert.Single(document.RootElement.EnumerateArray()); + Assert.Contains("GuardBefore(); DangerousCall();", row.GetProperty("snippet").GetString()); + Assert.DoesNotContain("PostGuard", row.GetProperty("snippet").GetString()); + + var evidence = Assert.Single(row.GetProperty("guard_evidence").EnumerateArray()); + Assert.Equal("same_line", evidence.GetProperty("scope").GetString()); + Assert.Equal("GuardBefore", evidence.GetProperty("query").GetString()); + Assert.Equal(9, evidence.GetProperty("column").GetInt32()); + + var checks = row.GetProperty("guard_checks").EnumerateArray().ToArray(); + Assert.Equal(2, checks.Length); + Assert.All(checks, check => Assert.Equal("same_line", check.GetProperty("scope").GetString())); + Assert.True(Assert.Single(checks, check => check.GetProperty("name").GetString() == "require-before").GetProperty("passed").GetBoolean()); + Assert.True(Assert.Single(checks, check => check.GetProperty("name").GetString() == "reject-after").GetProperty("passed").GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_GuardFiltersApplyToCount_Issue2852() { @@ -309,5 +366,6 @@ public void SearchUsageLineListsGuardFlags_Issue2852() Assert.Contains("--reject-before <query>", usage); Assert.Contains("--reject-after <query>", usage); Assert.Contains("--guard-window <n>", usage); + Assert.Contains("--guard-scope <window|same-line>", usage); } } From c5e8e3ec82bfdcf8f3890c29fbc70e2a3adba364 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Sat, 20 Jun 2026 21:05:30 +0900 Subject: [PATCH 4/4] Stream guarded search filtering with diagnostics --- changelog.d/unreleased/3746.fixed.md | 18 +++ src/CodeIndex/Cli/SearchSnippetFormatter.cs | 3 + src/CodeIndex/Database/DbSearchReader.cs | 122 ++++++++++++++++-- src/CodeIndex/Models/QueryResults.cs | 12 ++ .../QueryCommandRunnerSearchGuardTests.cs | 3 + 5 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 changelog.d/unreleased/3746.fixed.md diff --git a/changelog.d/unreleased/3746.fixed.md b/changelog.d/unreleased/3746.fixed.md new file mode 100644 index 0000000000..ba42e4ea69 --- /dev/null +++ b/changelog.d/unreleased/3746.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 3746 +affected: + - src/CodeIndex/Database/DbSearchReader.cs + - src/CodeIndex/Models/QueryResults.cs + - src/CodeIndex/Cli/SearchSnippetFormatter.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs +--- + +## English + +- **Guarded search now retains less intermediate state (#3746)** — guard filtering is applied while ranked candidates are read, deduplication state has per-file/file-count budgets, and compact JSON results can include bounded `diagnostics` when guard candidates or dedup state are truncated. + +## 日本語 + +- **guard 付き search が保持する中間状態を減らしました (#3746)** — ranked candidates の読み込み中に guard filtering を適用し、deduplication state に file 数 / file 単位の上限を設けました。guard candidates や dedup state が打ち切られた場合、compact JSON result に bounded な `diagnostics` を出せます。 diff --git a/src/CodeIndex/Cli/SearchSnippetFormatter.cs b/src/CodeIndex/Cli/SearchSnippetFormatter.cs index 095f572361..a25318986e 100644 --- a/src/CodeIndex/Cli/SearchSnippetFormatter.cs +++ b/src/CodeIndex/Cli/SearchSnippetFormatter.cs @@ -119,6 +119,7 @@ public static CompactSearchResult ToCompactResult(SearchResult result, SearchSni TruncationContext = excerpt.TruncationContext, GuardEvidence = result.GuardEvidence, GuardChecks = result.GuardChecks, + Diagnostics = result.Diagnostics, Score = result.Score, EnclosingSymbolName = result.EnclosingSymbolName, EnclosingSymbolKind = result.EnclosingSymbolKind, @@ -902,6 +903,8 @@ public sealed class CompactSearchResult [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List<SearchGuardCheck>? GuardChecks { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List<SearchDiagnostic>? Diagnostics { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public SearchQueryHint? ExactSubstringHint { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List<SearchCommandHint>? NextSteps { get; set; } diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index a8cd987167..de390fb3c5 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -25,6 +25,11 @@ public partial class DbReader private const int MinGuardedSearchCandidates = 200; private const int GuardedSearchOverFetchFactor = 50; private const int MaxSearchGuardLineWindowCacheEntries = 256; + private const int MaxSearchDedupTrackedFiles = 4096; + private const int MaxSearchDedupMatchLinesPerFile = 8192; + private const int MaxSearchDedupIntervalsPerFile = 4096; + private const string SearchGuardCandidatesTruncatedDiagnosticCode = "search_guard_candidates_truncated"; + private const string SearchDedupStateTruncatedDiagnosticCode = "search_dedup_state_truncated"; /// <summary> /// Sanitize user input for FTS5 MATCH by quoting each literal term or phrase. @@ -198,14 +203,17 @@ FROM fts_chunks AddPathIncludeFilterParameters(cmd, requiredPathPatterns, "requiredPathPattern"); var raw = new List<SearchResult>(); + var guardMatchContext = hasGuardFilters ? SearchPrimaryMatchContext.Create(query, normalizedQuery, rawQuery, exact, lang) : null; + var guardLineWindowCache = hasGuardFilters ? new Dictionary<SearchGuardLineWindowKey, SortedDictionary<int, string>>() : null; var nextOffset = hasGuardFilters ? 0 : cursor?.Offset ?? 0; + var guardCandidateLimitReached = false; try { using var reader = cmd.ExecuteTrackedReader(); while (reader.TrackedRead()) { nextOffset++; - raw.Add(new SearchResult + var result = new SearchResult { Path = reader.GetString(0), Lang = GetNullableString(reader, 1), @@ -216,7 +224,20 @@ FROM fts_chunks Visibility = GetNullableString(reader, 6), ChunkId = reader.GetInt64(7), NextOffset = nextOffset, - }); + }; + if (!hasGuardFilters) + { + raw.Add(result); + continue; + } + + if (nextOffset > guardedCandidateLimit) + { + guardCandidateLimitReached = true; + continue; + } + + raw.AddRange(FilterSearchResultByGuards(result, guardMatchContext!, guardFilters!, guardWindow, guardScope, guardLineWindowCache!)); } } catch (SqliteException ex) when (rawQuery && IsFtsQuerySyntaxError(ex)) @@ -224,19 +245,22 @@ FROM fts_chunks throw new FtsQuerySyntaxException(ex.Message, ex); } - var guardCandidateLimitReached = hasGuardFilters && raw.Count > guardedCandidateLimit; - if (guardCandidateLimitReached) - raw.RemoveRange(guardedCandidateLimit, raw.Count - guardedCandidateLimit); - - if (hasGuardFilters) - raw = FilterBySearchGuards(raw, SearchPrimaryMatchContext.Create(query, normalizedQuery, rawQuery, exact, lang), guardFilters!, guardWindow, guardScope); - var results = deduplicate ? DeduplicateOverlappingResults(raw, SearchPrimaryMatchContext.Create(query, normalizedQuery, rawQuery, exact, lang)) : raw; if (guardCandidateLimitReached && results.Count < GetGuardedSearchRequestedPageEnd(guardedRequestedLimit, cursor)) throw new SearchGuardCandidateLimitException(guardedCandidateLimit, guardedRequestedLimit, cursor?.Offset ?? 0); - AttachSearchEnclosingSymbols(results, searchMatchLineContext); - return hasGuardFilters ? PageGuardedSearchResults(results, limit, cursor) : results; + var pagedResults = hasGuardFilters ? PageGuardedSearchResults(results, limit, cursor) : results; + if (guardCandidateLimitReached && pagedResults.Count > 0) + { + AddSearchDiagnostic( + pagedResults[0], + SearchGuardCandidatesTruncatedDiagnosticCode, + $"Guard filtering inspected the first {guardedCandidateLimit} ranked candidates before pagination; narrow the query or add path/lang filters if later matches are needed.", + limit: guardedCandidateLimit); + } + + AttachSearchEnclosingSymbols(pagedResults, searchMatchLineContext); + return pagedResults; } private static int GetGuardedSearchCandidateLimit(int limit, SearchCursor? cursor) @@ -578,6 +602,7 @@ private List<SearchResult> FilterSearchResultByGuards( Visibility = result.Visibility, GuardEvidence = guardEvidence.Count == 0 ? null : guardEvidence, GuardChecks = guardChecks.Count == 0 ? null : guardChecks, + Diagnostics = result.Diagnostics, ChunkId = result.ChunkId, NextOffset = result.NextOffset, }); @@ -1382,6 +1407,17 @@ private static bool AddSearchResultDedupCoverage( { if (!keptIntervals.TryGetValue(result.Path, out var intervals)) { + if (keptIntervals.Count >= MaxSearchDedupTrackedFiles) + { + AddSearchDiagnostic( + result, + SearchDedupStateTruncatedDiagnosticCode, + $"Search deduplication reached the tracked-file budget ({MaxSearchDedupTrackedFiles}); this result was kept without adding more file state.", + result.Path, + MaxSearchDedupTrackedFiles); + return true; + } + intervals = new IntervalSet(); keptIntervals[result.Path] = intervals; } @@ -1397,6 +1433,17 @@ private static bool AddSearchResultDedupCoverage( { if (!keptMatchLines.TryGetValue(result.Path, out var lines)) { + if (keptMatchLines.Count >= MaxSearchDedupTrackedFiles) + { + AddSearchDiagnostic( + result, + SearchDedupStateTruncatedDiagnosticCode, + $"Search deduplication reached the tracked-file budget ({MaxSearchDedupTrackedFiles}) for match lines; this result was kept without adding more line state.", + result.Path, + MaxSearchDedupTrackedFiles); + return true; + } + lines = []; keptMatchLines[result.Path] = lines; } @@ -1404,15 +1451,55 @@ private static bool AddSearchResultDedupCoverage( var added = false; foreach (var line in matchLines) { - if (lines.Add(line)) + if (lines.Contains(line)) + continue; + if (lines.Count >= MaxSearchDedupMatchLinesPerFile) + { + AddSearchDiagnostic( + result, + SearchDedupStateTruncatedDiagnosticCode, + $"Search deduplication reached the per-file match-line budget ({MaxSearchDedupMatchLinesPerFile}); this result was kept without adding more line state.", + result.Path, + MaxSearchDedupMatchLinesPerFile); added = true; + break; + } + + lines.Add(line); + added = true; } if (!added) return false; } - return intervals.AddIfAddsCoverage(result.StartLine, result.EndLine); + var addsCoverage = intervals.AddIfAddsCoverage(result.StartLine, result.EndLine, MaxSearchDedupIntervalsPerFile, out var intervalStateTruncated); + if (intervalStateTruncated) + { + AddSearchDiagnostic( + result, + SearchDedupStateTruncatedDiagnosticCode, + $"Search deduplication reached the per-file interval budget ({MaxSearchDedupIntervalsPerFile}); this result was kept without adding more interval state.", + result.Path, + MaxSearchDedupIntervalsPerFile); + } + + return addsCoverage; + } + + private static void AddSearchDiagnostic(SearchResult result, string code, string message, string? path = null, int? limit = null) + { + result.Diagnostics ??= []; + if (result.Diagnostics.Any(diagnostic => string.Equals(diagnostic.Code, code, StringComparison.Ordinal))) + return; + + result.Diagnostics.Add(new SearchDiagnostic + { + Code = code, + Message = message, + Path = path, + Limit = limit, + }); } private sealed class IntervalSet @@ -1444,8 +1531,9 @@ public bool Contains(int start, int end) return false; } - public bool AddIfAddsCoverage(int start, int end) + public bool AddIfAddsCoverage(int start, int end, int maxIntervals, out bool stateTruncated) { + stateTruncated = false; if (end < start) (start, end) = (end, start); @@ -1482,6 +1570,12 @@ public bool AddIfAddsCoverage(int start, int end) if (removeCount > 0) _intervals.RemoveRange(firstMergeIndex, removeCount); + if (_intervals.Count >= maxIntervals) + { + stateTruncated = true; + return true; + } + _intervals.Insert(firstMergeIndex, (mergeStart, mergeEnd)); return true; } diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index bad5dc9bda..1012907929 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -34,12 +34,24 @@ public class SearchResult public List<SearchGuardEvidence>? GuardEvidence { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List<SearchGuardCheck>? GuardChecks { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List<SearchDiagnostic>? Diagnostics { get; set; } [JsonIgnore] public long ChunkId { get; set; } [JsonIgnore] public int NextOffset { get; set; } } +public sealed class SearchDiagnostic +{ + public string Code { get; set; } = string.Empty; + public string Message { get; set; } = string.Empty; + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Path { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Limit { get; set; } +} + public readonly record struct SearchCursor(double Score, long ChunkId, int Offset); public readonly record struct QueryCountResult(int Count, int FileCount, bool IncludesSql = false); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs index 0116834381..2134dbd38f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchGuardTests.cs @@ -348,6 +348,9 @@ public void Run() using var document = ParseJsonOutput(stdout); var row = Assert.Single(document.RootElement.EnumerateArray()); Assert.Equal("src/GuardBudgetNeedle.cs", row.GetProperty("path").GetString()); + var diagnostic = Assert.Single(row.GetProperty("diagnostics").EnumerateArray()); + Assert.Equal("search_guard_candidates_truncated", diagnostic.GetProperty("code").GetString()); + Assert.Equal(200, diagnostic.GetProperty("limit").GetInt32()); } finally {