From 95c9c57ce56e2f1cd4a82746200e0e9862e84138 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Fri, 31 Jul 2026 22:29:57 +0900 Subject: [PATCH 1/4] Fix query freshness accounting for zero matches (#4907) --- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 33 +- changelog.d/unreleased/4907.fixed.md | 21 + src/CodeIndex/Cli/JsonOutputContracts.cs | 2 + .../Cli/QueryCommandRunner.SearchRecipes.cs | 486 ++++++++++++++++-- .../Cli/QueryCommandRunner.SearchResults.cs | 35 +- src/CodeIndex/Cli/SearchAuditRecipes.cs | 28 +- .../QueryCommandRunnerSearchTests.cs | 202 +++++++- 8 files changed, 731 insertions(+), 80 deletions(-) create mode 100644 changelog.d/unreleased/4907.fixed.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 9fddebc2e..ae2c16757 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -224,7 +224,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Validate JSON, compact, count, and SARIF pagination/severity coverage shares one mixed informational/actionable fixture so authoritative totals, limited rows, SARIF levels, and actionability metadata cannot drift across formats; keep missing-`file_issues` degradation coverage in a separate legacy-schema fixture because availability is a distinct mutable state. Command-specific output format coverage uses a command/format matrix that checks both parser acceptance and the matching usage line; recognized shared formats without a command implementation need a separate usage-error assertion. Ad-hoc search SARIF completion coverage shares one fixture across complete, 1-of-126 limited, facet-filtered occurrence-expanded limited, bounded guarded, empty, and synthetically merged multi-run documents. Assert source/emitted/omitted counts and source-count authority in SARIF result units, applied limits, conservative truncation, null cursor state, raw-FTS and option-like-query replay commands, guard-preserving replay, and unchanged rule/location/severity fields on every run. - Recipe SARIF coverage must assert bounded result counts, `recipe/query` rule identity, source locations, severity mapping, confidence, conservative truncation metadata, and stable `fingerprints.cdidx/v1` values across identical runs. + Recipe SARIF coverage must assert bounded result counts, `recipe/query` rule identity, source locations, severity mapping, confidence, conservative truncation metadata, stable `fingerprints.cdidx/v1` values across identical runs, and the same `query_freshness` run properties as aggregate JSON. Query-freshness coverage must keep successful matched and zero-match executions separate from stale index/recipe/query versions and invalid or missing child executions, preserve the compatibility cardinality fields, and reconcile clean/stale/invalid state counts in mixed runs. MCP schema-origin coverage keeps identical audit phrases in `McpToolCatalog.cs` top-level tool descriptions, concatenated description segments, nested schema-property prose, and executable C# in one indexed fixture; assert explicit `schema_description` search metadata and the recipe's JSON, SARIF, and issue-draft outputs so origin filtering cannot drift across projections. Recipe row-selection coverage reuses one multi-file, multi-chunk fixture across aggregate JSON, compact JSON, NDJSON, and issue-draft source metadata. Assert emitted/matched/omitted counts, `selection_reason` / `selection_omitted_count`, first-per-file path uniqueness, selector-preserving replay commands, suppressed raw cursors when a later limit truncates selected rows, and rejection of incoming cursors with either selector. A separate candidate-window fixture must exceed the default low-limit fetch envelope and prove that `--sample ` observes at least its requested candidate target; validate rejected selectors for non-row recipe shapes without opening a database. Unused default-suppression row, JSON count, summary-only, and text count envelopes, including the `--all` count control, share one unused-symbol fixture. @@ -1175,7 +1175,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" validate の JSON、compact、count、SARIF における pagination / severity coverage は、informational finding と actionable finding が混在する1つの fixture を共有し、authoritative な総件数、limited row、SARIF level、actionability metadata が format 間で drift しないことを検証してください。`file_issues` 欠落時の degradation coverage は availability が独立した mutable state なので、別の legacy-schema fixture に分けてください。 コマンド別の出力形式 coverage は command / format matrix で parser の受理と対応する usage line の両方を検証してください。共通 parser が認識してもコマンド側に実装がない形式には、別途 usage error の assertion が必要です。 ad-hoc search SARIF の completion coverage は complete、1-of-126 の limited、facet filter 付き occurrence 展開後の limited、bounded guard、empty、合成した multi-run document で1つの fixture を共有します。SARIF result 単位の source / emitted / omitted count と source count の確定性、適用済み limit、保守的な truncation、null cursor state、raw FTS と option のような query の replay command、guard を保持する replay、および各 run で rule / location / severity field が不変であることを検証してください。 - Recipe SARIF coverage では、上限付き result count、`recipe/query` rule identity、source location、severity mapping、confidence、保守的な truncation metadata、同一 run 間で安定する `fingerprints.cdidx/v1` を検証してください。 + Recipe SARIF coverage では、上限付き result count、`recipe/query` rule identity、source location、severity mapping、confidence、保守的な truncation metadata、同一 run 間で安定する `fingerprints.cdidx/v1`、aggregate JSON と同じ `query_freshness` run properties を検証してください。query freshness coverage では、成功した matched / zero-match execution を stale な index / recipe / query version および invalid / missing child execution と分離し、互換用の件数フィールドを維持し、mixed run の clean / stale / invalid state count が整合することを検証してください。 MCP schema-origin coverage では、同一の audit phrase を `McpToolCatalog.cs` の top-level tool description、連結された description segment、nested schema property の prose、実行可能な C# に置いた1つの indexed fixture を共有し、明示的な `schema_description` 検索 metadata と recipe の JSON、SARIF、issue-draft 出力を検証して、projection 間で origin filter が drift しないようにしてください。 recipe row-selection coverage は aggregate JSON、compact JSON、NDJSON、issue-draft の source metadata で1つの multi-file / multi-chunk fixture を共有します。emitted / matched / omitted count、`selection_reason` / `selection_omitted_count`、first-per-file の path uniqueness、selector を保持する replay command、後続 limit が選択済み row を truncate する場合の raw cursor 抑止、両 selector と受け取った cursor の併用拒否を検証してください。別の candidate-window fixture では既定の low-limit fetch envelope を超え、`--sample ` が少なくとも要求 candidate 数を観測することを証明し、row を持たない recipe shape での selector 拒否は database を開かずに確認してください。 unused default-suppressionのrow、JSON count、summary-only、text count envelopeは、`--all` count controlも含めて1つのunused-symbol fixtureを共有してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 388cb5629..d70a6d853 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1458,12 +1458,19 @@ include `classifier_counts` when classified rows are present. For example, `phrase-risk-patterns/task-result-property-review` separates DTO/result-wrapper `.Result` properties from Task/ValueTask blocking waits. Recipe JSON and compact output also return `next_cursor` when a single selected recipe query is -truncated. Recipe run summaries and -count summaries include `query_freshness` with the number of queries that found -positive evidence, the number that returned zero results, and `stale_query_names` -so broad audit recipes can surface query drift without requiring the full -recipe catalog JSON. Output-limited recipe runs use matched-count metadata for -this summary, so queries with known omitted matches are not reported as stale. +truncated. Recipe run summaries and count summaries include `query_freshness`. +The compatibility fields `positive_evidence_query_count` and +`zero_result_query_count` still describe result cardinality, while +`clean_query_count`, `matched_query_count`, `clean_zero_match_query_count`, and +per-query `freshness_state` / `result_state` keep successful zero-match queries +separate from real freshness invalidation. `stale_query_names` is now reserved +for stale index or changed recipe/query definition versions; failed or missing +child executions are listed under `invalid_query_names`. Recipe definition and +query definition versions make cached consumers able to detect drift without +inferring freshness from match counts. Text recipe output summarizes the same +states, and SARIF recipe runs expose the same `query_freshness` object in run +properties. Output-limited recipe runs use matched-count metadata for this +summary, so queries with known omitted matches are not reported as zero-match. Add `--show-excluded` to a recipe run when you need the effective path scope and exclusion diagnostics in JSON output. Recipe runs support text output, aggregate JSON with `--json` / `--format json`, @@ -4816,10 +4823,16 @@ DTO / result-wrapper の `.Result` property と Task / ValueTask の blocking wa recipe の JSON / compact output は、単一の recipe query が truncated された場合に `next_cursor` も返します。`--format compact` は summary、query count、query ごとの count、`truncated` flag、該当する場合の `next_cursor` -を返します。recipe run summary と count summary は `query_freshness` も返し、肯定的な根拠が -見つかった query 数、結果 0 件の query 数、`stale_query_names` を示します。これにより、 -広範な audit recipe の query drift を full recipe catalog JSON なしで確認できます。出力制限された -recipe run では matched-count metadata を使うため、省略済みの match がある query は stale として報告されません。 +を返します。recipe run summary と count summary は `query_freshness` も返します。 +互換フィールドの `positive_evidence_query_count` と `zero_result_query_count` は引き続き結果件数を +表し、`clean_query_count`、`matched_query_count`、`clean_zero_match_query_count` と query ごとの +`freshness_state` / `result_state` により、正常に実行された 0 件の query を実際の freshness +無効化と分離します。`stale_query_names` は stale index または recipe / query definition version +変更だけに使い、失敗または欠落した child execution は `invalid_query_names` に示します。 +recipe definition と query definition の version により、cache consumer は match 数から freshness +を推測せず drift を検出できます。text の recipe output も同じ状態を要約し、SARIF recipe run は +run properties の `query_freshness` に同じ object を出力します。出力制限された recipe run では +matched-count metadata を使うため、省略済みの match がある query は zero-match として報告されません。 `--show-excluded` を recipe と併用すると、有効な path scope と除外診断を出力に含めます。 recipe run が対応する形式は text output、`--json` / `--format json` の aggregate JSON、 `--json=ndjson` または `--results-only` の NDJSON row stream、`--format count` の diff --git a/changelog.d/unreleased/4907.fixed.md b/changelog.d/unreleased/4907.fixed.md new file mode 100644 index 000000000..b14101b04 --- /dev/null +++ b/changelog.d/unreleased/4907.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 4907 +affected: + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Fresh named and recipe queries with zero matches are no longer reported as stale (#4907)** — `query_freshness` now separates successful matched and zero-match executions from stale index/definition versions and invalid child executions, while preserving the existing cardinality fields and synchronizing JSON, text, SARIF, and issue-draft metadata. + +## 日本語 + +- **正常に実行されて結果が 0 件だった named query と recipe query を stale として報告しないようにしました (#4907)** — `query_freshness` は成功した match / zero-match execution、stale な index / definition version、無効な child execution を分離し、既存の件数フィールドを維持しながら JSON、text、SARIF、issue-draft metadata を同期します。 diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index afd785d7e..f76348c03 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -1303,6 +1303,8 @@ internal sealed record ValidateConfigJsonResult( [JsonSerializable(typeof(SearchNamedBatchRunJsonResult))] [JsonSerializable(typeof(SearchNamedBatchCountSummaryQueryJsonResult))] [JsonSerializable(typeof(SearchNamedBatchCountSummaryRunJsonResult))] +[JsonSerializable(typeof(SearchAuditRecipe))] +[JsonSerializable(typeof(SearchAuditRecipeQuery))] [JsonSerializable(typeof(SearchRecipeListItemJsonResult))] [JsonSerializable(typeof(SearchRecipeListJsonResult))] [JsonSerializable(typeof(SearchRecipeNameListJsonResult))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 5fa233fd8..df70a883c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -3,7 +3,9 @@ using System.Text; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; using CodeIndex.Database; +using CodeIndex.Indexer; using CodeIndex.Models; namespace CodeIndex.Cli; @@ -687,6 +689,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp if (options.OutputFormat == OutputFormatCompact) { var compactQueryResults = CollectSearchRecipeCompactQueryResults(reader, selection.Queries, scope, options, userExact, out var compactTotal); + var compactFreshnessContext = BuildSearchRecipeFreshnessContext(reader, recipe, selection.Queries); var compactPayload = BuildSearchRecipeCompactRunPayload( recipe, selection.Queries, @@ -694,7 +697,8 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp options, jsonOptions, compactQueryResults, - compactTotal); + compactTotal, + compactFreshnessContext); var compactJson = compactPayload.ToJsonString(GetJsonNodeSerializationOptions(jsonOptions)); return WriteJsonObjectWithOptionalByteLimit( compactJson, @@ -704,10 +708,11 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp } var queryResults = CollectSearchRecipeQueryResults(reader, selection.Queries, scope, options, userExact, out var total, out _); + var freshnessContext = BuildSearchRecipeFreshnessContext(reader, recipe, selection.Queries); if (options.OutputFormat == OutputFormatSarif) { - WriteSearchRecipeSarif(recipe, scope, queryResults, total, options, jsonOptions); + WriteSearchRecipeSarif(recipe, scope, queryResults, total, options, jsonOptions, freshnessContext); return CommandExitCodes.Success; } @@ -725,7 +730,8 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp options.Limit, options.TotalLimit, total, - options.InvocationContext), + options.InvocationContext, + freshnessContext), queryResults), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeRunJsonResult); return WriteJsonObjectWithOptionalByteLimit( @@ -779,6 +785,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp Console.WriteLine(); } + WriteSearchRecipeFreshnessText(BuildSearchRecipeQueryFreshness(queryResults, freshnessContext)); CommandErrorWriter.WriteStderr($"({total} recipe results across {selection.Queries.Count} queries)"); return CommandExitCodes.Success; }, _ => @@ -794,14 +801,16 @@ private static void WriteSearchRecipeSarif( IReadOnlyList queryResults, int total, QueryCommandOptions options, - JsonSerializerOptions jsonOptions) + JsonSerializerOptions jsonOptions, + SearchQueryFreshnessContext freshnessContext) { var summary = BuildSearchRecipeRunSummary( queryResults, options.Limit, options.TotalLimit, total, - options.InvocationContext); + options.InvocationContext, + freshnessContext); var querySummaries = new JsonArray(); foreach (var queryResult in queryResults) { @@ -824,6 +833,9 @@ private static void WriteSearchRecipeSarif( CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeScopeJsonResult), ["query_count"] = queryResults.Count, ["result_count"] = total, + ["query_freshness"] = JsonSerializer.SerializeToNode( + summary.QueryFreshness, + CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeQueryFreshnessJsonResult), ["limit_per_query"] = options.Limit, ["total_limit"] = JsonValue.Create(options.TotalLimit), ["queries"] = querySummaries, @@ -954,7 +966,8 @@ private static JsonObject BuildSearchRecipeCompactRunPayload( QueryCommandOptions options, JsonSerializerOptions jsonOptions, List compactQueryResults, - int compactTotal) + int compactTotal, + SearchQueryFreshnessContext freshnessContext) { var run = new SearchRecipeCompactRunJsonResult( JsonOutputContract.ApiVersion, @@ -974,7 +987,8 @@ private static JsonObject BuildSearchRecipeCompactRunPayload( options.Limit, options.TotalLimit, compactTotal, - options.InvocationContext), + options.InvocationContext, + freshnessContext), compactQueryResults); var payload = JsonSerializer.SerializeToNode( run, @@ -1369,6 +1383,7 @@ private static int RunSearchRecipeIssueDrafts( .Where(queryResult => queryResult.Count > 0) .Select(queryResult => ToSearchIssueDraft(recipe, queryResult, preflight, options)) .ToList(); + var freshnessContext = BuildSearchRecipeFreshnessContext(reader, recipe, selection.Queries); var fullRecipeMetadata = options.SummaryOnly ? null : ToSearchRecipeListItem(recipe, selection.Queries); var recipeSummaryMetadata = options.SummaryOnly ? ToSearchRecipeCompactListItem(recipe, selection.Queries) : null; var json = JsonSerializer.Serialize( @@ -1380,7 +1395,7 @@ private static int RunSearchRecipeIssueDrafts( scope, selection.Queries.Count, total, - BuildSearchRecipeQueryFreshness(queryResults), + BuildSearchRecipeQueryFreshness(queryResults, freshnessContext), drafts.Count, new SuggestionIssueDraftPreflightSummaryJsonResult( preflight.Checked, @@ -1422,6 +1437,7 @@ private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSeriali userExact, out var total, out var fileCount); + var freshnessContext = BuildSearchRecipeFreshnessContext(reader, recipe, selection.Queries); if (options.Json) { @@ -1441,7 +1457,7 @@ private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSeriali selection.Queries.Count, total, fileCount, - BuildSearchRecipeQueryFreshness(queryCounts), + BuildSearchRecipeQueryFreshness(queryCounts, freshnessContext), summaryQueries), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeCountSummaryRunJsonResult); return WriteJsonObjectWithOptionalByteLimit( @@ -2225,14 +2241,15 @@ private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( int limitPerQuery, int? totalLimit, int emittedResultCount, - QueryCommandInvocationContext invocationContext) + QueryCommandInvocationContext invocationContext, + SearchQueryFreshnessContext freshnessContext) => new( limitPerQuery, totalLimit, emittedResultCount, queryResults.Count(query => query.Truncated), queryResults.Sum(query => query.MinimumOmittedResultCount), - BuildSearchRecipeQueryFreshness(queryResults), + BuildSearchRecipeQueryFreshness(queryResults, freshnessContext), queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor)), BuildSearchRecipeCursoringHint( queryResults.Any(query => query.Truncated), @@ -2253,14 +2270,15 @@ private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( int limitPerQuery, int? totalLimit, int emittedResultCount, - QueryCommandInvocationContext invocationContext) + QueryCommandInvocationContext invocationContext, + SearchQueryFreshnessContext freshnessContext) => new( limitPerQuery, totalLimit, emittedResultCount, queryResults.Count(query => query.Truncated), queryResults.Sum(query => query.MinimumOmittedResultCount), - BuildSearchRecipeQueryFreshness(queryResults), + BuildSearchRecipeQueryFreshness(queryResults, freshnessContext), queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor)), BuildSearchRecipeCursoringHint( queryResults.Any(query => query.Truncated), @@ -2276,6 +2294,19 @@ private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( queryResults.Sum(query => query.SelectorOmittedCount), queryResults.Sum(query => query.LimitOmittedCount)); + private static void WriteSearchRecipeFreshnessText(SearchRecipeQueryFreshnessJsonResult freshness) + { + Console.WriteLine( + $"Query freshness: {freshness.State} " + + $"(clean={freshness.CleanQueryCount}, matched={freshness.MatchedQueryCount}, " + + $"clean zero-match={freshness.CleanZeroMatchQueryCount}, stale={freshness.StaleQueryCount}, " + + $"invalid={freshness.InvalidQueryCount})"); + if (freshness.StaleQueryNames.Count > 0) + Console.WriteLine($"Stale queries: {string.Join(", ", freshness.StaleQueryNames)}"); + if (freshness.InvalidQueryNames.Count > 0) + Console.WriteLine($"Invalid queries: {string.Join(", ", freshness.InvalidQueryNames)}"); + } + private static string BuildSearchRecipeCursoringHint( bool hasTruncatedQuery, bool cursoringAvailable, @@ -2286,28 +2317,330 @@ private static string BuildSearchRecipeCursoringHint( ? "Continuation cursors are unavailable for the selected rows; increase --limit or --total-limit and rerun." : "No query is truncated, so no continuation cursor is needed."; - private static SearchRecipeQueryFreshnessJsonResult BuildSearchRecipeQueryFreshness(IReadOnlyList queryResults) - => BuildSearchRecipeQueryFreshness(queryResults.Select(query => (query.Name, query.MinimumMatchedCount))); + private static SearchRecipeQueryFreshnessJsonResult BuildSearchRecipeQueryFreshness( + IReadOnlyList queryResults, + SearchQueryFreshnessContext context) + => BuildSearchRecipeQueryFreshness( + context, + queryResults.Select(query => SuccessfulSearchQueryObservation( + context, + query.Name, + query.MinimumMatchedCount))); + + private static SearchRecipeQueryFreshnessJsonResult BuildSearchRecipeQueryFreshness( + IReadOnlyList queryResults, + SearchQueryFreshnessContext context) + => BuildSearchRecipeQueryFreshness( + context, + queryResults.Select(query => SuccessfulSearchQueryObservation( + context, + query.Name, + query.MinimumMatchedCount))); + + private static SearchRecipeQueryFreshnessJsonResult BuildSearchRecipeQueryFreshness( + IReadOnlyList queryResults, + SearchQueryFreshnessContext context) + => BuildSearchRecipeQueryFreshness( + context, + queryResults.Select(query => SuccessfulSearchQueryObservation( + context, + query.Name, + query.Count))); + + private static SearchRecipeQueryFreshnessJsonResult BuildSearchRecipeQueryFreshness( + IEnumerable<(string Name, int Count)> queryResults, + SearchQueryFreshnessContext context) + => BuildSearchRecipeQueryFreshness( + context, + queryResults.Select(query => SuccessfulSearchQueryObservation( + context, + query.Name, + query.Count))); + + internal static SearchRecipeQueryFreshnessJsonResult BuildSearchRecipeQueryFreshnessForTests( + SearchQueryFreshnessContext context, + IEnumerable observations) + => BuildSearchRecipeQueryFreshness(context, observations); + + private static SearchRecipeQueryFreshnessJsonResult BuildSearchRecipeQueryFreshness( + SearchQueryFreshnessContext context, + IEnumerable observations) + { + var observedByName = observations + .GroupBy(observation => observation.Name, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal); + var states = new List(); + var recipeChanged = !string.Equals( + context.ExpectedRecipeVersion, + context.ExecutedRecipeVersion, + StringComparison.Ordinal); + + foreach (var expected in context.ExpectedQueries) + { + if (!observedByName.Remove(expected.Name, out var observation)) + { + states.Add(new( + expected.Name, + "invalid", + "unknown", + null, + "missing_query_result", + expected.DefinitionVersion)); + continue; + } - private static SearchRecipeQueryFreshnessJsonResult BuildSearchRecipeQueryFreshness(IReadOnlyList queryResults) - => BuildSearchRecipeQueryFreshness(queryResults.Select(query => (query.Name, query.MinimumMatchedCount))); + var resultState = observation.MatchCount switch + { + > 0 => "matched", + 0 => "zero_match", + _ => "unknown", + }; + if (!observation.ExecutionSucceeded) + { + states.Add(new( + expected.Name, + "invalid", + resultState, + observation.MatchCount, + string.IsNullOrWhiteSpace(observation.FailureReason) + ? "query_execution_failed" + : observation.FailureReason!, + observation.DefinitionVersion)); + continue; + } - private static SearchRecipeQueryFreshnessJsonResult BuildSearchRecipeQueryFreshness(IReadOnlyList queryResults) - => BuildSearchRecipeQueryFreshness(queryResults.Select(query => (query.Name, query.Count))); + if (recipeChanged) + { + states.Add(new( + expected.Name, + "stale", + resultState, + observation.MatchCount, + "recipe_definition_changed", + observation.DefinitionVersion)); + continue; + } - private static SearchRecipeQueryFreshnessJsonResult BuildSearchRecipeQueryFreshness(IEnumerable<(string Name, int Count)> queryResults) - { - var results = queryResults.ToList(); - var staleQueryNames = results - .Where(query => query.Count == 0) + if (!string.Equals(expected.DefinitionVersion, observation.DefinitionVersion, StringComparison.Ordinal)) + { + states.Add(new( + expected.Name, + "stale", + resultState, + observation.MatchCount, + "query_definition_changed", + observation.DefinitionVersion)); + continue; + } + + if (string.Equals(context.IndexState, "stale", StringComparison.Ordinal)) + { + states.Add(new( + expected.Name, + "stale", + resultState, + observation.MatchCount, + context.IndexReason ?? "index_stale", + observation.DefinitionVersion)); + continue; + } + + states.Add(new( + expected.Name, + "clean", + resultState, + observation.MatchCount, + "executed_current_definition", + observation.DefinitionVersion)); + } + + foreach (var unexpected in observedByName.Values.OrderBy(observation => observation.Name, StringComparer.Ordinal)) + { + states.Add(new( + unexpected.Name, + "invalid", + unexpected.MatchCount switch + { + > 0 => "matched", + 0 => "zero_match", + _ => "unknown", + }, + unexpected.MatchCount, + "unexpected_query_result", + unexpected.DefinitionVersion)); + } + + var distinctFreshnessStates = states + .Select(query => query.FreshnessState) + .Distinct(StringComparer.Ordinal) + .ToList(); + var aggregateState = distinctFreshnessStates.Count switch + { + 0 => "clean", + 1 => distinctFreshnessStates[0], + _ => "mixed", + }; + var staleQueryNames = states + .Where(query => query.FreshnessState == "stale") + .Select(query => query.Name) + .ToList(); + var invalidQueryNames = states + .Where(query => query.FreshnessState == "invalid") .Select(query => query.Name) .ToList(); + var cleanZeroMatchQueryNames = states + .Where(query => query.FreshnessState == "clean" && query.ResultState == "zero_match") + .Select(query => query.Name) + .ToList(); + return new( - results.Count(query => query.Count > 0), + states.Count(query => query.ResultState == "matched"), + states.Count(query => query.ResultState == "zero_match"), + staleQueryNames, + aggregateState, + states.Count(query => query.FreshnessState == "clean"), + states.Count(query => query.ResultState == "matched"), + cleanZeroMatchQueryNames.Count, + cleanZeroMatchQueryNames, staleQueryNames.Count, - staleQueryNames); + invalidQueryNames.Count, + invalidQueryNames, + context.IndexState, + context.IndexReason, + context.ExecutedRecipeVersion, + states); } + private static SearchQueryFreshnessObservation SuccessfulSearchQueryObservation( + SearchQueryFreshnessContext context, + string name, + int count) + { + var definitionVersion = context.ExpectedQueries + .FirstOrDefault(query => string.Equals(query.Name, name, StringComparison.Ordinal)) + ?.DefinitionVersion + ?? SearchQueryFreshnessUnknownDefinitionVersion; + return new(name, count, definitionVersion, true, null); + } + + private static SearchQueryFreshnessContext BuildSearchRecipeFreshnessContext( + DbReader reader, + SearchAuditRecipe recipe, + IReadOnlyList selectedQueries) + { + var recipeVersion = BuildSearchDefinitionVersion( + "audit-recipe-v1", + recipe, + CliJsonSerializerContext.Default.SearchAuditRecipe); + return new( + ResolveSearchQueryIndexFreshness(reader, out var indexReason), + indexReason, + recipeVersion, + recipeVersion, + selectedQueries + .Select(query => new SearchQueryFreshnessExpectedQuery( + query.Name, + BuildSearchDefinitionVersion( + "audit-recipe-query-v1", + query, + CliJsonSerializerContext.Default.SearchAuditRecipeQuery))) + .ToList()); + } + + private static SearchQueryFreshnessContext BuildNamedSearchFreshnessContext( + DbReader reader, + IReadOnlyList queries, + QueryCommandOptions options, + bool userExact) + => new( + ResolveSearchQueryIndexFreshness(reader, out var indexReason), + indexReason, + null, + null, + queries + .Select(query => new SearchQueryFreshnessExpectedQuery( + query.Name, + BuildSearchDefinitionVersion( + "named-query-v1", + query.Name, + query.Query, + options.RawFts.ToString(CultureInfo.InvariantCulture), + userExact.ToString(CultureInfo.InvariantCulture), + options.Prefix.ToString(CultureInfo.InvariantCulture), + options.TokenBoundary.ToString(CultureInfo.InvariantCulture)))) + .ToList()); + + private static string ResolveSearchQueryIndexFreshness(DbReader reader, out string? reason) + { + var health = reader.GetWorkspaceIndexHealth(); + if (health.IndexNewerThanReader) + { + reason = "index_newer_than_reader"; + return "stale"; + } + if (!health.IndexComplete) + { + reason = "index_incomplete"; + return "stale"; + } + + var projectRoot = s_activeQueryProjectRoot; + var indexedHead = reader.GetMetaString(DbContext.IndexedHeadShaMetaKey); + indexedHead = string.IsNullOrWhiteSpace(indexedHead) + ? reader.GetMetaString(DbContext.IndexedHeadCommitMetaKey) + : indexedHead; + var workspaceHead = string.IsNullOrWhiteSpace(projectRoot) + ? null + : GitHelper.TryGetHeadCommit(projectRoot); + if (!string.IsNullOrWhiteSpace(indexedHead) + && !string.IsNullOrWhiteSpace(workspaceHead) + && !string.Equals(indexedHead, workspaceHead, StringComparison.Ordinal)) + { + reason = "index_head_changed"; + return "stale"; + } + + reason = null; + return "current"; + } + + private static string BuildSearchDefinitionVersion( + string contract, + T definition, + JsonTypeInfo jsonTypeInfo) + { + var json = JsonSerializer.Serialize(definition, jsonTypeInfo); + return BuildSearchDefinitionVersion(contract, json); + } + + private static string BuildSearchDefinitionVersion(string contract, params string[] parts) + { + var identity = new StringBuilder(contract); + foreach (var part in parts) + identity.Append('\0').Append(part.Length).Append(':').Append(part); + return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(identity.ToString()))).ToLowerInvariant(); + } + + internal const string SearchQueryFreshnessUnknownDefinitionVersion = "unknown"; + + internal sealed record SearchQueryFreshnessExpectedQuery( + string Name, + string DefinitionVersion); + + internal sealed record SearchQueryFreshnessObservation( + string Name, + int? MatchCount, + string DefinitionVersion, + bool ExecutionSucceeded, + string? FailureReason); + + internal sealed record SearchQueryFreshnessContext( + string IndexState, + string? IndexReason, + string? ExpectedRecipeVersion, + string? ExecutedRecipeVersion, + IReadOnlyList ExpectedQueries); + private static SearchRecipeScopeJsonResult BuildSearchRecipeScope(SearchAuditRecipe recipe, QueryCommandOptions options) { var scopeName = options.AuditScopeExplicit ? options.AuditScope : recipe.DefaultScope; @@ -2504,48 +2837,91 @@ private static List CountSearchName DbReader reader, QueryCommandOptions options, bool userExact, + SearchQueryFreshnessContext freshnessContext, out int total, - out int fileCount) + out int fileCount, + out List freshnessObservations, + out bool hasFailures) { var queryCounts = new List(); + freshnessObservations = []; var paths = new HashSet(StringComparer.Ordinal); total = 0; + hasFailures = false; foreach (var namedQuery in options.NamedSearchQueries) { - var results = reader.Search( - namedQuery.Query, - int.MaxValue, - options.Lang, - options.RawFts, - options.PathPatterns, - options.ExcludePaths, - options.ExcludeTests, - !options.NoDedup, - options.Since, - userExact, - options.Prefix, - !options.NoVisibilityRank, - guardFilters: options.GuardFilters, - guardWindow: options.GuardWindow, - guardScope: options.GuardScope); - var rows = BuildSearchDisplayRows(results, options, userExact, namedQuery.Query); - var count = rows.Count; - var fileCountForQuery = rows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count(); - foreach (var path in rows.Select(row => row.Result.Path)) - paths.Add(path); - - total += count; - queryCounts.Add(new SearchNamedBatchCountSummaryQueryJsonResult( - namedQuery.Name, - namedQuery.Query, - count, - fileCountForQuery)); + try + { + var results = reader.Search( + namedQuery.Query, + int.MaxValue, + options.Lang, + options.RawFts, + options.PathPatterns, + options.ExcludePaths, + options.ExcludeTests, + !options.NoDedup, + options.Since, + userExact, + options.Prefix, + !options.NoVisibilityRank, + guardFilters: options.GuardFilters, + guardWindow: options.GuardWindow, + guardScope: options.GuardScope); + var rows = BuildSearchDisplayRows(results, options, userExact, namedQuery.Query); + var count = rows.Count; + var fileCountForQuery = rows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count(); + foreach (var path in rows.Select(row => row.Result.Path)) + paths.Add(path); + + total += count; + queryCounts.Add(new SearchNamedBatchCountSummaryQueryJsonResult( + namedQuery.Name, + namedQuery.Query, + count, + fileCountForQuery)); + freshnessObservations.Add(SuccessfulSearchQueryObservation( + freshnessContext, + namedQuery.Name, + count)); + } + catch (Exception ex) when (TryClassifySearchQueryExecutionFailure(ex, out _)) + { + TryClassifySearchQueryExecutionFailure(ex, out var failureReason); + hasFailures = true; + queryCounts.Add(new SearchNamedBatchCountSummaryQueryJsonResult( + namedQuery.Name, + namedQuery.Query, + 0, + 0)); + var definitionVersion = freshnessContext.ExpectedQueries + .First(query => string.Equals(query.Name, namedQuery.Name, StringComparison.Ordinal)) + .DefinitionVersion; + freshnessObservations.Add(new( + namedQuery.Name, + null, + definitionVersion, + false, + failureReason)); + } } fileCount = paths.Count; return queryCounts; } + private static bool TryClassifySearchQueryExecutionFailure(Exception exception, out string reason) + { + reason = exception switch + { + FtsQuerySyntaxException => "query_syntax_invalid", + SearchGuardCandidateLimitException => "query_guard_limit_exceeded", + SearchQueryLimitException => "query_limit_exceeded", + _ => string.Empty, + }; + return reason.Length > 0; + } + private static List ApplySearchRecipeFileRejectQueries( DbReader reader, List results, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index 61b737e28..b6e30f1e3 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -930,29 +930,56 @@ private static int RunSearchNamedBatchCount(QueryCommandOptions options, JsonSer { return WithDb(options, jsonOptions, reader => { - var queryCounts = CountSearchNamedBatchQueryResults(reader, options, userExact, out var total, out var fileCount); + var freshnessContext = BuildNamedSearchFreshnessContext( + reader, + options.NamedSearchQueries, + options, + userExact); + var queryCounts = CountSearchNamedBatchQueryResults( + reader, + options, + userExact, + freshnessContext, + out var total, + out var fileCount, + out var freshnessObservations, + out var hasFailures); if (options.Json) { + var freshness = BuildSearchRecipeQueryFreshness( + freshnessContext, + freshnessObservations); var json = JsonSerializer.Serialize( new SearchNamedBatchCountSummaryRunJsonResult( JsonOutputContract.ApiVersion, queryCounts.Count, total, fileCount, - BuildSearchRecipeQueryFreshness(queryCounts.Select(query => (query.Name, query.Count))), + freshness, queryCounts), CliJsonSerializerContextFactory.Create(jsonOptions).SearchNamedBatchCountSummaryRunJsonResult); - return WriteJsonObjectWithOptionalByteLimit( + var writeExitCode = WriteJsonObjectWithOptionalByteLimit( json, options, "named-query count summary", "Use a larger --max-json-bytes value or narrow the named-query selection."); + if (writeExitCode != CommandExitCodes.Success || !hasFailures) + return writeExitCode; + + CommandErrorWriter.WriteStderr( + $"Error [{CommandErrorCodes.UsageError}]: one or more named queries failed; inspect query_freshness.invalid_query_names."); + return CommandExitCodes.UsageError; } Console.WriteLine(total.ToString(CultureInfo.InvariantCulture)); CommandErrorWriter.WriteStderr($"({total} named-query results in {fileCount} files across {queryCounts.Count} queries)"); - return CommandExitCodes.Success; + if (!hasFailures) + return CommandExitCodes.Success; + + CommandErrorWriter.WriteStderr( + $"Error [{CommandErrorCodes.UsageError}]: one or more named queries failed."); + return CommandExitCodes.UsageError; }); } diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 9d866dca6..6a607b3a0 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -4507,7 +4507,33 @@ internal sealed record SearchRecipeRunSummaryJsonResult( internal sealed record SearchRecipeQueryFreshnessJsonResult( [property: JsonPropertyName("positive_evidence_query_count")] int PositiveEvidenceQueryCount, [property: JsonPropertyName("zero_result_query_count")] int ZeroResultQueryCount, - [property: JsonPropertyName("stale_query_names")] List StaleQueryNames); + [property: JsonPropertyName("stale_query_names")] List StaleQueryNames, + [property: JsonPropertyName("state")] string State, + [property: JsonPropertyName("clean_query_count")] int CleanQueryCount, + [property: JsonPropertyName("matched_query_count")] int MatchedQueryCount, + [property: JsonPropertyName("clean_zero_match_query_count")] int CleanZeroMatchQueryCount, + [property: JsonPropertyName("clean_zero_match_query_names")] List CleanZeroMatchQueryNames, + [property: JsonPropertyName("stale_query_count")] int StaleQueryCount, + [property: JsonPropertyName("invalid_query_count")] int InvalidQueryCount, + [property: JsonPropertyName("invalid_query_names")] List InvalidQueryNames, + [property: JsonPropertyName("index_state")] string IndexState, + [property: JsonPropertyName("index_reason")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? IndexReason, + [property: JsonPropertyName("recipe_version")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? RecipeVersion, + [property: JsonPropertyName("queries")] List Queries); + +internal sealed record SearchRecipeQueryFreshnessStateJsonResult( + [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("freshness_state")] string FreshnessState, + [property: JsonPropertyName("result_state")] string ResultState, + [property: JsonPropertyName("match_count")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + int? MatchCount, + [property: JsonPropertyName("reason")] string Reason, + [property: JsonPropertyName("definition_version")] string DefinitionVersion); internal sealed record SearchRowSelectorJsonResult( [property: JsonPropertyName("mode")] string Mode, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index e2e67c77f..45d170cd0 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -23,6 +23,88 @@ public void GetSearchRecipeResultRanking_BypassesContextRankingWhenTotalLimitIsE QueryCommandRunner.GetSearchRecipeResultRanking(SearchResultRanking.CredentialContext, resultLimit: 0)); } + [Fact] + public void BuildSearchRecipeQueryFreshness_SeparatesResultsFromInvalidation_Issue4907() + { + var expectedQueries = new[] + { + new QueryCommandRunner.SearchQueryFreshnessExpectedQuery("matched", "query-v1"), + new QueryCommandRunner.SearchQueryFreshnessExpectedQuery("zero", "query-v1"), + }; + var cleanContext = new QueryCommandRunner.SearchQueryFreshnessContext( + "current", + null, + "recipe-v1", + "recipe-v1", + expectedQueries); + var cleanObservations = new[] + { + new QueryCommandRunner.SearchQueryFreshnessObservation("matched", 2, "query-v1", true, null), + new QueryCommandRunner.SearchQueryFreshnessObservation("zero", 0, "query-v1", true, null), + }; + + var clean = QueryCommandRunner.BuildSearchRecipeQueryFreshnessForTests(cleanContext, cleanObservations); + Assert.Equal("clean", clean.State); + Assert.Equal(2, clean.CleanQueryCount); + Assert.Equal(1, clean.MatchedQueryCount); + Assert.Equal(1, clean.CleanZeroMatchQueryCount); + Assert.Equal(["zero"], clean.CleanZeroMatchQueryNames); + Assert.Empty(clean.StaleQueryNames); + Assert.Equal(clean.Queries.Count, clean.CleanQueryCount + clean.StaleQueryCount + clean.InvalidQueryCount); + + var missing = QueryCommandRunner.BuildSearchRecipeQueryFreshnessForTests( + cleanContext, + cleanObservations.Take(1)); + Assert.Equal("mixed", missing.State); + Assert.Equal(["zero"], missing.InvalidQueryNames); + Assert.Equal( + "missing_query_result", + Assert.Single(missing.Queries, query => query.Name == "zero").Reason); + + var changedRecipe = QueryCommandRunner.BuildSearchRecipeQueryFreshnessForTests( + cleanContext with { ExecutedRecipeVersion = "recipe-v2" }, + cleanObservations); + Assert.Equal("stale", changedRecipe.State); + Assert.Equal(2, changedRecipe.StaleQueryCount); + Assert.All(changedRecipe.Queries, query => Assert.Equal("recipe_definition_changed", query.Reason)); + + var changedQuery = QueryCommandRunner.BuildSearchRecipeQueryFreshnessForTests( + cleanContext, + [ + cleanObservations[0], + cleanObservations[1] with { DefinitionVersion = "query-v2" }, + ]); + Assert.Equal("mixed", changedQuery.State); + Assert.Equal(["zero"], changedQuery.StaleQueryNames); + Assert.Equal( + "query_definition_changed", + Assert.Single(changedQuery.Queries, query => query.Name == "zero").Reason); + + var staleIndex = QueryCommandRunner.BuildSearchRecipeQueryFreshnessForTests( + cleanContext with { IndexState = "stale", IndexReason = "index_head_changed" }, + cleanObservations); + Assert.Equal("stale", staleIndex.State); + Assert.Equal(2, staleIndex.StaleQueryCount); + Assert.Equal(1, staleIndex.ZeroResultQueryCount); + Assert.Equal(0, staleIndex.CleanZeroMatchQueryCount); + Assert.All(staleIndex.Queries, query => Assert.Equal("index_head_changed", query.Reason)); + + var failedChild = QueryCommandRunner.BuildSearchRecipeQueryFreshnessForTests( + cleanContext, + [ + cleanObservations[0], + new("zero", null, "query-v1", false, "query_execution_failed"), + ]); + Assert.Equal("mixed", failedChild.State); + Assert.Equal(["zero"], failedChild.InvalidQueryNames); + Assert.Equal( + "query_execution_failed", + Assert.Single(failedChild.Queries, query => query.Name == "zero").Reason); + Assert.Equal( + failedChild.Queries.Count, + failedChild.CleanQueryCount + failedChild.StaleQueryCount + failedChild.InvalidQueryCount); + } + [Fact] public void SearchMatchClassifier_McpSchemaDescriptionHasDedicatedOrigin_Issues4416_4864() { @@ -1094,7 +1176,7 @@ public void RunSearch_NamedQueriesCountSummaryJsonCountsAllMatches_Issue4308() "Run nuget push after package validation."); var (countExitCode, countStdout, countStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - ["--named-query=pack=dotnet pack", "--named-query=push=nuget push", "--db", dbPath, "--format", "count", "--json", "--limit", "1"], + ["--named-query=pack=dotnet pack", "--named-query=push=nuget push", "--named-query=todo=TODO", "--db", dbPath, "--format", "count", "--json", "--limit", "1"], _jsonOptions)); var (summaryExitCode, summaryStdout, summaryStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( ["--named-query=pack=dotnet pack", "--named-query=push=nuget push", "--db", dbPath, "--summary-only", "--json", "--limit", "1"], @@ -1108,7 +1190,7 @@ public void RunSearch_NamedQueriesCountSummaryJsonCountsAllMatches_Issue4308() using var summaryDocument = ParseJsonOutput(summaryStdout); var countRoot = countDocument.RootElement; var summaryRoot = summaryDocument.RootElement; - Assert.Equal(2, countRoot.GetProperty("query_count").GetInt32()); + Assert.Equal(3, countRoot.GetProperty("query_count").GetInt32()); Assert.Equal(3, countRoot.GetProperty("result_count").GetInt32()); Assert.Equal(3, countRoot.GetProperty("file_count").GetInt32()); Assert.Equal(3, summaryRoot.GetProperty("result_count").GetInt32()); @@ -1118,7 +1200,67 @@ public void RunSearch_NamedQueriesCountSummaryJsonCountsAllMatches_Issue4308() Assert.Equal(2, pack.GetProperty("file_count").GetInt32()); Assert.False(pack.TryGetProperty("results", out _)); Assert.Equal(2, countRoot.GetProperty("query_freshness").GetProperty("positive_evidence_query_count").GetInt32()); - Assert.Equal(0, countRoot.GetProperty("query_freshness").GetProperty("zero_result_query_count").GetInt32()); + var freshness = countRoot.GetProperty("query_freshness"); + Assert.Equal(1, freshness.GetProperty("zero_result_query_count").GetInt32()); + Assert.Equal("clean", freshness.GetProperty("state").GetString()); + Assert.Equal(3, freshness.GetProperty("clean_query_count").GetInt32()); + Assert.Equal(2, freshness.GetProperty("matched_query_count").GetInt32()); + Assert.Equal(1, freshness.GetProperty("clean_zero_match_query_count").GetInt32()); + Assert.Contains(freshness.GetProperty("clean_zero_match_query_names").EnumerateArray(), name => name.GetString() == "todo"); + Assert.Empty(freshness.GetProperty("stale_query_names").EnumerateArray()); + Assert.False(freshness.TryGetProperty("recipe_version", out _)); + var todo = Assert.Single( + freshness.GetProperty("queries").EnumerateArray(), + query => query.GetProperty("name").GetString() == "todo"); + Assert.Equal("clean", todo.GetProperty("freshness_state").GetString()); + Assert.Equal("zero_match", todo.GetProperty("result_state").GetString()); + + var (failedExitCode, failedStdout, failedStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--named-query=good=dotnet pack", "--named-query=bad=(", "--fts", "--db", dbPath, "--format", "count", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, failedExitCode); + Assert.Contains("one or more named queries failed", failedStderr, StringComparison.Ordinal); + using var failedDocument = ParseJsonOutput(failedStdout); + var failedFreshness = failedDocument.RootElement.GetProperty("query_freshness"); + Assert.Equal("mixed", failedFreshness.GetProperty("state").GetString()); + Assert.Equal(1, failedFreshness.GetProperty("clean_query_count").GetInt32()); + Assert.Equal(1, failedFreshness.GetProperty("invalid_query_count").GetInt32()); + Assert.Equal(0, failedFreshness.GetProperty("zero_result_query_count").GetInt32()); + Assert.Contains(failedFreshness.GetProperty("invalid_query_names").EnumerateArray(), name => name.GetString() == "bad"); + var failedQuery = Assert.Single( + failedFreshness.GetProperty("queries").EnumerateArray(), + query => query.GetProperty("name").GetString() == "bad"); + Assert.Equal("invalid", failedQuery.GetProperty("freshness_state").GetString()); + Assert.Equal("unknown", failedQuery.GetProperty("result_state").GetString()); + Assert.Equal("query_syntax_invalid", failedQuery.GetProperty("reason").GetString()); + + using (var connection = new SqliteConnection($"Data Source={dbPath}")) + { + connection.Open(); + using var command = connection.CreateCommand(); + command.CommandText = + "INSERT INTO codeindex_meta(key, value) VALUES ($key, 'incomplete') " + + "ON CONFLICT(key) DO UPDATE SET value = excluded.value"; + command.Parameters.AddWithValue("$key", DbContext.IndexCompletenessMetaKey); + command.ExecuteNonQuery(); + } + + var (staleExitCode, staleStdout, staleStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--named-query=pack=dotnet pack", "--named-query=push=nuget push", "--named-query=todo=TODO", "--db", dbPath, "--format", "count", "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, staleExitCode); + Assert.Equal(string.Empty, staleStderr); + using var staleDocument = ParseJsonOutput(staleStdout); + var staleFreshness = staleDocument.RootElement.GetProperty("query_freshness"); + Assert.Equal("stale", staleFreshness.GetProperty("state").GetString()); + Assert.Equal("stale", staleFreshness.GetProperty("index_state").GetString()); + Assert.Equal("index_incomplete", staleFreshness.GetProperty("index_reason").GetString()); + Assert.Equal(3, staleFreshness.GetProperty("stale_query_count").GetInt32()); + Assert.Equal(1, staleFreshness.GetProperty("zero_result_query_count").GetInt32()); + Assert.Equal(0, staleFreshness.GetProperty("clean_zero_match_query_count").GetInt32()); + Assert.Contains(staleFreshness.GetProperty("stale_query_names").EnumerateArray(), name => name.GetString() == "todo"); } finally { @@ -5311,6 +5453,12 @@ public void RunSearch_RecipeSarifPreservesBoundedAuditMetadataAndStableIdentifie Assert.Equal(1, runProperties.GetProperty("query_count").GetInt32()); Assert.Equal(1, runProperties.GetProperty("result_count").GetInt32()); Assert.Equal(1, runProperties.GetProperty("limit_per_query").GetInt32()); + var sarifFreshness = runProperties.GetProperty("query_freshness"); + Assert.Equal("clean", sarifFreshness.GetProperty("state").GetString()); + Assert.Equal(1, sarifFreshness.GetProperty("clean_query_count").GetInt32()); + Assert.Equal(1, sarifFreshness.GetProperty("matched_query_count").GetInt32()); + Assert.Equal(0, sarifFreshness.GetProperty("clean_zero_match_query_count").GetInt32()); + Assert.Empty(sarifFreshness.GetProperty("stale_query_names").EnumerateArray()); Assert.True(runProperties.GetProperty("truncation").GetProperty("truncated").GetBoolean()); var querySummary = Assert.Single(runProperties.GetProperty("queries").EnumerateArray()); Assert.Equal("raw-diagnostic-echo", querySummary.GetProperty("name").GetString()); @@ -5330,6 +5478,10 @@ public void RunSearch_RecipeSarifPreservesBoundedAuditMetadataAndStableIdentifie Assert.Equal(2, totalLimitRun.GetProperty("properties").GetProperty("query_count").GetInt32()); Assert.Equal(1, totalLimitRun.GetProperty("properties").GetProperty("result_count").GetInt32()); Assert.Equal(1, totalLimitRun.GetProperty("properties").GetProperty("total_limit").GetInt32()); + var totalLimitFreshness = totalLimitRun.GetProperty("properties").GetProperty("query_freshness"); + Assert.Equal("clean", totalLimitFreshness.GetProperty("state").GetString()); + Assert.Equal(2, totalLimitFreshness.GetProperty("clean_query_count").GetInt32()); + Assert.Equal(2, totalLimitFreshness.GetProperty("matched_query_count").GetInt32()); } finally { @@ -5464,7 +5616,7 @@ public void Run(Exception ex) var root = document.RootElement; var queries = root.GetProperty("queries").EnumerateArray().ToArray(); var query = Assert.Single(queries, item => item.GetProperty("name").GetString() == "raw-diagnostic-echo"); - var staleQuery = Assert.Single(queries, item => item.GetProperty("name").GetString() == "unbounded-json-parse"); + var zeroMatchQuery = Assert.Single(queries, item => item.GetProperty("name").GetString() == "unbounded-json-parse"); var freshness = root.GetProperty("query_freshness"); Assert.Equal("risky-code", root.GetProperty("recipe").GetString()); @@ -5475,14 +5627,41 @@ public void Run(Exception ex) Assert.Equal("raw-diagnostic-echo", query.GetProperty("name").GetString()); Assert.Equal(2, query.GetProperty("count").GetInt32()); Assert.Equal(2, query.GetProperty("file_count").GetInt32()); - Assert.Equal(0, staleQuery.GetProperty("count").GetInt32()); - Assert.Equal(0, staleQuery.GetProperty("file_count").GetInt32()); + Assert.Equal(0, zeroMatchQuery.GetProperty("count").GetInt32()); + Assert.Equal(0, zeroMatchQuery.GetProperty("file_count").GetInt32()); Assert.Equal(1, freshness.GetProperty("positive_evidence_query_count").GetInt32()); Assert.Equal(1, freshness.GetProperty("zero_result_query_count").GetInt32()); - Assert.Contains(freshness.GetProperty("stale_query_names").EnumerateArray(), name => name.GetString() == "unbounded-json-parse"); + Assert.Equal("clean", freshness.GetProperty("state").GetString()); + Assert.Equal(2, freshness.GetProperty("clean_query_count").GetInt32()); + Assert.Equal(1, freshness.GetProperty("matched_query_count").GetInt32()); + Assert.Equal(1, freshness.GetProperty("clean_zero_match_query_count").GetInt32()); + Assert.Contains(freshness.GetProperty("clean_zero_match_query_names").EnumerateArray(), name => name.GetString() == "unbounded-json-parse"); + Assert.Equal(0, freshness.GetProperty("stale_query_count").GetInt32()); + Assert.Equal(0, freshness.GetProperty("invalid_query_count").GetInt32()); + Assert.Empty(freshness.GetProperty("stale_query_names").EnumerateArray()); + Assert.Equal("current", freshness.GetProperty("index_state").GetString()); + Assert.False(freshness.TryGetProperty("index_reason", out _)); + Assert.Equal(64, freshness.GetProperty("recipe_version").GetString()!.Length); + Assert.Equal(2, freshness.GetProperty("queries").GetArrayLength()); + var zeroMatchFreshness = Assert.Single( + freshness.GetProperty("queries").EnumerateArray(), + item => item.GetProperty("name").GetString() == "unbounded-json-parse"); + Assert.Equal("clean", zeroMatchFreshness.GetProperty("freshness_state").GetString()); + Assert.Equal("zero_match", zeroMatchFreshness.GetProperty("result_state").GetString()); + Assert.Equal("executed_current_definition", zeroMatchFreshness.GetProperty("reason").GetString()); Assert.False(query.TryGetProperty("query", out _)); Assert.False(query.TryGetProperty("top_files", out _)); + var (textExitCode, textStdout, textStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "risky-code/unbounded-json-parse", "--db", dbPath, "--origin", "code", "--limit", "1"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, textExitCode); + Assert.Contains("Query freshness: clean", textStdout, StringComparison.Ordinal); + Assert.Contains("clean zero-match=1", textStdout, StringComparison.Ordinal); + Assert.DoesNotContain("Stale queries:", textStdout, StringComparison.Ordinal); + Assert.Contains("0 recipe results across 1 queries", textStderr, StringComparison.Ordinal); + var (capExitCode, capStdout, capStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( ["--recipe", "risky-code/raw-diagnostic-echo", "--db", dbPath, "--format", "count", "--summary-only", "--origin", "code", "--max-json-bytes", "1"], _jsonOptions)); @@ -9137,7 +9316,11 @@ public void Run(System.Exception ex) Assert.Equal(1, root.GetProperty("result_count").GetInt32()); Assert.Equal(1, freshness.GetProperty("positive_evidence_query_count").GetInt32()); Assert.Equal(1, freshness.GetProperty("zero_result_query_count").GetInt32()); - Assert.Contains(freshness.GetProperty("stale_query_names").EnumerateArray(), name => name.GetString() == "unbounded-json-parse"); + Assert.Equal("clean", freshness.GetProperty("state").GetString()); + Assert.Equal(2, freshness.GetProperty("clean_query_count").GetInt32()); + Assert.Equal(1, freshness.GetProperty("clean_zero_match_query_count").GetInt32()); + Assert.Contains(freshness.GetProperty("clean_zero_match_query_names").EnumerateArray(), name => name.GetString() == "unbounded-json-parse"); + Assert.Empty(freshness.GetProperty("stale_query_names").EnumerateArray()); Assert.Equal("raw-diagnostic-echo", source.GetProperty("query_name").GetString()); Assert.Contains(source.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("raw exception messages", StringComparison.Ordinal)); } @@ -9197,6 +9380,9 @@ public void Run() Assert.Equal(1, root.GetProperty("result_count").GetInt32()); Assert.Equal(2, freshness.GetProperty("positive_evidence_query_count").GetInt32()); Assert.Equal(0, freshness.GetProperty("zero_result_query_count").GetInt32()); + Assert.Equal("clean", freshness.GetProperty("state").GetString()); + Assert.Equal(2, freshness.GetProperty("clean_query_count").GetInt32()); + Assert.Equal(0, freshness.GetProperty("clean_zero_match_query_count").GetInt32()); Assert.Empty(freshness.GetProperty("stale_query_names").EnumerateArray()); } finally From d2da127ca5c79867eb63918be0957f0a769c1f6f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 1 Aug 2026 00:03:44 +0900 Subject: [PATCH 2/4] Address adversarial query freshness findings (#4907) --- .../Cli/QueryCommandRunner.SearchRecipes.cs | 724 +++++++++++------- .../QueryCommandRunnerSearchTests.cs | 150 ++++ 2 files changed, 611 insertions(+), 263 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index df70a883c..745a985c2 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -673,8 +673,11 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp scope, options, userExact, + freshnessContext: null, out _, - out var rowMinimumMatchedTotal); + out var rowMinimumMatchedTotal, + out _, + out _); var stream = WriteRecipeSearchResultRows( reader, recipe.Name, @@ -688,8 +691,21 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp if (options.OutputFormat == OutputFormatCompact) { - var compactQueryResults = CollectSearchRecipeCompactQueryResults(reader, selection.Queries, scope, options, userExact, out var compactTotal); - var compactFreshnessContext = BuildSearchRecipeFreshnessContext(reader, recipe, selection.Queries); + var compactFreshnessContext = BuildSearchRecipeFreshnessContext( + reader, + recipe, + selection.Queries, + options); + var compactQueryResults = CollectSearchRecipeCompactQueryResults( + reader, + selection.Queries, + scope, + options, + userExact, + compactFreshnessContext, + out var compactTotal, + out var compactFreshnessObservations, + out var compactHasFailures); var compactPayload = BuildSearchRecipeCompactRunPayload( recipe, selection.Queries, @@ -698,22 +714,47 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp jsonOptions, compactQueryResults, compactTotal, - compactFreshnessContext); + compactFreshnessContext, + compactFreshnessObservations); var compactJson = compactPayload.ToJsonString(GetJsonNodeSerializationOptions(jsonOptions)); - return WriteJsonObjectWithOptionalByteLimit( + return CompleteSearchRecipeOutput( + WriteJsonObjectWithOptionalByteLimit( compactJson, options, "recipe compact", - $"Reduce --limit or --total-limit, select one child query with {options.InvocationContext.RecipeCursorSelectorSyntax}, stream rows with --json=ndjson, or increase --max-json-bytes."); + $"Reduce --limit or --total-limit, select one child query with {options.InvocationContext.RecipeCursorSelectorSyntax}, stream rows with --json=ndjson, or increase --max-json-bytes."), + compactHasFailures); } - var queryResults = CollectSearchRecipeQueryResults(reader, selection.Queries, scope, options, userExact, out var total, out _); - var freshnessContext = BuildSearchRecipeFreshnessContext(reader, recipe, selection.Queries); + var freshnessContext = BuildSearchRecipeFreshnessContext( + reader, + recipe, + selection.Queries, + options); + var queryResults = CollectSearchRecipeQueryResults( + reader, + selection.Queries, + scope, + options, + userExact, + freshnessContext, + out var total, + out _, + out var freshnessObservations, + out var hasFailures); if (options.OutputFormat == OutputFormatSarif) { - WriteSearchRecipeSarif(recipe, scope, queryResults, total, options, jsonOptions, freshnessContext); - return CommandExitCodes.Success; + WriteSearchRecipeSarif( + recipe, + scope, + queryResults, + total, + options, + jsonOptions, + freshnessContext, + freshnessObservations); + return CompleteSearchRecipeOutput(CommandExitCodes.Success, hasFailures); } if (options.Json) @@ -731,14 +772,17 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp options.TotalLimit, total, options.InvocationContext, - freshnessContext), + freshnessContext, + freshnessObservations), queryResults), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeRunJsonResult); - return WriteJsonObjectWithOptionalByteLimit( - json, - options, - "recipe search", - "Reduce --limit, use --snippet-lines 0, or increase --max-json-bytes."); + return CompleteSearchRecipeOutput( + WriteJsonObjectWithOptionalByteLimit( + json, + options, + "recipe search", + "Reduce --limit, use --snippet-lines 0, or increase --max-json-bytes."), + hasFailures); } Console.WriteLine($"Recipe: {recipe.Name}"); @@ -785,9 +829,11 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp Console.WriteLine(); } - WriteSearchRecipeFreshnessText(BuildSearchRecipeQueryFreshness(queryResults, freshnessContext)); + WriteSearchRecipeFreshnessText(BuildSearchRecipeQueryFreshness( + freshnessContext, + freshnessObservations)); CommandErrorWriter.WriteStderr($"({total} recipe results across {selection.Queries.Count} queries)"); - return CommandExitCodes.Success; + return CompleteSearchRecipeOutput(CommandExitCodes.Success, hasFailures); }, _ => { if (ndjsonTerminalLine != null && !options.ResultsOnly) @@ -802,7 +848,8 @@ private static void WriteSearchRecipeSarif( int total, QueryCommandOptions options, JsonSerializerOptions jsonOptions, - SearchQueryFreshnessContext freshnessContext) + SearchQueryFreshnessContext freshnessContext, + IReadOnlyList freshnessObservations) { var summary = BuildSearchRecipeRunSummary( queryResults, @@ -810,7 +857,8 @@ private static void WriteSearchRecipeSarif( options.TotalLimit, total, options.InvocationContext, - freshnessContext); + freshnessContext, + freshnessObservations); var querySummaries = new JsonArray(); foreach (var queryResult in queryResults) { @@ -831,7 +879,7 @@ private static void WriteSearchRecipeSarif( ["scope"] = JsonSerializer.SerializeToNode( scope, CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeScopeJsonResult), - ["query_count"] = queryResults.Count, + ["query_count"] = summary.QueryFreshness.Queries.Count, ["result_count"] = total, ["query_freshness"] = JsonSerializer.SerializeToNode( summary.QueryFreshness, @@ -967,7 +1015,8 @@ private static JsonObject BuildSearchRecipeCompactRunPayload( JsonSerializerOptions jsonOptions, List compactQueryResults, int compactTotal, - SearchQueryFreshnessContext freshnessContext) + SearchQueryFreshnessContext freshnessContext, + IReadOnlyList freshnessObservations) { var run = new SearchRecipeCompactRunJsonResult( JsonOutputContract.ApiVersion, @@ -988,7 +1037,8 @@ private static JsonObject BuildSearchRecipeCompactRunPayload( options.TotalLimit, compactTotal, options.InvocationContext, - freshnessContext), + freshnessContext, + freshnessObservations), compactQueryResults); var payload = JsonSerializer.SerializeToNode( run, @@ -1378,12 +1428,26 @@ private static int RunSearchRecipeIssueDrafts( return WithDb(options, jsonOptions, reader => { - var queryResults = CollectSearchRecipeQueryResults(reader, selection.Queries, scope, options, userExact, out var total, out _); + var freshnessContext = BuildSearchRecipeFreshnessContext( + reader, + recipe, + selection.Queries, + options); + var queryResults = CollectSearchRecipeQueryResults( + reader, + selection.Queries, + scope, + options, + userExact, + freshnessContext, + out var total, + out _, + out var freshnessObservations, + out var hasFailures); var drafts = queryResults .Where(queryResult => queryResult.Count > 0) .Select(queryResult => ToSearchIssueDraft(recipe, queryResult, preflight, options)) .ToList(); - var freshnessContext = BuildSearchRecipeFreshnessContext(reader, recipe, selection.Queries); var fullRecipeMetadata = options.SummaryOnly ? null : ToSearchRecipeListItem(recipe, selection.Queries); var recipeSummaryMetadata = options.SummaryOnly ? ToSearchRecipeCompactListItem(recipe, selection.Queries) : null; var json = JsonSerializer.Serialize( @@ -1395,7 +1459,7 @@ private static int RunSearchRecipeIssueDrafts( scope, selection.Queries.Count, total, - BuildSearchRecipeQueryFreshness(queryResults, freshnessContext), + BuildSearchRecipeQueryFreshness(freshnessContext, freshnessObservations), drafts.Count, new SuggestionIssueDraftPreflightSummaryJsonResult( preflight.Checked, @@ -1406,11 +1470,13 @@ private static int RunSearchRecipeIssueDrafts( drafts, BuildSearchIssueDraftSelectionAccounting(recipe.Name, queryResults)), CliJsonSerializerContextFactory.Create(jsonOptions).SearchIssueDraftExportJsonResult); - return WriteJsonObjectWithOptionalByteLimit( - json, - options, - "issue-draft", - "Reduce --limit, use --snippet-lines 0, or increase --max-json-bytes."); + return CompleteSearchRecipeOutput( + WriteJsonObjectWithOptionalByteLimit( + json, + options, + "issue-draft", + "Reduce --limit, use --snippet-lines 0, or increase --max-json-bytes."), + hasFailures); }); } @@ -1429,20 +1495,26 @@ private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSeriali var scope = BuildSearchRecipeScope(recipe, options); return WithDb(options, jsonOptions, reader => { + var freshnessContext = options.SummaryOnly + ? BuildSearchRecipeFreshnessContext(reader, recipe, selection.Queries, options) + : null; var queryCounts = CountSearchRecipeQueryResults( reader, selection.Queries, scope, options, userExact, + freshnessContext, out var total, - out var fileCount); - var freshnessContext = BuildSearchRecipeFreshnessContext(reader, recipe, selection.Queries); + out var fileCount, + out var freshnessObservations, + out var hasFailures); if (options.Json) { if (options.SummaryOnly) { + var requiredFreshnessContext = freshnessContext!; var summaryQueries = queryCounts .Select(query => new SearchRecipeCountSummaryQueryJsonResult( query.Name, @@ -1457,14 +1529,18 @@ private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSeriali selection.Queries.Count, total, fileCount, - BuildSearchRecipeQueryFreshness(queryCounts, freshnessContext), + BuildSearchRecipeQueryFreshness( + requiredFreshnessContext, + freshnessObservations), summaryQueries), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeCountSummaryRunJsonResult); - return WriteJsonObjectWithOptionalByteLimit( - summaryJson, - options, - "recipe count summary", - "Use a larger --max-json-bytes value or narrow the recipe/query selection."); + return CompleteSearchRecipeOutput( + WriteJsonObjectWithOptionalByteLimit( + summaryJson, + options, + "recipe count summary", + "Use a larger --max-json-bytes value or narrow the recipe/query selection."), + hasFailures); } var json = JsonSerializer.Serialize( @@ -1652,98 +1728,124 @@ private static List CollectSearchRecipeQueryR SearchRecipeScopeJsonResult scope, QueryCommandOptions options, bool userExact, + SearchQueryFreshnessContext? freshnessContext, out int total, - out int minimumMatchedTotal) + out int minimumMatchedTotal, + out List freshnessObservations, + out bool hasFailures) { var queryResults = new List(); + freshnessObservations = []; total = 0; minimumMatchedTotal = 0; + hasFailures = false; foreach (var recipeQuery in recipeQueries) { - var exact = userExact || recipeQuery.ExactSubstring; - var queryScope = BuildSearchRecipeQueryScope(scope, recipeQuery); - var resultLimit = GetSearchRecipeEffectiveResultLimit(options, total); - var guardFilters = BuildSearchRecipeGuardFilters(options, recipeQuery); - var fetchLimit = GetSearchRecipeFetchLimit(options, resultLimit); - var results = reader.Search( - recipeQuery.Query, - fetchLimit, - options.Lang, - false, - queryScope.PathPatterns, - queryScope.ExcludePaths, - queryScope.ExcludeTests, - !options.NoDedup, - options.Since, - exact, - false, - !options.NoVisibilityRank, - cursor: options.SearchCursor, - guardFilters: guardFilters, - guardWindow: options.GuardWindow, - guardScope: options.GuardScope, - requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery), - resultRanking: GetSearchRecipeResultRanking(recipeQuery.ResultRanking, resultLimit)); - var sourceTotalAuthoritative = IsSearchRecipeSourceTotalAuthoritative( - options, - recipeQuery, - guardFilters, - results.Count, - fetchLimit); - results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); - var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false, recipeQuery: recipeQuery); - var outputSelection = ApplySearchOutputSelection(rows, options, resultLimit, sourceTotalAuthoritative); - rows = outputSelection.Rows; - ApplySearchRecipeAuditClassifications(recipeQuery, rows); - var minimumOmitted = Math.Max(0, outputSelection.OriginalCount - rows.Count); - var selectionReason = GetSearchRecipeSelectionReason(outputSelection); - total += rows.Count; - minimumMatchedTotal += outputSelection.OriginalCount; - queryResults.Add(new SearchRecipeQueryResultJsonResult( - recipeQuery.Name, - recipeQuery.Query, - recipeQuery.Description, - recipeQuery.RecommendedLabels, - recipeQuery.FalsePositiveGuidance, - [.. recipeQuery.RiskEvidence], - ToSearchRecipeGuardFilterJsonResults(recipeQuery.GuardFilters), - exact, - recipeQuery.Severity, - [.. recipeQuery.PathPatterns], - [.. recipeQuery.ExcludePaths], - [.. recipeQuery.MatchOrigins], - [.. recipeQuery.ExcludeOrigins], - [.. recipeQuery.ResultKinds], - [.. recipeQuery.Classifiers], - recipeQuery.StringComparisonTaxonomy, - recipeQuery.BroadCatchTaxonomy, - recipeQuery.NullableContractTaxonomy, - BuildSearchRecipeClassifierCounts(rows), - rows.Count, - rows.Count, - outputSelection.OriginalCount, - minimumOmitted, - selectionReason, - selectionReason != null ? outputSelection.SelectionOmittedCount : null, - resultLimit, - minimumOmitted, - BuildSearchRecipeTopFiles(rows), - outputSelection.LimitTruncated, - outputSelection.LimitTruncated - && !options.FirstPerFile - && !options.SampleSize.HasValue - && rows.Count > 0 - ? FormatSearchCursor(rows[^1].Result) - : null, - rows.Select(row => row.Compact).ToList(), - outputSelection.SourceTotal, - outputSelection.SourceTotalAuthoritative, - outputSelection.SourceTotalAuthoritative ? null : outputSelection.SourceTotal, - outputSelection.SelectedTotal, - outputSelection.Returned, - outputSelection.SelectorOmittedCount, - outputSelection.LimitOmittedCount, - outputSelection.Selectors)); + try + { + var exact = userExact || recipeQuery.ExactSubstring; + var queryScope = BuildSearchRecipeQueryScope(scope, recipeQuery); + var resultLimit = GetSearchRecipeEffectiveResultLimit(options, total); + var guardFilters = BuildSearchRecipeGuardFilters(options, recipeQuery); + var fetchLimit = GetSearchRecipeFetchLimit(options, resultLimit); + var results = reader.Search( + recipeQuery.Query, + fetchLimit, + options.Lang, + false, + queryScope.PathPatterns, + queryScope.ExcludePaths, + queryScope.ExcludeTests, + !options.NoDedup, + options.Since, + exact, + false, + !options.NoVisibilityRank, + cursor: options.SearchCursor, + guardFilters: guardFilters, + guardWindow: options.GuardWindow, + guardScope: options.GuardScope, + requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery), + resultRanking: GetSearchRecipeResultRanking(recipeQuery.ResultRanking, resultLimit)); + var sourceTotalAuthoritative = IsSearchRecipeSourceTotalAuthoritative( + options, + recipeQuery, + guardFilters, + results.Count, + fetchLimit); + results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); + var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false, recipeQuery: recipeQuery); + var outputSelection = ApplySearchOutputSelection(rows, options, resultLimit, sourceTotalAuthoritative); + rows = outputSelection.Rows; + ApplySearchRecipeAuditClassifications(recipeQuery, rows); + var minimumOmitted = Math.Max(0, outputSelection.OriginalCount - rows.Count); + var selectionReason = GetSearchRecipeSelectionReason(outputSelection); + total += rows.Count; + minimumMatchedTotal += outputSelection.OriginalCount; + queryResults.Add(new SearchRecipeQueryResultJsonResult( + recipeQuery.Name, + recipeQuery.Query, + recipeQuery.Description, + recipeQuery.RecommendedLabels, + recipeQuery.FalsePositiveGuidance, + [.. recipeQuery.RiskEvidence], + ToSearchRecipeGuardFilterJsonResults(recipeQuery.GuardFilters), + exact, + recipeQuery.Severity, + [.. recipeQuery.PathPatterns], + [.. recipeQuery.ExcludePaths], + [.. recipeQuery.MatchOrigins], + [.. recipeQuery.ExcludeOrigins], + [.. recipeQuery.ResultKinds], + [.. recipeQuery.Classifiers], + recipeQuery.StringComparisonTaxonomy, + recipeQuery.BroadCatchTaxonomy, + recipeQuery.NullableContractTaxonomy, + BuildSearchRecipeClassifierCounts(rows), + rows.Count, + rows.Count, + outputSelection.OriginalCount, + minimumOmitted, + selectionReason, + selectionReason != null ? outputSelection.SelectionOmittedCount : null, + resultLimit, + minimumOmitted, + BuildSearchRecipeTopFiles(rows), + outputSelection.LimitTruncated, + outputSelection.LimitTruncated + && !options.FirstPerFile + && !options.SampleSize.HasValue + && rows.Count > 0 + ? FormatSearchCursor(rows[^1].Result) + : null, + rows.Select(row => row.Compact).ToList(), + outputSelection.SourceTotal, + outputSelection.SourceTotalAuthoritative, + outputSelection.SourceTotalAuthoritative ? null : outputSelection.SourceTotal, + outputSelection.SelectedTotal, + outputSelection.Returned, + outputSelection.SelectorOmittedCount, + outputSelection.LimitOmittedCount, + outputSelection.Selectors)); + if (freshnessContext != null) + { + freshnessObservations.Add(SuccessfulSearchQueryObservation( + freshnessContext, + recipeQuery.Name, + outputSelection.OriginalCount)); + } + } + catch (Exception ex) when ( + freshnessContext != null + && TryClassifySearchQueryExecutionFailure(ex, out _)) + { + TryClassifySearchQueryExecutionFailure(ex, out var failureReason); + hasFailures = true; + freshnessObservations.Add(FailedSearchQueryObservation( + freshnessContext, + recipeQuery.Name, + failureReason)); + } } return queryResults; @@ -1755,100 +1857,121 @@ private static List CollectSearchRecip SearchRecipeScopeJsonResult scope, QueryCommandOptions options, bool userExact, - out int total) + SearchQueryFreshnessContext freshnessContext, + out int total, + out List freshnessObservations, + out bool hasFailures) { var queryResults = new List(); + freshnessObservations = []; total = 0; + hasFailures = false; foreach (var recipeQuery in recipeQueries) { - var exact = userExact || recipeQuery.ExactSubstring; - var queryScope = BuildSearchRecipeQueryScope(scope, recipeQuery); - var resultLimit = GetSearchRecipeEffectiveResultLimit(options, total); - var guardFilters = BuildSearchRecipeGuardFilters(options, recipeQuery); - var fetchLimit = GetSearchRecipeFetchLimit(options, resultLimit); - var results = reader.Search( - recipeQuery.Query, - fetchLimit, - options.Lang, - false, - queryScope.PathPatterns, - queryScope.ExcludePaths, - queryScope.ExcludeTests, - !options.NoDedup, - options.Since, - exact, - false, - !options.NoVisibilityRank, - cursor: options.SearchCursor, - guardFilters: guardFilters, - guardWindow: options.GuardWindow, - guardScope: options.GuardScope, - requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery), - resultRanking: GetSearchRecipeResultRanking(recipeQuery.ResultRanking, resultLimit)); - var sourceTotalAuthoritative = IsSearchRecipeSourceTotalAuthoritative( - options, - recipeQuery, - guardFilters, - results.Count, - fetchLimit); - results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); - var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, recipeQuery: recipeQuery); - var outputSelection = ApplySearchOutputSelection(rows, options, resultLimit, sourceTotalAuthoritative); - rows = outputSelection.Rows; - ApplySearchRecipeAuditClassifications(recipeQuery, rows); - var minimumOmitted = Math.Max(0, outputSelection.OriginalCount - rows.Count); - var selectionReason = GetSearchRecipeSelectionReason(outputSelection); - total += rows.Count; - queryResults.Add(new SearchRecipeCompactQueryResultJsonResult( - recipeQuery.Name, - recipeQuery.Query, - recipeQuery.Description, - recipeQuery.Severity, - [.. recipeQuery.RiskEvidence], - ToSearchRecipeGuardFilterJsonResults(recipeQuery.GuardFilters), - [.. recipeQuery.PathPatterns], - [.. recipeQuery.ExcludePaths], - [.. recipeQuery.MatchOrigins], - [.. recipeQuery.ExcludeOrigins], - [.. recipeQuery.ResultKinds], - [.. recipeQuery.Classifiers], - recipeQuery.StringComparisonTaxonomy, - recipeQuery.BroadCatchTaxonomy, - BuildSearchRecipeClassifierCounts(rows), - rows.Count, - rows.Count, - outputSelection.OriginalCount, - minimumOmitted, - selectionReason, - selectionReason != null ? outputSelection.SelectionOmittedCount : null, - resultLimit, - minimumOmitted, - BuildSearchRecipeTopFiles(rows), - outputSelection.LimitTruncated, - outputSelection.LimitTruncated - && !options.FirstPerFile - && !options.SampleSize.HasValue - && rows.Count > 0 - ? FormatSearchCursor(rows[^1].Result) - : null, - rows.Select(row => new SearchRecipeCompactResultJsonResult( - row.Result.Path, - row.Result.Lang, - row.Result.Visibility, + try + { + var exact = userExact || recipeQuery.ExactSubstring; + var queryScope = BuildSearchRecipeQueryScope(scope, recipeQuery); + var resultLimit = GetSearchRecipeEffectiveResultLimit(options, total); + var guardFilters = BuildSearchRecipeGuardFilters(options, recipeQuery); + var fetchLimit = GetSearchRecipeFetchLimit(options, resultLimit); + var results = reader.Search( + recipeQuery.Query, + fetchLimit, + options.Lang, + false, + queryScope.PathPatterns, + queryScope.ExcludePaths, + queryScope.ExcludeTests, + !options.NoDedup, + options.Since, + exact, + false, + !options.NoVisibilityRank, + cursor: options.SearchCursor, + guardFilters: guardFilters, + guardWindow: options.GuardWindow, + guardScope: options.GuardScope, + requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery), + resultRanking: GetSearchRecipeResultRanking(recipeQuery.ResultRanking, resultLimit)); + var sourceTotalAuthoritative = IsSearchRecipeSourceTotalAuthoritative( + options, + recipeQuery, + guardFilters, + results.Count, + fetchLimit); + results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); + var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, recipeQuery: recipeQuery); + var outputSelection = ApplySearchOutputSelection(rows, options, resultLimit, sourceTotalAuthoritative); + rows = outputSelection.Rows; + ApplySearchRecipeAuditClassifications(recipeQuery, rows); + var minimumOmitted = Math.Max(0, outputSelection.OriginalCount - rows.Count); + var selectionReason = GetSearchRecipeSelectionReason(outputSelection); + total += rows.Count; + queryResults.Add(new SearchRecipeCompactQueryResultJsonResult( + recipeQuery.Name, + recipeQuery.Query, + recipeQuery.Description, + recipeQuery.Severity, [.. recipeQuery.RiskEvidence], - row.Result.StartLine, - row.Result.EndLine, - row.Compact.MatchLines, - row.Compact.EnclosingSymbolName, - row.Compact.EnclosingSymbolKind)).ToList(), - outputSelection.SourceTotal, - outputSelection.SourceTotalAuthoritative, - outputSelection.SourceTotalAuthoritative ? null : outputSelection.SourceTotal, - outputSelection.SelectedTotal, - outputSelection.Returned, - outputSelection.SelectorOmittedCount, - outputSelection.LimitOmittedCount, - outputSelection.Selectors)); + ToSearchRecipeGuardFilterJsonResults(recipeQuery.GuardFilters), + [.. recipeQuery.PathPatterns], + [.. recipeQuery.ExcludePaths], + [.. recipeQuery.MatchOrigins], + [.. recipeQuery.ExcludeOrigins], + [.. recipeQuery.ResultKinds], + [.. recipeQuery.Classifiers], + recipeQuery.StringComparisonTaxonomy, + recipeQuery.BroadCatchTaxonomy, + BuildSearchRecipeClassifierCounts(rows), + rows.Count, + rows.Count, + outputSelection.OriginalCount, + minimumOmitted, + selectionReason, + selectionReason != null ? outputSelection.SelectionOmittedCount : null, + resultLimit, + minimumOmitted, + BuildSearchRecipeTopFiles(rows), + outputSelection.LimitTruncated, + outputSelection.LimitTruncated + && !options.FirstPerFile + && !options.SampleSize.HasValue + && rows.Count > 0 + ? FormatSearchCursor(rows[^1].Result) + : null, + rows.Select(row => new SearchRecipeCompactResultJsonResult( + row.Result.Path, + row.Result.Lang, + row.Result.Visibility, + [.. recipeQuery.RiskEvidence], + row.Result.StartLine, + row.Result.EndLine, + row.Compact.MatchLines, + row.Compact.EnclosingSymbolName, + row.Compact.EnclosingSymbolKind)).ToList(), + outputSelection.SourceTotal, + outputSelection.SourceTotalAuthoritative, + outputSelection.SourceTotalAuthoritative ? null : outputSelection.SourceTotal, + outputSelection.SelectedTotal, + outputSelection.Returned, + outputSelection.SelectorOmittedCount, + outputSelection.LimitOmittedCount, + outputSelection.Selectors)); + freshnessObservations.Add(SuccessfulSearchQueryObservation( + freshnessContext, + recipeQuery.Name, + outputSelection.OriginalCount)); + } + catch (Exception ex) when (TryClassifySearchQueryExecutionFailure(ex, out _)) + { + TryClassifySearchQueryExecutionFailure(ex, out var failureReason); + hasFailures = true; + freshnessObservations.Add(FailedSearchQueryObservation( + freshnessContext, + recipeQuery.Name, + failureReason)); + } } return queryResults; @@ -1885,57 +2008,83 @@ private static List CountSearchRecipeQueryResu SearchRecipeScopeJsonResult scope, QueryCommandOptions options, bool userExact, + SearchQueryFreshnessContext? freshnessContext, out int total, - out int fileCount) + out int fileCount, + out List freshnessObservations, + out bool hasFailures) { var queryCounts = new List(); + freshnessObservations = []; var paths = new HashSet(StringComparer.Ordinal); total = 0; + hasFailures = false; foreach (var recipeQuery in recipeQueries) { - var exact = userExact || recipeQuery.ExactSubstring; - var queryScope = BuildSearchRecipeQueryScope(scope, recipeQuery); - var guardFilters = BuildSearchRecipeGuardFilters(options, recipeQuery); - var results = reader.Search( - recipeQuery.Query, - int.MaxValue, - options.Lang, - false, - queryScope.PathPatterns, - queryScope.ExcludePaths, - queryScope.ExcludeTests, - !options.NoDedup, - options.Since, - exact, - false, - !options.NoVisibilityRank, - cursor: options.SearchCursor, - guardFilters: guardFilters, - guardWindow: options.GuardWindow, - guardScope: options.GuardScope, - requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery)); - results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); - var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false, recipeQuery: recipeQuery); - ApplySearchRecipeAuditClassifications(recipeQuery, rows); - var count = rows.Count; - var fileCountForQuery = rows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count(); - foreach (var path in rows.Select(row => row.Result.Path)) - paths.Add(path); + try + { + var exact = userExact || recipeQuery.ExactSubstring; + var queryScope = BuildSearchRecipeQueryScope(scope, recipeQuery); + var guardFilters = BuildSearchRecipeGuardFilters(options, recipeQuery); + var results = reader.Search( + recipeQuery.Query, + int.MaxValue, + options.Lang, + false, + queryScope.PathPatterns, + queryScope.ExcludePaths, + queryScope.ExcludeTests, + !options.NoDedup, + options.Since, + exact, + false, + !options.NoVisibilityRank, + cursor: options.SearchCursor, + guardFilters: guardFilters, + guardWindow: options.GuardWindow, + guardScope: options.GuardScope, + requiredPathPatterns: GetSearchRecipeRequiredPathPatterns(options, recipeQuery)); + results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); + var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false, recipeQuery: recipeQuery); + ApplySearchRecipeAuditClassifications(recipeQuery, rows); + var count = rows.Count; + var fileCountForQuery = rows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count(); + foreach (var path in rows.Select(row => row.Result.Path)) + paths.Add(path); - total += count; - queryCounts.Add(new SearchRecipeCountQueryJsonResult( - recipeQuery.Name, - recipeQuery.Query, - recipeQuery.Description, - recipeQuery.Severity, - count, - count, - 0, - count, - fileCountForQuery, - false, - BuildSearchRecipeClassifierCounts(rows), - BuildSearchRecipeTopFiles(rows))); + total += count; + queryCounts.Add(new SearchRecipeCountQueryJsonResult( + recipeQuery.Name, + recipeQuery.Query, + recipeQuery.Description, + recipeQuery.Severity, + count, + count, + 0, + count, + fileCountForQuery, + false, + BuildSearchRecipeClassifierCounts(rows), + BuildSearchRecipeTopFiles(rows))); + if (freshnessContext != null) + { + freshnessObservations.Add(SuccessfulSearchQueryObservation( + freshnessContext, + recipeQuery.Name, + count)); + } + } + catch (Exception ex) when ( + freshnessContext != null + && TryClassifySearchQueryExecutionFailure(ex, out _)) + { + TryClassifySearchQueryExecutionFailure(ex, out var failureReason); + hasFailures = true; + freshnessObservations.Add(FailedSearchQueryObservation( + freshnessContext, + recipeQuery.Name, + failureReason)); + } } fileCount = paths.Count; @@ -2242,14 +2391,15 @@ private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( int? totalLimit, int emittedResultCount, QueryCommandInvocationContext invocationContext, - SearchQueryFreshnessContext freshnessContext) + SearchQueryFreshnessContext freshnessContext, + IReadOnlyList freshnessObservations) => new( limitPerQuery, totalLimit, emittedResultCount, queryResults.Count(query => query.Truncated), queryResults.Sum(query => query.MinimumOmittedResultCount), - BuildSearchRecipeQueryFreshness(queryResults, freshnessContext), + BuildSearchRecipeQueryFreshness(freshnessContext, freshnessObservations), queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor)), BuildSearchRecipeCursoringHint( queryResults.Any(query => query.Truncated), @@ -2271,14 +2421,15 @@ private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( int? totalLimit, int emittedResultCount, QueryCommandInvocationContext invocationContext, - SearchQueryFreshnessContext freshnessContext) + SearchQueryFreshnessContext freshnessContext, + IReadOnlyList freshnessObservations) => new( limitPerQuery, totalLimit, emittedResultCount, queryResults.Count(query => query.Truncated), queryResults.Sum(query => query.MinimumOmittedResultCount), - BuildSearchRecipeQueryFreshness(queryResults, freshnessContext), + BuildSearchRecipeQueryFreshness(freshnessContext, freshnessObservations), queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor)), BuildSearchRecipeCursoringHint( queryResults.Any(query => query.Truncated), @@ -2307,6 +2458,16 @@ private static void WriteSearchRecipeFreshnessText(SearchRecipeQueryFreshnessJso Console.WriteLine($"Invalid queries: {string.Join(", ", freshness.InvalidQueryNames)}"); } + private static int CompleteSearchRecipeOutput(int writeExitCode, bool hasFailures) + { + if (writeExitCode != CommandExitCodes.Success || !hasFailures) + return writeExitCode; + + CommandErrorWriter.WriteStderr( + $"Error [{CommandErrorCodes.UsageError}]: one or more recipe queries failed; inspect query_freshness.invalid_query_names."); + return CommandExitCodes.UsageError; + } + private static string BuildSearchRecipeCursoringHint( bool hasTruncatedQuery, bool cursoringAvailable, @@ -2523,17 +2684,30 @@ private static SearchQueryFreshnessObservation SuccessfulSearchQueryObservation( return new(name, count, definitionVersion, true, null); } + private static SearchQueryFreshnessObservation FailedSearchQueryObservation( + SearchQueryFreshnessContext context, + string name, + string failureReason) + { + var definitionVersion = context.ExpectedQueries + .FirstOrDefault(query => string.Equals(query.Name, name, StringComparison.Ordinal)) + ?.DefinitionVersion + ?? SearchQueryFreshnessUnknownDefinitionVersion; + return new(name, null, definitionVersion, false, failureReason); + } + private static SearchQueryFreshnessContext BuildSearchRecipeFreshnessContext( DbReader reader, SearchAuditRecipe recipe, - IReadOnlyList selectedQueries) + IReadOnlyList selectedQueries, + QueryCommandOptions options) { var recipeVersion = BuildSearchDefinitionVersion( "audit-recipe-v1", recipe, CliJsonSerializerContext.Default.SearchAuditRecipe); return new( - ResolveSearchQueryIndexFreshness(reader, out var indexReason), + ResolveSearchQueryIndexFreshness(reader, options, out var indexReason), indexReason, recipeVersion, recipeVersion, @@ -2553,7 +2727,7 @@ private static SearchQueryFreshnessContext BuildNamedSearchFreshnessContext( QueryCommandOptions options, bool userExact) => new( - ResolveSearchQueryIndexFreshness(reader, out var indexReason), + ResolveSearchQueryIndexFreshness(reader, options, out var indexReason), indexReason, null, null, @@ -2570,7 +2744,10 @@ private static SearchQueryFreshnessContext BuildNamedSearchFreshnessContext( options.TokenBoundary.ToString(CultureInfo.InvariantCulture)))) .ToList()); - private static string ResolveSearchQueryIndexFreshness(DbReader reader, out string? reason) + private static string ResolveSearchQueryIndexFreshness( + DbReader reader, + QueryCommandOptions options, + out string? reason) { var health = reader.GetWorkspaceIndexHealth(); if (health.IndexNewerThanReader) @@ -2600,6 +2777,27 @@ private static string ResolveSearchQueryIndexFreshness(DbReader reader, out stri return "stale"; } + if (!string.IsNullOrWhiteSpace(projectRoot) + && !string.IsNullOrWhiteSpace(workspaceHead)) + { + var workspaceCheck = IndexFreshnessChecker.Check( + reader, + projectRoot, + internalIndexDatabasePath: DbPathResolver.NormalizeDbPath(options.DbPath)); + if (!workspaceCheck.Checked) + { + reason = "index_workspace_unverified"; + return "stale"; + } + if (!workspaceCheck.MatchesWorkspace) + { + reason = workspaceCheck.Reason == "head_changed" + ? "index_head_changed" + : "index_workspace_changed"; + return "stale"; + } + } + reason = null; return "current"; } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 45d170cd0..2a252e8b1 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -105,6 +105,156 @@ public void BuildSearchRecipeQueryFreshness_SeparatesResultsFromInvalidation_Iss failedChild.CleanQueryCount + failedChild.StaleQueryCount + failedChild.InvalidQueryCount); } + [Fact] + public void RunSearch_NamedQueryFreshnessReportsDirtyWorkspaceIndexAsStale_Issue4907() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_freshness_dirty_workspace_4907"); + try + { + const string indexedContent = "public sealed class App { private const string Value = \"FreshnessNeedle\"; }\n"; + TestProjectHelper.InitializeGitRepo(projectRoot); + TestProjectHelper.WriteTextFile(projectRoot, Path.Combine("src", "App.cs"), indexedContent); + TestProjectHelper.RunGit(projectRoot, "add", "src/App.cs"); + TestProjectHelper.RunGit(projectRoot, "commit", "-m", "initial"); + + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/App.cs", "csharp", indexedContent); + TestProjectHelper.WriteTextFile( + projectRoot, + Path.Combine("src", "App.cs"), + "public sealed class App { }\n"); + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [ + "--named-query=needle=FreshnessNeedle", + "--db", dbPath, + "--format", "count", + "--json", + ], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var freshness = document.RootElement.GetProperty("query_freshness"); + Assert.Equal("stale", freshness.GetProperty("state").GetString()); + Assert.Equal("stale", freshness.GetProperty("index_state").GetString()); + Assert.Equal("index_workspace_changed", freshness.GetProperty("index_reason").GetString()); + Assert.Contains( + freshness.GetProperty("stale_query_names").EnumerateArray(), + name => name.GetString() == "needle"); + Assert.Equal( + "index_workspace_changed", + Assert.Single(freshness.GetProperty("queries").EnumerateArray()) + .GetProperty("reason") + .GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_RecipeJsonRetainsClassifiedChildFailureAsInvalid_Issue4907() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_freshness_failed_recipe_child_4907"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + const int minimumGuardedCandidates = 200; + for (var i = 0; i <= minimumGuardedCandidates; i++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"src/C{i:D4}.cs", + "csharp", + $"public sealed class C{i:D4} {{ private string token = \"value\"; }}\n"); + } + + using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); + env.Set(SearchAuditRecipes.RecipePathsEnvironmentVariable, null); + string[] BuildArgs(params string[] outputArgs) => + [ + "--recipe", "broad-token-audit", + "--include-query", "token-term-broad", + "--include-query", "auth-token", + "--db", dbPath, + "--limit", "1", + "--total-limit", "0", + "--require-before", "CDIDX_REVIEW_NO_SUCH_GUARD", + "--guard-window", "1", + .. outputArgs, + ]; + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + BuildArgs("--json"), + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Contains("one or more recipe queries failed", stderr, StringComparison.Ordinal); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + var freshness = root.GetProperty("summary").GetProperty("query_freshness"); + Assert.Equal(2, root.GetProperty("query_count").GetInt32()); + Assert.Equal("mixed", freshness.GetProperty("state").GetString()); + Assert.Contains( + freshness.GetProperty("invalid_query_names").EnumerateArray(), + name => name.GetString() == "token-term-broad"); + var failedQuery = Assert.Single( + freshness.GetProperty("queries").EnumerateArray(), + query => query.GetProperty("name").GetString() == "token-term-broad"); + Assert.Equal("invalid", failedQuery.GetProperty("freshness_state").GetString()); + Assert.Equal("query_guard_limit_exceeded", failedQuery.GetProperty("reason").GetString()); + var successfulQuery = Assert.Single( + freshness.GetProperty("queries").EnumerateArray(), + query => query.GetProperty("name").GetString() == "auth-token"); + Assert.Equal("clean", successfulQuery.GetProperty("freshness_state").GetString()); + Assert.Equal("zero_match", successfulQuery.GetProperty("result_state").GetString()); + + var (sarifExitCode, sarifStdout, sarifStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + BuildArgs("--format", "sarif"), + _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, sarifExitCode); + Assert.Contains("one or more recipe queries failed", sarifStderr, StringComparison.Ordinal); + using (var sarifDocument = ParseJsonOutput(sarifStdout)) + { + var sarifFreshness = sarifDocument.RootElement + .GetProperty("runs")[0] + .GetProperty("properties") + .GetProperty("query_freshness"); + Assert.Contains( + sarifFreshness.GetProperty("invalid_query_names").EnumerateArray(), + name => name.GetString() == "token-term-broad"); + } + + var (draftExitCode, draftStdout, draftStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + BuildArgs("--format", "issue-drafts"), + _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, draftExitCode); + Assert.Contains("one or more recipe queries failed", draftStderr, StringComparison.Ordinal); + using (var draftDocument = ParseJsonOutput(draftStdout)) + { + Assert.Contains( + draftDocument.RootElement + .GetProperty("query_freshness") + .GetProperty("invalid_query_names") + .EnumerateArray(), + name => name.GetString() == "token-term-broad"); + } + + var (textExitCode, textStdout, textStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + BuildArgs(), + _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, textExitCode); + Assert.Contains("Invalid queries: token-term-broad", textStdout, StringComparison.Ordinal); + Assert.Contains("one or more recipe queries failed", textStderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void SearchMatchClassifier_McpSchemaDescriptionHasDedicatedOrigin_Issues4416_4864() { From 7206dfab56ed7b57afdbe85dec5c32c8774f6ed7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 1 Aug 2026 03:08:28 +0900 Subject: [PATCH 3/4] Complete query freshness review fixes (#4907) --- .../Cli/QueryCommandRunner.SearchRecipes.cs | 23 +++--- .../Cli/QueryCommandRunner.SearchResults.cs | 14 ++-- .../QueryCommandRunnerSearchTests.cs | 71 ++++++++++++++++--- 3 files changed, 86 insertions(+), 22 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 41a7f62d8..e088a251c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -12,6 +12,8 @@ namespace CodeIndex.Cli; public static partial class QueryCommandRunner { + internal static Action? SearchQueryFreshnessWorkspaceCheckForTesting; + private static int WriteSearchRecipeList( QueryCommandOptions options, JsonSerializerOptions jsonOptions, @@ -3069,9 +3071,9 @@ private static string ResolveSearchQueryIndexFreshness( return "stale"; } - if (!string.IsNullOrWhiteSpace(projectRoot) - && !string.IsNullOrWhiteSpace(workspaceHead)) + if (!string.IsNullOrWhiteSpace(projectRoot)) { + SearchQueryFreshnessWorkspaceCheckForTesting?.Invoke(); var workspaceCheck = IndexFreshnessChecker.Check( reader, projectRoot, @@ -3327,7 +3329,7 @@ private static List CountSearchName DbReader reader, QueryCommandOptions options, bool userExact, - SearchQueryFreshnessContext freshnessContext, + SearchQueryFreshnessContext? freshnessContext, out int total, out int fileCount, out List freshnessObservations, @@ -3370,12 +3372,17 @@ private static List CountSearchName namedQuery.Query, count, fileCountForQuery)); - freshnessObservations.Add(SuccessfulSearchQueryObservation( - freshnessContext, - namedQuery.Name, - count)); + if (freshnessContext != null) + { + freshnessObservations.Add(SuccessfulSearchQueryObservation( + freshnessContext, + namedQuery.Name, + count)); + } } - catch (Exception ex) when (TryClassifySearchQueryExecutionFailure(ex, out _)) + catch (Exception ex) when ( + freshnessContext != null + && TryClassifySearchQueryExecutionFailure(ex, out _)) { TryClassifySearchQueryExecutionFailure(ex, out var failureReason); hasFailures = true; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index 786fe0ac2..3c417cd56 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -935,11 +935,13 @@ private static int RunSearchNamedBatchCount(QueryCommandOptions options, JsonSer { return WithDb(options, jsonOptions, reader => { - var freshnessContext = BuildNamedSearchFreshnessContext( - reader, - options.NamedSearchQueries, - options, - userExact); + var freshnessContext = options.Json + ? BuildNamedSearchFreshnessContext( + reader, + options.NamedSearchQueries, + options, + userExact) + : null; var queryCounts = CountSearchNamedBatchQueryResults( reader, options, @@ -953,7 +955,7 @@ private static int RunSearchNamedBatchCount(QueryCommandOptions options, JsonSer if (options.Json) { var freshness = BuildSearchRecipeQueryFreshness( - freshnessContext, + freshnessContext!, freshnessObservations); var json = JsonSerializer.Serialize( new SearchNamedBatchCountSummaryRunJsonResult( diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 2a252e8b1..4b23c04cc 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -105,17 +105,22 @@ public void BuildSearchRecipeQueryFreshness_SeparatesResultsFromInvalidation_Iss failedChild.CleanQueryCount + failedChild.StaleQueryCount + failedChild.InvalidQueryCount); } - [Fact] - public void RunSearch_NamedQueryFreshnessReportsDirtyWorkspaceIndexAsStale_Issue4907() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RunSearch_NamedQueryFreshnessReportsDirtyWorkspaceIndexAsStale_Issue4907(bool initializeGit) { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_freshness_dirty_workspace_4907"); try { const string indexedContent = "public sealed class App { private const string Value = \"FreshnessNeedle\"; }\n"; - TestProjectHelper.InitializeGitRepo(projectRoot); TestProjectHelper.WriteTextFile(projectRoot, Path.Combine("src", "App.cs"), indexedContent); - TestProjectHelper.RunGit(projectRoot, "add", "src/App.cs"); - TestProjectHelper.RunGit(projectRoot, "commit", "-m", "initial"); + if (initializeGit) + { + TestProjectHelper.InitializeGitRepo(projectRoot); + TestProjectHelper.RunGit(projectRoot, "add", "src/App.cs"); + TestProjectHelper.RunGit(projectRoot, "commit", "-m", "initial"); + } var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); TestProjectHelper.InsertIndexedFile(dbPath, "src/App.cs", "csharp", indexedContent); @@ -128,7 +133,7 @@ public void RunSearch_NamedQueryFreshnessReportsDirtyWorkspaceIndexAsStale_Issue [ "--named-query=needle=FreshnessNeedle", "--db", dbPath, - "--format", "count", + "--count", "--json", ], _jsonOptions)); @@ -155,6 +160,53 @@ public void RunSearch_NamedQueryFreshnessReportsDirtyWorkspaceIndexAsStale_Issue } } + [Fact] + public void RunSearch_NamedTextCountSkipsWorkspaceFreshnessScan_Issue4907() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_query_freshness_text_count_4907"); + try + { + const string content = "public sealed class App { private const string Value = \"FreshnessNeedle\"; }\n"; + TestProjectHelper.WriteTextFile(projectRoot, Path.Combine("src", "App.cs"), content); + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile(dbPath, "src/App.cs", "csharp", content); + var workspaceCheckCount = 0; + QueryCommandRunner.SearchQueryFreshnessWorkspaceCheckForTesting = + () => workspaceCheckCount++; + + var (textExitCode, textStdout, textStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [ + "--named-query=needle=FreshnessNeedle", + "--db", dbPath, + "--count", + ], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, textExitCode); + Assert.Equal("1", textStdout.Trim()); + Assert.Contains("1 named-query results", textStderr, StringComparison.Ordinal); + Assert.Equal(0, workspaceCheckCount); + + var (jsonExitCode, _, jsonStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [ + "--named-query=needle=FreshnessNeedle", + "--db", dbPath, + "--format", "count", + "--json", + ], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, jsonExitCode); + Assert.Equal(string.Empty, jsonStderr); + Assert.Equal(1, workspaceCheckCount); + } + finally + { + QueryCommandRunner.SearchQueryFreshnessWorkspaceCheckForTesting = null; + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_RecipeJsonRetainsClassifiedChildFailureAsInvalid_Issue4907() { @@ -165,11 +217,14 @@ public void RunSearch_RecipeJsonRetainsClassifiedChildFailureAsInvalid_Issue4907 const int minimumGuardedCandidates = 200; for (var i = 0; i <= minimumGuardedCandidates; i++) { + var path = $"src/C{i:D4}.cs"; + var content = $"public sealed class C{i:D4} {{ private string token = \"value\"; }}\n"; + TestProjectHelper.WriteTextFile(projectRoot, path, content); TestProjectHelper.InsertIndexedFile( dbPath, - $"src/C{i:D4}.cs", + path, "csharp", - $"public sealed class C{i:D4} {{ private string token = \"value\"; }}\n"); + content); } using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); From 6611a96633a8c8734f86e0ef4184f02afad43b65 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 1 Aug 2026 13:13:29 +0900 Subject: [PATCH 4/4] Fix freshness CI fixtures for workspace validation (#4907) --- .../QueryCommandRunnerSearchTests.cs | 27 ++++++++++++------- tests/CodeIndex.Tests/TestProjectHelper.cs | 11 ++++++++ 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 186dce71e..755e3426b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1364,17 +1364,20 @@ public void RunSearch_NamedQueriesCountSummaryJsonCountsAllMatches_Issue4308() try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile( + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, dbPath, "release/pack.md", "markdown", "Run dotnet pack before publishing."); - TestProjectHelper.InsertIndexedFile( + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, dbPath, "release/pack-extra.md", "markdown", "Run dotnet pack after signing."); - TestProjectHelper.InsertIndexedFile( + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, dbPath, "release/push.md", "markdown", @@ -5992,12 +5995,14 @@ public void RunSearch_RecipeSarifPreservesBoundedAuditMetadataAndStableIdentifie try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile( + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, dbPath, "src/a.cs", "csharp", "public sealed class A { void Run(Exception ex) { JsonDocument.Parse(\"{}\"); Console.WriteLine(ex.Message); } }"); - TestProjectHelper.InsertIndexedFile( + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, dbPath, "src/b.cs", "csharp", @@ -6196,7 +6201,8 @@ public void RunSearch_RecipeCountSummaryOnlyJsonOmitsRecipeMetadataAndReportsFre try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile( + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, dbPath, "src/a.cs", "csharp", @@ -6209,7 +6215,8 @@ public void Run(Exception ex) } } """); - TestProjectHelper.InsertIndexedFile( + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, dbPath, "src/b.cs", "csharp", @@ -10307,7 +10314,8 @@ public void RunSearch_RecipeIssueDraftsSummaryOnlyEmitsCompactMetadataAndFreshne try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile( + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, dbPath, "src/a.cs", "csharp", @@ -10374,7 +10382,8 @@ public void RunSearch_RecipeIssueDraftsSummaryOnlyFreshnessUsesMatchedCountsWhen try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); - TestProjectHelper.InsertIndexedFile( + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, dbPath, "src/a.cs", "csharp", diff --git a/tests/CodeIndex.Tests/TestProjectHelper.cs b/tests/CodeIndex.Tests/TestProjectHelper.cs index 2f3bbeb2b..396bdb81d 100644 --- a/tests/CodeIndex.Tests/TestProjectHelper.cs +++ b/tests/CodeIndex.Tests/TestProjectHelper.cs @@ -303,6 +303,17 @@ internal static void InsertIndexedFile( } } + internal static void InsertFreshIndexedFile( + string projectRoot, + string dbPath, + string path, + string lang, + string content) + { + WriteTextFile(projectRoot, path, content); + InsertIndexedFile(dbPath, path, lang, content); + } + internal static void DeleteSqliteDatabaseFiles(string dbPath) { DeleteFile(dbPath);