diff --git a/README.md b/README.md index c5ccebd387..3c440aa481 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,7 @@ cdidx status --check --json cdidx search "handleRequest" cdidx definition UserService cdidx search "Handle" --project MyApp +cdidx search "File.ReadAllText" --exact-substring --reject-before "Length" --guard-window 8 cdidx validate cdidx mcp cdidx lsp --db .cdidx/codeindex.db @@ -350,6 +351,7 @@ cdidx status --check --json cdidx search "handleRequest" cdidx definition UserService cdidx search "Handle" --project MyApp +cdidx search "File.ReadAllText" --exact-substring --reject-before "Length" --guard-window 8 cdidx validate cdidx mcp cdidx lsp --db .cdidx/codeindex.db diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 225521bee0..40c8f6323c 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -817,6 +817,8 @@ cdidx search "計算" --prefix # widen every token to cdidx search "content:auth*" --fts # raw FTS5 syntax; `content:` is the only valid column qualifier, and NEAR distance is capped at 100 cdidx search "Run();" --exact-substring # case-sensitive exact substring, no FTS5 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 "--open-reports" --path README.md --count # quoted literal that starts with -- cdidx search --query "--path" --path README.md # search for an option-looking literal ``` @@ -825,6 +827,14 @@ Search normalizes literal FTS queries to Unicode NFC before matching. If every literal token exceeds SQLite FTS5 unicode61's 1000-character token cap, zero-result JSON includes `query_degraded_reason` and `tokens_dropped`. Index validation reports long unbroken FTS tokens as `fts_token_too_long`. +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 +`guard_evidence` for required guards that matched. +The MCP `search` tool exposes the same mode as camelCase arguments: +`requireBefore`, `requireAfter`, `rejectBefore`, `rejectAfter`, and +`guardWindow`. ### Debugging queries @@ -2933,6 +2943,8 @@ cdidx search "計算" --prefix # クエリ全体を p cdidx search "content:auth*" --fts # 生のFTS5構文。列修飾子は `content:` だけが有効で、NEAR distance は 100 まで cdidx search "Run();" --exact-substring # 大文字小文字区別の完全部分一致、FTS5 なし 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 "--open-reports" --path README.md --count # `--` で始まる引用済みリテラル cdidx search --query "--path" --path README.md # オプションに見えるリテラルを検索 ``` @@ -2941,6 +2953,12 @@ literal FTS クエリは照合前に Unicode NFC へ正規化されます。す token が SQLite FTS5 unicode61 の 1000 文字 token 上限を超える場合、0 件 JSON には `query_degraded_reason` と `tokens_dropped` が含まれます。index validation は長い連続 FTS token を `fts_token_too_long` として報告します。 +guard-aware search は primary の `search` 一致を近傍の literal guard で絞り込みます: +`--require-before` / `--require-after` は指定行窓内に guard query がある場合だけ残し、 +`--reject-before` / `--reject-after` は guard query がある一致を落とします。JSON の検索結果には +一致した required guard の `guard_evidence` が含まれます。 +MCP `search` tool では同じ mode を camelCase 引数 `requireBefore`, `requireAfter`, +`rejectBefore`, `rejectAfter`, `guardWindow` で指定できます。 ### クエリのデバッグ diff --git a/changelog.d/unreleased/2852.added.md b/changelog.d/unreleased/2852.added.md new file mode 100644 index 0000000000..09cfc24e04 --- /dev/null +++ b/changelog.d/unreleased/2852.added.md @@ -0,0 +1,19 @@ +--- +category: added +issues: + - 2852 +affected: + - src/CodeIndex/Database/DbSearchReader.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - USER_GUIDE.md +--- + +## English + +- **Added guard-aware search filters (#2852)** — `cdidx search` and the MCP `search` tool can now keep or reject primary matches based on nearby literal guard queries with before/after guard constraints. + +## 日本語 + +- **guard-aware search filter を追加しました (#2852)** — `cdidx search` と MCP `search` tool は、before/after の guard constraint により、primary の一致を近傍の literal guard query で残す / 除外できるようになりました。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 949d562f01..86779f7c5e 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -246,6 +246,11 @@ private static IReadOnlyList BuildAll() new() { Name = "--exact-name", Description = "Exact symbol-name equality", Commands = Set(ExactNameCommands), AlsoAcceptedBy = Set("search") }, new() { Name = "--exact-substring", Description = "Search-only exact substring match", Commands = Set("search"), AlsoAcceptedBy = Set(ExactSubstringAccepted) }, new() { Name = "--prefix", Description = "Trailing-asterisk prefix shorthand", Commands = Set("search") }, + new() { Name = "--require-before", ValuePlaceholder = "", Description = "Search: require a nearby guard query before each primary match", Commands = Set("search") }, + new() { Name = "--require-after", ValuePlaceholder = "", Description = "Search: require a nearby guard query after each primary match", Commands = Set("search") }, + 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 = "--no-progress", Description = "Disable animated progress and spinner output", Commands = Set(AllCommands.ToArray()) }, new() { Name = "--name", ValuePlaceholder = "", Description = "Exact symbol name", Commands = Set("symbols") }, new() { Name = "--max-line-width", ValuePlaceholder = "", Description = "Clamp long single-line payloads (0 disables clamping)", Commands = Set(MaxLineWidthCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 58df30ff72..5ac2518578 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -78,7 +78,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("index-commits", "cdidx index --commits [commit-ref ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-changed-between", "cdidx index --changed-between [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), ("index-files", "cdidx index --files [path ...] [--db ] [--verbose] [--dry-run] [--json] [--memory-trace] [--duration-format ] [--max-file-bytes ] [--include-symbol-kind [,]] [--exclude-symbol-kind [,]]"), - ("search", "cdidx search |--query |-- [--db ] [--json[=ndjson|array]] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank]"), + ("search", "cdidx search |--query |-- [--db ] [--json[=ndjson|array]] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--snippet-lines ] [--snippet-focus ] [--max-line-width ] [--fts] [--exact|--exact-substring] [--prefix] [--count] [--since ] [--no-dedup] [--no-visibility-rank] [--require-before ] [--require-after ] [--reject-before ] [--reject-after ] [--guard-window ]"), ("definition", "cdidx definition |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--exact|--exact-name] [--count] [--since ]"), ("goto", "cdidx goto |--query |-- [--db ] [--json] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--all]"), ("references", "cdidx references |--query |-- [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--exact|--exact-name] [--count]"), @@ -980,6 +980,8 @@ private static void PrintFlagReference(Action WriteHelpLine) WriteHelpLine(" --count Count only; search/definition/references/callers/callees/symbols/files/find/unused ignore --limit, impact/hotspots still use visible page counts"); Console.WriteLine(" --since Filter to files modified since this timestamp (ISO 8601)"); Console.WriteLine(" --no-dedup search only: return every raw overlapping chunk hit (debug/density)"); + WriteHelpLine($" --require-before/--require-after 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 search only: drop primary matches when the guard query appears within the same before/after window; useful for finding API calls missing nearby checks"); Console.WriteLine(" --bytes Show raw byte counts in human output for files/map instead of binary units; JSON always keeps raw integer bytes"); Console.WriteLine(" --min-entrypoint-confidence map only: omit entrypoint candidates below this 0.0..1.0 confidence"); WriteHelpLine(" --max-hops Max BFS hops for impact analysis, inclusive (default: 5; --max-hops 2 returns callers at hop 1 and 2; --max-hops 0 resolves the symbol without traversing callers)"); @@ -1012,6 +1014,8 @@ private static void PrintExamples() Console.WriteLine(" cdidx search \"auth*\" Prefix shorthand in literal-safe mode"); Console.WriteLine(" cdidx search --query --path --path README.md Search for a literal option token"); Console.WriteLine(" cdidx search \"Run();\" --exact-substring Case-sensitive exact substring search"); + Console.WriteLine(" cdidx search \"File.ReadAllText\" --exact-substring --reject-before \"Length\" --guard-window 8"); + Console.WriteLine(" Find calls without a nearby preceding size guard"); Console.WriteLine(" cdidx search authenticate --json=array Emit search results as one JSON array"); Console.WriteLine(" cdidx search authenticate --profile Append SQL profile JSON for slow-query debugging"); Console.WriteLine(" cdidx search authenticate --verbose Emit query debug diagnostics on stderr"); diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 540fcd3310..6f985b4675 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -432,6 +432,8 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(RepoModuleResult))] [JsonSerializable(typeof(ReportBundleSummary))] [JsonSerializable(typeof(SearchHighlight))] +[JsonSerializable(typeof(SearchGuardEvidence))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(SearchQueryHint))] [JsonSerializable(typeof(SearchResult))] [JsonSerializable(typeof(SearchTermOccurrence))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 5cf60d1ca2..33c1ac9fd1 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -81,6 +81,11 @@ public static class QueryCommandRunner "--snippet-lines", "--snippet-focus", "--path", + "--require-before", + "--require-after", + "--reject-before", + "--reject-after", + "--guard-window", "--project", "--solution", "--exclude-path", @@ -420,7 +425,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) { if (options.CountOnly) { - var counts = reader.CountSearchResults(options.Query, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank); + var counts = reader.CountSearchResults(options.Query, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, options.GuardFilters, options.GuardWindow); var queryDiagnostics = DbReader.AnalyzeFtsQuery(options.Query, options.RawFts, options.Prefix, options.Lang); if (counts.Count == 0) { @@ -448,7 +453,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.Success; } - var results = reader.Search(options.Query, options.Limit, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank); + var results = reader.Search(options.Query, options.Limit, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow); var ftsQueryDiagnostics = DbReader.AnalyzeFtsQuery(options.Query, options.RawFts, options.Prefix, options.Lang); if (results.Count == 0) { @@ -4936,6 +4941,8 @@ public static QueryCommandOptions ParseArgs( bool exact = false; bool regex = false; bool prefix = false; + var guardFilters = new List(); + var guardWindow = DbReader.DefaultSearchGuardWindow; List? parseErrors = null; bool exactName = false; bool exactSubstring = false; @@ -4972,6 +4979,22 @@ void AddParseError(string error) parseErrors.Add(error); } + void AddSearchGuardFilter(string optionName, SearchGuardRole role, SearchGuardDirection direction, string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + AddParseError(BuildMissingOptionValueError(optionName)); + return; + } + if (value.Length > QueryLimits.MaxQueryLength) + { + AddParseError($"Error: {optionName} query too long (max {QueryLimits.MaxQueryLength} characters)."); + return; + } + + guardFilters.Add(new SearchGuardFilter(role, direction, value)); + } + void AddStatusCheckScopes(string rawScopes) { if (string.IsNullOrWhiteSpace(rawScopes)) @@ -5164,6 +5187,48 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(queryError!); break; + case "--require-before": + if (TryReadStringOptionValue(args, ref i, "--require-before", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var requireBeforeValue, out var requireBeforeError)) + AddSearchGuardFilter("--require-before", SearchGuardRole.Require, SearchGuardDirection.Before, requireBeforeValue!); + else + AddParseError(requireBeforeError!); + break; + case "--require-after": + if (TryReadStringOptionValue(args, ref i, "--require-after", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var requireAfterValue, out var requireAfterError)) + AddSearchGuardFilter("--require-after", SearchGuardRole.Require, SearchGuardDirection.After, requireAfterValue!); + else + AddParseError(requireAfterError!); + break; + case "--reject-before": + if (TryReadStringOptionValue(args, ref i, "--reject-before", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var rejectBeforeValue, out var rejectBeforeError)) + AddSearchGuardFilter("--reject-before", SearchGuardRole.Reject, SearchGuardDirection.Before, rejectBeforeValue!); + else + AddParseError(rejectBeforeError!); + break; + case "--reject-after": + if (TryReadStringOptionValue(args, ref i, "--reject-after", inlineValue, allowSeparatedDashPrefixedLiteralValue: true, out var rejectAfterValue, out var rejectAfterError)) + AddSearchGuardFilter("--reject-after", SearchGuardRole.Reject, SearchGuardDirection.After, rejectAfterValue!); + else + AddParseError(rejectAfterError!); + break; + case "--guard-window": + if (!TryReadRawOptionValue(args, ref i, "--guard-window", inlineValue, out var guardWindowValue, out var missingGuardWindowError)) + { + AddParseError(missingGuardWindowError!); + } + else if (TryParseNonNegativeInt(guardWindowValue!, "--guard-window", out var parsedGuardWindow, out var guardWindowError)) + { + WarnIfDuplicateSingleValueOption("--guard-window", guardWindowValue!); + if (parsedGuardWindow > DbReader.MaxSearchGuardWindow) + AddParseError($"Error: --guard-window must be between 0 and {DbReader.MaxSearchGuardWindow}; got {parsedGuardWindow}."); + else + guardWindow = parsedGuardWindow; + } + else + { + AddParseError(guardWindowError!); + } + break; case "--kind": if (TryReadStringOptionValue(args, ref i, "--kind", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var kindValue, out var kindError)) { @@ -5600,6 +5665,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) } ValidateQueryPathOptionValues(userPathPatterns, excludePaths, AddParseError); + if (guardFilters.Count > DbReader.MaxSearchGuardFilters) + AddParseError($"Error: search accepts at most {DbReader.MaxSearchGuardFilters} guard filters; got {guardFilters.Count}."); if (validateDefaultLimit && !limitExplicit && defaultLimitError != null) AddParseError(defaultLimitError); @@ -5657,6 +5724,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) Exact = exact, Regex = regex, Prefix = prefix, + GuardFilters = guardFilters, + GuardWindow = guardWindow, ExactName = exactName, ExactSubstring = exactSubstring, CheckWorkspace = checkWorkspace, @@ -8343,6 +8412,8 @@ public sealed class QueryCommandOptions public bool Exact { get; init; } public bool Regex { get; init; } public bool Prefix { get; init; } + public List GuardFilters { get; init; } = []; + public int GuardWindow { get; init; } = DbReader.DefaultSearchGuardWindow; public bool ExactName { get; init; } public bool ExactSubstring { get; init; } public bool CheckWorkspace { get; init; } diff --git a/src/CodeIndex/Cli/SearchSnippetFormatter.cs b/src/CodeIndex/Cli/SearchSnippetFormatter.cs index e1b74d8351..98985ab738 100644 --- a/src/CodeIndex/Cli/SearchSnippetFormatter.cs +++ b/src/CodeIndex/Cli/SearchSnippetFormatter.cs @@ -51,6 +51,7 @@ public static CompactSearchResult ToCompactResult(SearchResult result, string qu TruncatedLineCount = excerpt.TruncatedLineCount, DroppedMatchLineCount = excerpt.DroppedMatchLineCount, TruncationContext = excerpt.TruncationContext, + GuardEvidence = result.GuardEvidence, Score = result.Score, }; } @@ -518,6 +519,8 @@ public sealed class CompactSearchResult public int DroppedMatchLineCount { get; set; } public SearchTruncationContext TruncationContext { get; set; } = new(); [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? GuardEvidence { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public SearchQueryHint? ExactSubstringHint { get; set; } public double Score { get; set; } } diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index dbf205234d..f373ecf505 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -16,6 +16,9 @@ public partial class DbReader internal const int MaxRawFtsNearOperators = 16; internal const int MaxRawFtsParenthesisDepth = 16; internal const int MaxRawFtsNearDistance = 100; + internal const int DefaultSearchGuardWindow = 8; + internal const int MaxSearchGuardWindow = 200; + internal const int MaxSearchGuardFilters = 8; /// /// Sanitize user input for FTS5 MATCH by quoting each token as a phrase. @@ -99,7 +102,7 @@ private static string FormatFtsToken(string token, bool prefix) /// Full-text search across indexed chunks using FTS5. /// FTS5を使ったチャンク全文検索。 /// - public List Search(string query, int limit = 20, string? lang = null, bool rawQuery = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, SearchCursor? cursor = null) + public List Search(string query, int limit = 20, string? lang = null, bool rawQuery = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, SearchCursor? cursor = null, IReadOnlyList? guardFilters = null, int guardWindow = DefaultSearchGuardWindow) { // Guard against empty/whitespace queries that would match everything // 空白のみのクエリが全件マッチするのを防止 @@ -109,6 +112,7 @@ public List Search(string query, int limit = 20, string? lang = nu lang = NormalizeQueryLanguage(lang); var normalizedQuery = rawQuery ? query : NormalizeLiteralSearchQuery(query, lang); var coverageTokens = exact ? new List() : GetSearchCoverageTokens(normalizedQuery, rawQuery); + var hasGuardFilters = guardFilters is { Count: > 0 }; using var cmd = _conn.CreateCommand(); string sql; @@ -149,8 +153,10 @@ FROM fts_chunks if (since != null && _fileColumns.Contains("modified")) sql += " AND f.modified >= @since"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); - sql += $" ORDER BY {GetSearchOrderSql(coverageTokens.Count)} LIMIT @limit"; - if (cursor is { }) + sql += $" ORDER BY {GetSearchOrderSql(coverageTokens.Count)}"; + if (!hasGuardFilters) + sql += " LIMIT @limit"; + if (cursor is { } && !hasGuardFilters) sql += " OFFSET @cursorOffset"; cmd.CommandText = sql; @@ -160,19 +166,20 @@ FROM fts_chunks cmd.Parameters.AddWithValue("@rankingQueryPrefix", $"{EscapeLikeQuery(normalizedQuery.Trim())}%"); cmd.Parameters.AddWithValue("@visibilityRank", visibilityRank ? 1 : 0); AddSearchCoverageParameters(cmd, coverageTokens); - cmd.Parameters.AddWithValue("@limit", limit); + if (!hasGuardFilters) + cmd.Parameters.AddWithValue("@limit", limit); if (lang != null) cmd.Parameters.AddWithValue("@lang", lang); if (since != null && _fileColumns.Contains("modified")) cmd.Parameters.AddWithValue("@since", since.Value); - if (cursor is { } searchCursorParameter) + if (cursor is { } searchCursorParameter && !hasGuardFilters) { cmd.Parameters.AddWithValue("@cursorOffset", searchCursorParameter.Offset); } AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); var raw = new List(); - var nextOffset = cursor?.Offset ?? 0; + var nextOffset = hasGuardFilters ? 0 : cursor?.Offset ?? 0; try { using var reader = cmd.ExecuteTrackedReader(); @@ -197,14 +204,25 @@ FROM fts_chunks { throw new FtsQuerySyntaxException(ex.Message, ex); } - return deduplicate ? DeduplicateOverlappingResults(raw) : raw; + + if (hasGuardFilters) + raw = FilterBySearchGuards(raw, query, normalizedQuery, rawQuery, exact, lang, guardFilters!, guardWindow); + + var results = deduplicate ? DeduplicateOverlappingResults(raw) : raw; + return hasGuardFilters ? PageGuardedSearchResults(results, limit, cursor) : results; } - public QueryCountResult CountSearchResults(string query, string? lang = null, bool rawQuery = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true) + public QueryCountResult CountSearchResults(string query, string? lang = null, bool rawQuery = false, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, bool deduplicate = true, DateTime? since = null, bool exact = false, bool prefix = false, bool visibilityRank = true, IReadOnlyList? guardFilters = null, int guardWindow = DefaultSearchGuardWindow) { if (string.IsNullOrWhiteSpace(query)) return new QueryCountResult(0, 0); + 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); + return new QueryCountResult(guardedResults.Count, guardedResults.Select(result => result.Path).Distinct(StringComparer.Ordinal).Count()); + } + lang = NormalizeQueryLanguage(lang); var normalizedQuery = rawQuery ? query : NormalizeLiteralSearchQuery(query, lang); var coverageTokens = exact ? new List() : GetSearchCoverageTokens(normalizedQuery, rawQuery); @@ -303,6 +321,207 @@ FROM fts_chunks return new QueryCountResult(count, fileCount); } + private List FilterBySearchGuards( + List results, + string query, + string normalizedQuery, + bool rawQuery, + bool exact, + string? lang, + IReadOnlyList guardFilters, + int guardWindow) + { + guardWindow = Math.Clamp(guardWindow, 0, MaxSearchGuardWindow); + var filtered = new List(results.Count); + foreach (var result in results) + { + foreach (var (focusLine, focusText) in FindPrimarySearchMatchLines(result, query, normalizedQuery, rawQuery, exact, lang)) + { + var guardEvidence = new List(); + var keep = true; + foreach (var filter in guardFilters) + { + var match = FindGuardEvidence(result.Path, focusLine, filter, guardWindow, lang ?? result.Lang); + var matched = match != null; + if (filter.Role == SearchGuardRole.Require && !matched) + { + keep = false; + break; + } + if (filter.Role == SearchGuardRole.Reject && matched) + { + keep = false; + break; + } + if (match != null) + guardEvidence.Add(match); + } + + 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, + ChunkId = result.ChunkId, + NextOffset = result.NextOffset, + }); + } + } + + return filtered; + } + + private static List<(int LineNumber, string Text)> FindPrimarySearchMatchLines(SearchResult result, string query, string normalizedQuery, bool rawQuery, bool exact, string? lang) + { + var terms = BuildPrimarySearchMatchTerms(query, normalizedQuery, rawQuery, exact); + var lines = SplitContentLines(result.Content); + if (terms.Length == 0) + return [(result.StartLine, lines.FirstOrDefault() ?? string.Empty)]; + + var normalizeCSharp = string.Equals(lang ?? result.Lang, "csharp", StringComparison.OrdinalIgnoreCase); + var comparison = exact ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + var requireAllTermsOnLine = !rawQuery && !exact && terms.Length > 1; + var matches = new List<(int LineNumber, string Text)>(); + for (var i = 0; i < lines.Length; i++) + { + var line = normalizeCSharp ? CSharpVerbatimNameNormalizer.Normalize(lines[i]) : lines[i]; + var lineMatches = requireAllTermsOnLine + ? terms.All(term => line.Contains(term, comparison)) + : terms.Any(term => line.Contains(term, comparison)); + if (lineMatches) + matches.Add((result.StartLine + i, lines[i])); + } + + return matches; + } + + private static string[] BuildPrimarySearchMatchTerms(string query, string normalizedQuery, bool rawQuery, bool exact) + { + IEnumerable rawTerms = !exact && !rawQuery + ? normalizedQuery.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries) + : [rawQuery ? query.Trim() : normalizedQuery.Trim()]; + var terms = rawTerms.Select(NormalizeGuardSearchTerm).ToList(); + if (!exact && rawQuery) + terms.AddRange(GetSearchCoverageTokens(normalizedQuery, rawQuery)); + + return terms + .Select(NormalizeGuardSearchTerm) + .Where(term => term.Length > 0) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + private SearchGuardEvidence? FindGuardEvidence(string path, int focusLine, SearchGuardFilter filter, int guardWindow, string? lang) + { + var windowStart = filter.Direction == SearchGuardDirection.Before + ? Math.Max(1, focusLine - guardWindow) + : focusLine + 1; + var windowEnd = filter.Direction == SearchGuardDirection.Before + ? Math.Max(0, focusLine - 1) + : focusLine + guardWindow; + + if (windowEnd < windowStart) + return null; + + var lineWindow = ReadLineWindow(path, windowStart, windowEnd); + if (lineWindow.Count == 0) + return null; + + var guardQuery = NormalizeGuardQuery(filter.Query, lang); + if (guardQuery.Length == 0) + return null; + + foreach (var (lineNumber, text) in lineWindow) + { + var candidate = string.Equals(lang, "csharp", StringComparison.OrdinalIgnoreCase) + ? CSharpVerbatimNameNormalizer.Normalize(text) + : text; + if (!candidate.Contains(guardQuery, StringComparison.OrdinalIgnoreCase)) + continue; + + return new SearchGuardEvidence + { + Role = FormatSearchGuardRole(filter.Role), + Direction = FormatSearchGuardDirection(filter.Direction), + Query = filter.Query, + Line = lineNumber, + Text = text, + }; + } + + return null; + } + + private SortedDictionary ReadLineWindow(string path, int startLine, int endLine) + { + var linesByNumber = new SortedDictionary(); + using var cmd = _conn.CreateCommand(); + cmd.CommandText = @" + SELECT c.start_line, c.content + FROM chunks c + JOIN files f ON c.file_id = f.id + WHERE f.path = @path + AND c.end_line >= @startLine + AND c.start_line <= @endLine + ORDER BY c.start_line ASC, c.id ASC"; + cmd.Parameters.AddWithValue("@path", path); + cmd.Parameters.AddWithValue("@startLine", startLine); + cmd.Parameters.AddWithValue("@endLine", endLine); + + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + { + var chunkStartLine = reader.GetInt32(0); + var chunkLines = SplitContentLines(reader.GetString(1)); + for (var i = 0; i < chunkLines.Length; i++) + { + var lineNumber = chunkStartLine + i; + if (lineNumber < startLine || lineNumber > endLine) + continue; + linesByNumber.TryAdd(lineNumber, chunkLines[i]); + } + } + + return linesByNumber; + } + + private static List PageGuardedSearchResults(List results, int limit, SearchCursor? cursor) + { + var offset = Math.Max(0, cursor?.Offset ?? 0); + var page = results.Skip(offset).Take(Math.Max(0, limit)).ToList(); + for (var i = 0; i < page.Count; i++) + page[i].NextOffset = offset + i + 1; + return page; + } + + private static string NormalizeGuardQuery(string query, string? lang) + { + var normalized = NormalizeGuardSearchTerm(query.Normalize(NormalizationForm.FormC)); + return string.Equals(lang, "csharp", StringComparison.OrdinalIgnoreCase) + ? CSharpVerbatimNameNormalizer.Normalize(normalized) + : normalized; + } + + private static string NormalizeGuardSearchTerm(string value) + => value.Trim().Trim('"', '\'', '(', ')').TrimEnd('*'); + + private static string[] SplitContentLines(string content) + => content.Replace("\r\n", "\n").Split('\n'); + + private static string FormatSearchGuardRole(SearchGuardRole role) + => role == SearchGuardRole.Require ? "require" : "reject"; + + private static string FormatSearchGuardDirection(SearchGuardDirection direction) + => direction == SearchGuardDirection.Before ? "before" : "after"; + private static string NormalizeLiteralSearchQuery(string query, string? lang) { var normalized = query.Normalize(NormalizationForm.FormC); diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index f4a3e1eb14..8ab24502b6 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -42,6 +42,11 @@ private JsonNode HandleToolsList(JsonNode? id) ["exactSubstring"] = new JsonObject { ["type"] = "boolean", ["description"] = "Preferred explicit name for search's exact mode: case-sensitive exact substring match (bypasses FTS5).", ["default"] = false }, ["exact"] = new JsonObject { ["type"] = "boolean", ["description"] = "Backward-compatible alias for `exactSubstring`.", ["default"] = false }, ["prefix"] = new JsonObject { ["type"] = "boolean", ["description"] = "Opt into FTS5 prefix expansion for every token in `query`. Cannot be combined with `exact`/`exactSubstring`.", ["default"] = false }, + ["requireBefore"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Keep search matches only when this guard query appears within `guardWindow` lines before the primary match. Accepts a string or string array." }, + ["requireAfter"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Keep search matches only when this guard query appears within `guardWindow` lines after the primary match. Accepts a string or string array." }, + ["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 }, ["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 9137a64dd9..533f051542 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -406,6 +406,71 @@ private static List ReadStringList(JsonNode? args, string propertyName) : []; } + private JsonNode? TryReadStringOrStringList(JsonNode? id, JsonNode? args, string propertyName, out List values) + { + values = []; + var node = args?[propertyName]; + if (node is null) + return null; + + if (node is JsonValue singleValue && singleValue.TryGetValue(out var singleText)) + { + values.Add(singleText); + return null; + } + + if (node is JsonArray array) + { + foreach (var item in array) + { + if (item is not JsonValue value || !value.TryGetValue(out var text)) + return CreateToolErrorResponse(id, $"'{propertyName}' entries must be strings."); + values.Add(text); + } + return null; + } + + return CreateToolErrorResponse(id, $"'{propertyName}' must be a string or string array."); + } + + private JsonNode? TryReadSearchGuardFilters(JsonNode? id, JsonNode? args, out List filters) + { + filters = []; + var collected = new List(); + + JsonNode? AddFilters(string propertyName, SearchGuardRole role, SearchGuardDirection direction) + { + if (TryReadStringOrStringList(id, args, propertyName, out var values) is JsonNode readError) + return readError; + + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value)) + return CreateToolErrorResponse(id, $"'{propertyName}' entries must be non-empty strings."); + if (value.Length > QueryLimits.MaxQueryLength) + return CreateToolErrorResponse(id, $"'{propertyName}' query too long (max {QueryLimits.MaxQueryLength} characters)."); + + collected.Add(new SearchGuardFilter(role, direction, value)); + } + + return null; + } + + if (AddFilters("requireBefore", SearchGuardRole.Require, SearchGuardDirection.Before) is JsonNode requireBeforeError) + return requireBeforeError; + if (AddFilters("requireAfter", SearchGuardRole.Require, SearchGuardDirection.After) is JsonNode requireAfterError) + return requireAfterError; + if (AddFilters("rejectBefore", SearchGuardRole.Reject, SearchGuardDirection.Before) is JsonNode rejectBeforeError) + return rejectBeforeError; + if (AddFilters("rejectAfter", SearchGuardRole.Reject, SearchGuardDirection.After) is JsonNode rejectAfterError) + return rejectAfterError; + + filters = collected; + return filters.Count > DbReader.MaxSearchGuardFilters + ? CreateToolErrorResponse(id, $"search accepts at most {DbReader.MaxSearchGuardFilters} guard filters; got {filters.Count}.") + : null; + } + private static JsonObject? ValidateCommonListArguments(JsonNode? args) { foreach (var propertyName in new[] { "path", "project", "excludePaths", "names" }) @@ -494,10 +559,11 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, { "limit" or "offset" or "snippetLines" or "maxLineWidth" or "before" or "after" or "focusLine" or "focusColumn" or "focusLength" or "startLine" or "endLine" or - "maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" => "integer", + "maxHops" or "maxDepth" or "depth" or "parallelism" or "maxFileBytes" or "guardWindow" => "integer", "excludeTests" or "includeGenerated" or "rawQuery" or "noDedup" or "exactSubstring" or "exactName" or "exact" or "prefix" or "countOnly" or "includeBody" or "lsp_compatible" or "regex" or "withPaths" or "rebuild" or "dryRun" or "dry_run" or "force" or "optimize" => "boolean", + "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "path" or "project" or "solution" or "symbol" or "direction" or "groupBy" or "category" or "language" or "description" or "context" or "toolInvocationContext" or "db" => "string", @@ -516,6 +582,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "integer" => node is JsonValue value && value.TryGetValue(out _), "boolean" => node is JsonValue value && value.TryGetValue(out _), "string" => node is JsonValue value && value.TryGetValue(out _), + "string_or_array" => node is JsonArray || node is JsonValue value && value.TryGetValue(out _), "array" => node is JsonArray, _ => true, }; @@ -548,7 +615,7 @@ private static string DescribeJsonType(JsonNode? node) private static IReadOnlySet GetAllowedToolArguments(string toolName) => toolName switch { - "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "countOnly", "format", "project", "solution" }, + "search" => new HashSet(StringComparer.Ordinal) { "query", "limit", "lang", "snippetLines", "maxLineWidth", "rawQuery", "cursor", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "noDedup", "exactSubstring", "exact", "prefix", "requireBefore", "requireAfter", "rejectBefore", "rejectAfter", "guardWindow", "countOnly", "format", "project", "solution" }, "definition" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "includeBody", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "since", "exactName", "exact", "format", "project", "solution" }, "references" => new HashSet(StringComparer.Ordinal) { "query", "kind", "lang", "limit", "offset", "maxLineWidth", "lsp_compatible", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, "callers" or "callees" => new HashSet(StringComparer.Ordinal) { "query", "kind", "rankBy", "lang", "limit", "offset", "path", "excludePaths", "excludeTests", "includeGenerated", "exactName", "exact", "countOnly", "format", "project", "solution" }, @@ -998,13 +1065,18 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) var prefix = args?["prefix"]?.GetValue() ?? false; if (prefix && exact) 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; + var guardWindow = args?["guardWindow"]?.GetValue() ?? DbReader.DefaultSearchGuardWindow; + if (guardWindow < 0 || guardWindow > DbReader.MaxSearchGuardWindow) + return CreateToolErrorResponse(id, $"'guardWindow' must be between 0 and {DbReader.MaxSearchGuardWindow}; got {guardWindow}."); var suggestExactSubstring = SearchQueryAdvisor.ShouldSuggestExactSubstring(query, rawQuery, exact, prefix); return WithDbReader(id, args, reader => { if (countOnly) { - var countResults = reader.Search(query, MaxLimit, lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix); + var countResults = reader.Search(query, MaxLimit, lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix, guardFilters: guardFilters, guardWindow: guardWindow); var truncatedCount = countResults.Count >= MaxLimit; var payload = BuildCountOnlyPayload(countResults.Count, truncatedCount ? null : countResults.Count, truncatedCount, countResults, result => result.Path); payload["query"] = query; @@ -1019,7 +1091,7 @@ private JsonNode ExecuteSearch(JsonNode? id, JsonNode? args) return CreateToolResult(id, $"Counted {countResults.Count} search result(s).", payload); } - var results = reader.Search(query, FetchLimitForEnvelope(limit), lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix, cursor: cursor); + var results = reader.Search(query, FetchLimitForEnvelope(limit), lang, rawQuery, pathPatterns, excludePaths, excludeTests, deduplicate, since, exact, prefix, cursor: cursor, guardFilters: guardFilters, guardWindow: guardWindow); var ftsDiagnostics = DbReader.AnalyzeFtsQuery(query, rawQuery, prefix, lang); var truncated = TrimToRequestedLimit(results, limit); if (results.Count == 0) diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index 2f5c53f050..0d6ecfae36 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -19,6 +19,8 @@ public class SearchResult public string Content { get; set; } = string.Empty; public double Score { get; set; } public string? Visibility { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? GuardEvidence { get; set; } [JsonIgnore] public long ChunkId { get; set; } [JsonIgnore] @@ -29,6 +31,29 @@ public class SearchResult public readonly record struct QueryCountResult(int Count, int FileCount, bool IncludesSql = false); +public enum SearchGuardRole +{ + Require, + Reject, +} + +public enum SearchGuardDirection +{ + Before, + After, +} + +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 Query { get; set; } = string.Empty; + public int Line { get; set; } + public string Text { get; set; } = string.Empty; +} + public sealed record FtsQueryDiagnostics( [property: JsonPropertyName("query_degraded_reason")] string? QueryDegradedReason, [property: JsonPropertyName("tokens_dropped")] IReadOnlyList TokensDropped) diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index b10c135795..16d486d13c 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -149,6 +149,65 @@ public void Search_ExplicitPrefixMatchesLatinDiacriticToken() Assert.Contains(results, r => r.Path == "src/cafe.md"); } + [Fact] + public void Search_GuardFiltersReadAcrossChunkBoundaries_Issue2852() + { + var fileId = _writer.UpsertFile(new FileRecord + { + Path = "src/chunked.cs", + Lang = "csharp", + Size = 128, + Lines = 5, + Modified = new DateTime(2025, 6, 1, 0, 0, 0, DateTimeKind.Utc), + }); + _writer.InsertChunks( + [ + new ChunkRecord + { + FileId = fileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = 3, + Content = "public void Guarded(string path)\n{\n var length = new FileInfo(path).Length;", + }, + new ChunkRecord + { + FileId = fileId, + ChunkIndex = 1, + StartLine = 4, + EndLine = 5, + Content = " var text = File.ReadAllText(path);\n}", + }, + ]); + + var requireResults = _reader.Search( + "File.ReadAllText", + exact: true, + pathPatterns: ["src/chunked.cs"], + guardFilters: [new SearchGuardFilter(SearchGuardRole.Require, SearchGuardDirection.Before, "Length")], + guardWindow: 2); + var rejectResults = _reader.Search( + "File.ReadAllText", + exact: true, + pathPatterns: ["src/chunked.cs"], + guardFilters: [new SearchGuardFilter(SearchGuardRole.Reject, SearchGuardDirection.Before, "Length")], + guardWindow: 2); + var cursorResults = _reader.Search( + "File.ReadAllText", + exact: true, + pathPatterns: ["src/chunked.cs"], + cursor: new SearchCursor(0, 0, 0), + guardFilters: [new SearchGuardFilter(SearchGuardRole.Require, SearchGuardDirection.Before, "Length")], + guardWindow: 2); + + var result = Assert.Single(requireResults); + Assert.Equal(4, result.StartLine); + var evidence = Assert.Single(result.GuardEvidence!); + Assert.Equal(3, evidence.Line); + Assert.Empty(rejectResults); + Assert.Single(cursorResults); + } + [Theory] [InlineData("rowid:authenticate", "rowid:")] [InlineData("title:authenticate", "title:")] diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index e15f8ed74f..6765d7b2ae 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -3080,6 +3080,81 @@ public void ToolsCall_Search_ReturnsResults() Assert.Null(structured["results"]![0]!["content"]); } + [Fact] + public void ToolsCall_Search_GuardFiltersReturnEvidence_Issue2852() + { + InsertIndexedFile( + "src/guard-mcp.cs", + "csharp", + """ + using System.IO; + + public class GuardMcp + { + public void Atomic(string path, string tempPath) + { + using var stream = new FileStream(path, FileMode.Create); + File.Move(tempPath, path, overwrite: true); + } + + public void NonAtomic(string path) + { + using var stream = new FileStream(path, FileMode.Create); + } + } + """); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"FileMode.Create","exactSubstring":true,"requireAfter":"File.Move","guardWindow":2}}}""")!; + var response = _server.HandleMessage(request)!; + + var structured = response["result"]!["structuredContent"]!; + Assert.Equal(1, structured["count"]!.GetValue()); + var result = structured["results"]![0]!; + Assert.Equal("src/guard-mcp.cs", result["path"]!.GetValue()); + var evidence = Assert.Single(result["guardEvidence"]!.AsArray()); + Assert.Equal("require", evidence!["role"]!.GetValue()); + Assert.Equal("after", evidence["direction"]!.GetValue()); + Assert.Equal("File.Move", evidence["query"]!.GetValue()); + } + + [Fact] + public void ToolsCall_Search_GuardPaginationResumesWithinSplitChunk_Issue2852() + { + InsertIndexedFile( + "src/guard-paged.cs", + "csharp", + """ + using System.IO; + + public class GuardPaged + { + public void First(string path) + { + var one = File.ReadAllText(path); + } + + public void Second(string path) + { + var two = File.ReadAllText(path); + } + } + """); + + var firstRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"search","arguments":{"query":"File.ReadAllText","exactSubstring":true,"rejectBefore":"Length","guardWindow":1,"limit":1}}}""")!; + var firstResponse = _server.HandleMessage(firstRequest)!; + var firstStructured = firstResponse["result"]!["structuredContent"]!; + var firstSnippet = firstStructured["results"]![0]!["snippet"]!.GetValue(); + var cursor = firstStructured["next_cursor"]!.GetValue(); + + var secondRequest = JsonNode.Parse("{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"search\",\"arguments\":{\"query\":\"File.ReadAllText\",\"exactSubstring\":true,\"rejectBefore\":\"Length\",\"guardWindow\":1,\"limit\":1,\"cursor\":\"" + cursor + "\"}}}")!; + var secondResponse = _server.HandleMessage(secondRequest)!; + var secondStructured = secondResponse["result"]!["structuredContent"]!; + var secondSnippet = secondStructured["results"]![0]!["snippet"]!.GetValue(); + + Assert.Contains("one", firstSnippet); + Assert.Contains("two", secondSnippet); + } + [Fact] public void ToolsCall_Search_ExactSubstringReturnsLiteralHighlightMetadata() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 6bb46e74c5..8d459ff53d 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -44,6 +44,11 @@ public void ParseArgs_ParsesFiltersFlagsAndAcceptsMaxSnippetLines() "--snippet-lines", $"{SearchSnippetFormatter.MaxSnippetLines}", "--snippet-focus", "proximity", "--max-line-width", "77", + "--require-before", "Length", + "--require-after", "File.Move", + "--reject-before", "NoSizeCap", + "--reject-after", "FileMode.Create", + "--guard-window", "12", "--profile", "--verbose", "--slow-query-ms", "500", @@ -71,6 +76,17 @@ public void ParseArgs_ParsesFiltersFlagsAndAcceptsMaxSnippetLines() Assert.Equal(SearchSnippetFormatter.MaxSnippetLines, options.SnippetLines); Assert.Equal(SearchSnippetFocusMode.Proximity, options.SnippetFocus); Assert.Equal(77, options.MaxLineWidth); + Assert.Equal(4, options.GuardFilters.Count); + Assert.Equal(SearchGuardRole.Require, options.GuardFilters[0].Role); + Assert.Equal(SearchGuardDirection.Before, options.GuardFilters[0].Direction); + Assert.Equal("Length", options.GuardFilters[0].Query); + Assert.Equal(SearchGuardRole.Require, options.GuardFilters[1].Role); + Assert.Equal(SearchGuardDirection.After, options.GuardFilters[1].Direction); + Assert.Equal(SearchGuardRole.Reject, options.GuardFilters[2].Role); + Assert.Equal(SearchGuardDirection.Before, options.GuardFilters[2].Direction); + Assert.Equal(SearchGuardRole.Reject, options.GuardFilters[3].Role); + Assert.Equal(SearchGuardDirection.After, options.GuardFilters[3].Direction); + Assert.Equal(12, options.GuardWindow); Assert.True(options.Profile); Assert.True(options.Verbose); Assert.Equal(500, options.SlowQueryMs); @@ -132,6 +148,207 @@ public void RunSearch_FormatCompactEmitsFileLineOnly_Issue1642() } } + [Fact] + public void RunSearch_GuardFiltersReturnUnguardedCallSites_Issue2852() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_guard_filters"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + """ + using System.IO; + + public class App + { + public void Guarded(string path) + { + var length = new FileInfo(path).Length; + var text = File.ReadAllText(path); + } + + public void Unguarded(string path) + { + var text = File.ReadAllText(path); + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["File.ReadAllText", "--db", dbPath, "--exact-substring", "--reject-before", "Length", "--guard-window", "2", "--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/app.cs", row.GetProperty("path").GetString()); + Assert.Equal(13, row.GetProperty("chunk_start_line").GetInt32()); + Assert.Equal(" var text = File.ReadAllText(path);", row.GetProperty("snippet").GetString()); + Assert.False(row.TryGetProperty("guard_evidence", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_GuardFiltersRequireAllNonExactTokensOnFocusLine_Issue2852() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_guard_non_exact_terms"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + """ + using System.IO; + + public class App + { + public void Guarded(string path) + { + var length = new FileInfo(path).Length; + var text = File.ReadAllText(path); + } + + public void Unguarded(string path) + { + var text = File.ReadAllText(path); + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["File ReadAllText", "--db", dbPath, "--reject-before", "Length", "--guard-window", "2", "--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/app.cs", row.GetProperty("path").GetString()); + Assert.Equal(13, row.GetProperty("chunk_start_line").GetInt32()); + Assert.Equal(" var text = File.ReadAllText(path);", row.GetProperty("snippet").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_GuardRequireEvidenceAppearsInJson_Issue2852() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_guard_require_json"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + """ + using System.IO; + + public class App + { + public void Atomic(string path, string tempPath) + { + using var stream = new FileStream(path, FileMode.Create); + File.Move(tempPath, path, overwrite: true); + } + + public void NonAtomic(string path) + { + using var stream = new FileStream(path, FileMode.Create); + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["FileMode.Create", "--db", dbPath, "--exact-substring", "--require-after", "File.Move", "--guard-window", "2", "--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(7, row.GetProperty("chunk_start_line").GetInt32()); + 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("File.Move", evidence.GetProperty("query").GetString()); + Assert.Equal(8, evidence.GetProperty("line").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_GuardFiltersApplyToCount_Issue2852() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_guard_count"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + """ + using System.IO; + + public class App + { + public void Guarded(string path) + { + var length = new FileInfo(path).Length; + var text = File.ReadAllText(path); + } + + public void Unguarded(string path) + { + var text = File.ReadAllText(path); + } + } + """); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["File.ReadAllText", "--db", dbPath, "--exact-substring", "--reject-before", "Length", "--guard-window", "2", "--count"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal("1", stdout.Trim()); + Assert.Equal(string.Empty, stderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void SearchUsageLineListsGuardFlags_Issue2852() + { + var usage = ConsoleUi.GetUsageLine("search"); + + Assert.NotNull(usage); + Assert.Contains("--require-before ", usage); + Assert.Contains("--require-after ", usage); + Assert.Contains("--reject-before ", usage); + Assert.Contains("--reject-after ", usage); + Assert.Contains("--guard-window ", usage); + } + [Fact] public void RunSearch_FormatCsvEmitsDelimitedRows_Issue1941() {