From 952b602522946ea88563b05c57dab141869319a8 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 16:25:25 +0900 Subject: [PATCH 1/4] Add guard-aware search filters (#2852) --- README.md | 2 + USER_GUIDE.md | 18 ++ changelog.d/unreleased/2852.added.md | 19 ++ src/CodeIndex/Cli/CliFlagSchema.cs | 5 + src/CodeIndex/Cli/ConsoleUi.cs | 4 + src/CodeIndex/Cli/JsonOutputContracts.cs | 2 + src/CodeIndex/Cli/QueryCommandRunner.cs | 75 +++++- src/CodeIndex/Cli/SearchSnippetFormatter.cs | 3 + src/CodeIndex/Database/DbSearchReader.cs | 219 +++++++++++++++++- src/CodeIndex/Mcp/McpToolDefinitions.cs | 5 + src/CodeIndex/Mcp/McpToolHandlers.cs | 80 ++++++- src/CodeIndex/Models/QueryResults.cs | 25 ++ tests/CodeIndex.Tests/DbReaderTests.cs | 59 +++++ tests/CodeIndex.Tests/McpServerTests.cs | 37 +++ .../QueryCommandRunnerTests.cs | 157 +++++++++++++ 15 files changed, 699 insertions(+), 11 deletions(-) create mode 100644 changelog.d/unreleased/2852.added.md diff --git a/README.md b/README.md index 3208e3dc05..da0b4e5f41 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 @@ -343,6 +344,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 4c8730e4a6..dc13cb4064 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -809,6 +809,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 ``` @@ -817,6 +819,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 @@ -2917,6 +2927,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 # オプションに見えるリテラルを検索 ``` @@ -2925,6 +2937,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 f87231355f..df3c09ca81 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 c1751f6617..4996a17e0f 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -979,6 +979,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)"); @@ -1011,6 +1013,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 9745644871..4f51e80b1e 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -431,6 +431,8 @@ internal sealed record VersionInfoJsonResult( [JsonSerializable(typeof(RepoModuleResult))] [JsonSerializable(typeof(ReportBundleSummary))] [JsonSerializable(typeof(SearchHighlight))] +[JsonSerializable(typeof(SearchGuardEvidence))] +[JsonSerializable(typeof(List))] [JsonSerializable(typeof(SearchResult))] [JsonSerializable(typeof(SearchTermOccurrence))] [JsonSerializable(typeof(SearchTruncationContext))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 87e3e5219c..1532b35dd5 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -80,6 +80,11 @@ public static class QueryCommandRunner "--snippet-lines", "--snippet-focus", "--path", + "--require-before", + "--require-after", + "--reject-before", + "--reject-after", + "--guard-window", "--project", "--solution", "--exclude-path", @@ -418,7 +423,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) { @@ -434,7 +439,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) { @@ -4911,6 +4916,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; @@ -4947,6 +4954,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)) @@ -5139,6 +5162,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)) { @@ -5575,6 +5640,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); @@ -5632,6 +5699,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) Exact = exact, Regex = regex, Prefix = prefix, + GuardFilters = guardFilters, + GuardWindow = guardWindow, ExactName = exactName, ExactSubstring = exactSubstring, CheckWorkspace = checkWorkspace, @@ -8299,6 +8368,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 b1b597bc67..03202a1ec0 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, }; } @@ -509,6 +510,8 @@ public sealed class CompactSearchResult public int TruncatedLineCount { get; set; } public int DroppedMatchLineCount { get; set; } public SearchTruncationContext TruncationContext { get; set; } = new(); + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? GuardEvidence { get; set; } public double Score { get; set; } } diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index dbf205234d..2a54beed36 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,7 +153,11 @@ 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"; + sql += $" ORDER BY {GetSearchOrderSql(coverageTokens.Count)}"; + if (!hasGuardFilters) + sql += " LIMIT @limit"; + else if (cursor is { }) + sql += " LIMIT -1"; if (cursor is { }) sql += " OFFSET @cursorOffset"; @@ -160,7 +168,8 @@ 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")) @@ -197,14 +206,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 ? results.Take(limit).ToList() : 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 +323,195 @@ 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 matches = new List<(int LineNumber, string Text)>(); + for (var i = 0; i < lines.Length; i++) + { + var line = normalizeCSharp ? CSharpVerbatimNameNormalizer.Normalize(lines[i]) : lines[i]; + if (terms.Any(term => line.Contains(term, comparison))) + matches.Add((result.StartLine + i, lines[i])); + } + + return matches.Count == 0 ? [(result.StartLine, lines.FirstOrDefault() ?? string.Empty)] : matches; + } + + private static string[] BuildPrimarySearchMatchTerms(string query, string normalizedQuery, bool rawQuery, bool exact) + { + var terms = new List(); + var fullQuery = rawQuery ? query.Trim() : normalizedQuery.Trim(); + if (!string.IsNullOrWhiteSpace(fullQuery)) + terms.Add(NormalizeGuardSearchTerm(fullQuery)); + + if (!exact) + 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 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 553478cffe..16b6b1c36b 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 dbf4f4cabb..4329ea5b93 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -392,6 +392,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" }) @@ -480,10 +545,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", @@ -502,6 +568,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, }; @@ -534,7 +601,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" }, @@ -984,12 +1051,17 @@ 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}."); 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; @@ -1002,7 +1074,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 5095fb788c..f4c78b9b73 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -18,6 +18,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] @@ -28,6 +30,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 af8243bf6d..fe9ffb23bf 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -3080,6 +3080,43 @@ 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_ExcludesGeneratedFilesByDefault() { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 3e4afb92d6..47dc358399 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,147 @@ 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_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 RunSearch_FormatCsvEmitsDelimitedRows_Issue1941() { From 23ef34afb72a9f4365882d415a71003d6619564f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 16:32:52 +0900 Subject: [PATCH 2/4] Fix guard search pagination (#2852) --- src/CodeIndex/Database/DbSearchReader.cs | 19 ++++++++---- tests/CodeIndex.Tests/McpServerTests.cs | 38 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index 2a54beed36..638af1d822 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -156,9 +156,7 @@ FROM fts_chunks sql += $" ORDER BY {GetSearchOrderSql(coverageTokens.Count)}"; if (!hasGuardFilters) sql += " LIMIT @limit"; - else if (cursor is { }) - sql += " LIMIT -1"; - if (cursor is { }) + if (cursor is { } && !hasGuardFilters) sql += " OFFSET @cursorOffset"; cmd.CommandText = sql; @@ -174,14 +172,14 @@ FROM fts_chunks 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(); @@ -211,7 +209,7 @@ FROM fts_chunks raw = FilterBySearchGuards(raw, query, normalizedQuery, rawQuery, exact, lang, guardFilters!, guardWindow); var results = deduplicate ? DeduplicateOverlappingResults(raw) : raw; - return hasGuardFilters ? results.Take(limit).ToList() : results; + 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, IReadOnlyList? guardFilters = null, int guardWindow = DefaultSearchGuardWindow) @@ -492,6 +490,15 @@ FROM chunks c 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)); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index fe9ffb23bf..afa949f851 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -3117,6 +3117,44 @@ public void NonAtomic(string path) 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_ExcludesGeneratedFilesByDefault() { From d454d44c1db3d06dd5e836bf4359ae82a706ff6c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 2 Jun 2026 16:49:09 +0900 Subject: [PATCH 3/4] Tighten guard search focus lines (#2852) --- src/CodeIndex/Cli/ConsoleUi.cs | 2 +- src/CodeIndex/Database/DbSearchReader.cs | 19 +++--- .../QueryCommandRunnerTests.cs | 60 +++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 4996a17e0f..bd6ea50d94 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 [id ...] [--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]"), diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index 638af1d822..f373ecf505 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -388,25 +388,28 @@ private List FilterBySearchGuards( 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]; - if (terms.Any(term => line.Contains(term, comparison))) + 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.Count == 0 ? [(result.StartLine, lines.FirstOrDefault() ?? string.Empty)] : matches; + return matches; } private static string[] BuildPrimarySearchMatchTerms(string query, string normalizedQuery, bool rawQuery, bool exact) { - var terms = new List(); - var fullQuery = rawQuery ? query.Trim() : normalizedQuery.Trim(); - if (!string.IsNullOrWhiteSpace(fullQuery)) - terms.Add(NormalizeGuardSearchTerm(fullQuery)); - - if (!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 diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 47dc358399..476fa8e87f 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -196,6 +196,53 @@ public void Unguarded(string path) } } + [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() { @@ -289,6 +336,19 @@ public void Unguarded(string path) } } + [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() { From c442c2f99b31d62971860b91fca227d0d5205ef6 Mon Sep 17 00:00:00 2001 From: Widthdom <125688807+Widthdom@users.noreply.github.com> Date: Wed, 3 Jun 2026 01:25:18 +0900 Subject: [PATCH 4/4] Restore latest main files after conflict resolution --- .github/workflows/dotnet.yml | 3 ++ .../GitHubIssueReporterTests.cs | 28 +++++----- .../PostExtractionHookTests.cs | 54 +++++++++++++------ tests/CodeIndex.Tests/TestProjectHelper.cs | 54 ++++++++++--------- tests/CodeIndex.Tests/golden/status.json | 10 ++++ 5 files changed, 95 insertions(+), 54 deletions(-) diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index ac3e8f7008..441227b5f3 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -240,6 +240,7 @@ jobs: with: name: TestResults-${{ matrix.os }}-${{ matrix.test-framework }} if-no-files-found: warn + overwrite: true path: | TestResults/**/*.trx TestResults/**/*.txt @@ -255,6 +256,7 @@ jobs: with: name: Coverage-${{ matrix.os }}-${{ matrix.test-framework }} if-no-files-found: warn + overwrite: true path: TestResults/**/coverage.cobertura.xml - name: Publish @@ -266,4 +268,5 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: CodeIndex + overwrite: true path: publish/** diff --git a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs index 71fefce7af..0bdb59aec9 100644 --- a/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs +++ b/tests/CodeIndex.Tests/GitHubIssueReporterTests.cs @@ -314,28 +314,20 @@ public void BuildApiFailureMessage_IsActionable() Assert.Contains("retry `suggest_improvement`", message); } - [Fact] - public void BuildIssueLabels_MapsSuggestionCategoriesToExistingRepositoryLabels() - { - Assert.Equal(["enhancement"], GitHubIssueReporter.BuildIssueLabels(new SuggestionRecord { Category = "output_format" })); - Assert.Equal(["bug"], GitHubIssueReporter.BuildIssueLabels(new SuggestionRecord { Category = "crash_report" })); - Assert.Equal(["bug"], GitHubIssueReporter.BuildIssueLabels(new SuggestionRecord { Category = "unexpected_error" })); - } - [Fact] public void BuildApiFailureMessage_RedactsAndBoundsSensitiveErrorBody() { var errorBody = $$""" { "message": "validation failed", - "token": "ghp_secret_token", + "token": "value-that-must-not-leak", "details": "{{new string('x', 2000)}}" } """; var message = GitHubIssueReporter.BuildApiFailureMessage(403, errorBody); - Assert.DoesNotContain("ghp_secret_token", message); + Assert.DoesNotContain("value-that-must-not-leak", message); Assert.Contains("\"token\":\"[redacted]\"", message); Assert.True(message.Length < 900); } @@ -347,14 +339,22 @@ public void BuildRateLimitFailureMessage_RedactsSensitiveErrorBody() var message = GitHubIssueReporter.BuildRateLimitFailureMessage( 429, - """{ "authorization": "Bearer ghp_secret_token", "message": "rate limited" }""", + """{ "authorization": "Bearer value-that-must-not-leak", "message": "rate limited" }""", retryAt); - Assert.DoesNotContain("ghp_secret_token", message); + Assert.DoesNotContain("value-that-must-not-leak", message); Assert.Contains("\"authorization\":\"[redacted]\"", message); Assert.Contains(retryAt.ToString("O"), message); } + [Fact] + public void BuildIssueLabels_MapsSuggestionCategoriesToExistingRepositoryLabels() + { + Assert.Equal(["enhancement"], GitHubIssueReporter.BuildIssueLabels(new SuggestionRecord { Category = "output_format" })); + Assert.Equal(["bug"], GitHubIssueReporter.BuildIssueLabels(new SuggestionRecord { Category = "crash_report" })); + Assert.Equal(["bug"], GitHubIssueReporter.BuildIssueLabels(new SuggestionRecord { Category = "unexpected_error" })); + } + [Fact] public void BuildApiErrorDetail_UsesStatusAndSingleLineBodyExcerpt() { @@ -693,7 +693,7 @@ public async Task TryCreateIssueDetailedAsync_CreateApiFails_ReadsBoundedSanitiz { _env.Set("CDIDX_GITHUB_TOKEN", "ghp_idempotency_test"); - var errorBody = "{\"message\":\"validation failed\",\"token\":\"ghp_secret_token\",\"details\":\"" + var errorBody = "{\"message\":\"validation failed\",\"token\":\"value-that-must-not-leak\",\"details\":\"" + new string('x', GitHubIssueReporter.MaxGitHubApiErrorBodyBytes * 2) + "\"}"; @@ -721,7 +721,7 @@ public async Task TryCreateIssueDetailedAsync_CreateApiFails_ReadsBoundedSanitiz var result = await GitHubIssueReporter.TryCreateIssueDetailedAsync(record, "1.0.0-test"); Assert.Null(result.IssueUrl); - Assert.DoesNotContain("ghp_secret_token", result.Error); + Assert.DoesNotContain("value-that-must-not-leak", result.Error); Assert.Contains("[redacted]", result.Error); Assert.True(result.Error!.Length <= 512); } diff --git a/tests/CodeIndex.Tests/PostExtractionHookTests.cs b/tests/CodeIndex.Tests/PostExtractionHookTests.cs index bbd21056d5..9013bea407 100644 --- a/tests/CodeIndex.Tests/PostExtractionHookTests.cs +++ b/tests/CodeIndex.Tests/PostExtractionHookTests.cs @@ -7,6 +7,7 @@ namespace CodeIndex.Tests; public class PostExtractionHookTests { internal const string SlowHookDelayEnvironmentVariable = "CDIDX_TEST_SLOW_POST_EXTRACTION_HOOK_MS"; + internal const string SlowHookCompletionPathEnvironmentVariable = "CDIDX_TEST_SLOW_POST_EXTRACTION_HOOK_DONE_PATH"; [Fact] public void Discover_LoadsHooksAndAllowsSymbolAndReferenceMutation() @@ -79,35 +80,42 @@ public void CallbackBudgetExceeded_AddsDiagnosticAndSkipsTimedOutMutation() lock (TestConsoleLock.Gate) { var originalDelay = Environment.GetEnvironmentVariable(SlowHookDelayEnvironmentVariable); + var originalCompletionPath = Environment.GetEnvironmentVariable(SlowHookCompletionPathEnvironmentVariable); var originalBudget = PostExtractionHookRunner.CallbackBudgetForTesting; try { Environment.SetEnvironmentVariable(SlowHookDelayEnvironmentVariable, "200"); PostExtractionHookRunner.CallbackBudgetForTesting = () => TimeSpan.FromMilliseconds(50); var hooksDir = Path.Combine(projectRoot, "hooks"); + var completionPath = Path.Combine(projectRoot, "slow-hook.done"); + Environment.SetEnvironmentVariable(SlowHookCompletionPathEnvironmentVariable, completionPath); Directory.CreateDirectory(hooksDir); File.Copy(Assembly.GetExecutingAssembly().Location, Path.Combine(hooksDir, "CodeIndex.Tests.dll")); - using var runner = PostExtractionHookRunner.Discover(hooksDir); - var context = new FileContext(projectRoot, "src/App.cs", Path.Combine(projectRoot, "src", "App.cs"), "csharp"); - var symbols = new List(); - - runner.OnSymbolsExtracted(context, symbols); - Thread.Sleep(250); - - Assert.DoesNotContain(symbols, symbol => symbol.Name == "SlowHookTag"); - var diagnostic = Assert.Single( - runner.Diagnostics, - item => item.TypeName == typeof(SlowPostExtractionHook).FullName - && item.Callback == nameof(IPostExtractionHook.OnSymbolsExtracted)); - Assert.Contains("exceeded", diagnostic.Message, StringComparison.Ordinal); - Assert.True(diagnostic.DurationMs >= 50); - Assert.Equal(50, (long)Math.Round(runner.CallbackBudget.TotalMilliseconds, MidpointRounding.AwayFromZero)); + { + using var runner = PostExtractionHookRunner.Discover(hooksDir); + var context = new FileContext(projectRoot, "src/App.cs", Path.Combine(projectRoot, "src", "App.cs"), "csharp"); + var symbols = new List(); + + runner.OnSymbolsExtracted(context, symbols); + WaitForSlowHookCompletion(completionPath); + + Assert.DoesNotContain(symbols, symbol => symbol.Name == "SlowHookTag"); + var diagnostic = Assert.Single( + runner.Diagnostics, + item => item.TypeName == typeof(SlowPostExtractionHook).FullName + && item.Callback == nameof(IPostExtractionHook.OnSymbolsExtracted)); + Assert.Contains("exceeded", diagnostic.Message, StringComparison.Ordinal); + Assert.True(diagnostic.DurationMs >= 50); + Assert.Equal(50, (long)Math.Round(runner.CallbackBudget.TotalMilliseconds, MidpointRounding.AwayFromZero)); + } + CollectUnloadedHookAssemblies(); } finally { PostExtractionHookRunner.CallbackBudgetForTesting = originalBudget; Environment.SetEnvironmentVariable(SlowHookDelayEnvironmentVariable, originalDelay); + Environment.SetEnvironmentVariable(SlowHookCompletionPathEnvironmentVariable, originalCompletionPath); TestProjectHelper.DeleteDirectory(projectRoot); } } @@ -144,6 +152,18 @@ private static void CollectUnloadedHookAssemblies() GC.WaitForPendingFinalizers(); GC.Collect(); } + + private static void WaitForSlowHookCompletion(string completionPath) + { + var deadline = DateTimeOffset.UtcNow.AddSeconds(5); + while (!File.Exists(completionPath)) + { + if (DateTimeOffset.UtcNow >= deadline) + throw new TimeoutException("Timed out waiting for the slow post-extraction hook to finish."); + + Thread.Sleep(25); + } + } } public sealed class SamplePostExtractionHook : IPostExtractionHook @@ -224,6 +244,10 @@ private static bool DelayWhenRequested() return false; Thread.Sleep(milliseconds); + var completionPath = Environment.GetEnvironmentVariable(PostExtractionHookTests.SlowHookCompletionPathEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(completionPath)) + File.WriteAllText(completionPath, "done"); + return true; } } diff --git a/tests/CodeIndex.Tests/TestProjectHelper.cs b/tests/CodeIndex.Tests/TestProjectHelper.cs index 66d404de38..a66485368d 100644 --- a/tests/CodeIndex.Tests/TestProjectHelper.cs +++ b/tests/CodeIndex.Tests/TestProjectHelper.cs @@ -48,34 +48,38 @@ internal static void InsertIndexedFile(string dbPath, string path, string lang, var lines = normalized.Split('\n'); var lineCount = FileIndexer.CountPhysicalLines(content); - using var db = new DbContext(dbPath); - db.InitializeSchema(); - - var writer = new DbWriter(db.Connection); - var fileId = writer.UpsertFile(new FileRecord + using (var db = new DbContext(dbPath)) { - Path = path, - Lang = lang, - Size = normalized.Length, - Lines = lineCount, - Modified = modified ?? new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), - Checksum = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant(), - }); - - writer.InsertChunks([ - new ChunkRecord + db.InitializeSchema(); + + var writer = new DbWriter(db.Connection); + var fileId = writer.UpsertFile(new FileRecord { - FileId = fileId, - ChunkIndex = 0, - StartLine = 1, - EndLine = lines.Length, - Content = normalized, - } - ]); + Path = path, + Lang = lang, + Size = normalized.Length, + Lines = lineCount, + Modified = modified ?? new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Checksum = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(normalized))).ToLowerInvariant(), + }); + + writer.InsertChunks([ + new ChunkRecord + { + FileId = fileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = lines.Length, + Content = normalized, + } + ]); + + var symbols = SymbolExtractor.Extract(fileId, lang, normalized, path); + writer.InsertSymbols(symbols); + writer.InsertReferences(ReferenceExtractor.Extract(fileId, lang, normalized, symbols)); + } - var symbols = SymbolExtractor.Extract(fileId, lang, normalized, path); - writer.InsertSymbols(symbols); - writer.InsertReferences(ReferenceExtractor.Extract(fileId, lang, normalized, symbols)); + SqlitePoolCleanup.ClearPoolsForWindowsFileRelease(); } internal static string RunGit(string workDir, params string[] args) diff --git a/tests/CodeIndex.Tests/golden/status.json b/tests/CodeIndex.Tests/golden/status.json index d498c44408..a1e3a86416 100644 --- a/tests/CodeIndex.Tests/golden/status.json +++ b/tests/CodeIndex.Tests/golden/status.json @@ -75,6 +75,16 @@ "vue", "zig" ], + "extractors": { + "plugin_assembly_count": 0, + "pattern_config_count": 0, + "symbol_extractor_count": 0, + "reference_extractor_count": 0, + "skipped_file_count": 0, + "diagnostic_count": 0, + "diagnostic_limit": 20, + "diagnostics_truncated": false + }, "version": null, "summary": "1 files, 3 symbols, 0 refs across 1 languages (csharp); index fresh, DEGRADED", "graph_table_available": false,