diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2ea044278..8a930f341 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1559,6 +1559,7 @@ access. | `--count --json` envelope | Count-only JSON for `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `impact`, and `unused` is a single automation-oriented object. It always includes `count`, applied `query_context`, freshness metadata (`indexed_file_count`, `indexed_at`, `freshness_available`), and trust flags `degraded` / `authoritative_count`; commands with matched-file totals also include `files` and the older `file_count` compatibility alias. `file_count` carries the same value as `files`, remains for compatibility, and is not scheduled for removal before the next major release. `unused --count --json` also includes `returned_bucket_counts`, `returned_contract_domain_counts`, and `summary.by_bucket` / `summary.by_confidence` / `summary.by_contract_domain`. `authoritative_count=false` means a readiness or graph/exact trust signal made the count non-authoritative, while the freshness fields describe the indexed snapshot used for the count. | | Search row selection | Row-producing plain-search and recipe paths share `ApplySearchOutputSelection`: `--first-per-file` and fixed-seed deterministic `--sample` run before the effective per-query / remaining total limit. Sample fetch envelopes are sized from at least the requested sample target. Aggregate/compact query DTOs, plain compact roots, run summaries, issue-draft source DTOs, NDJSON terminals, and bounded array-envelope stream terminals expose `source_total`, `selected_total`, `returned`, `selector_omitted_count`, and `limit_omitted_count`; `source_total_authoritative` / `source_total_lower_bound` distinguish complete populations from bounded observations. Guard filters, origin/facet post-filters, exhausted candidate windows, and recipe file-reject post-filters force lower-bound authority. Their ordered `selectors` entries preserve each stage's input/output/omission counts plus sample size, mode, and seed, while nullable `selection_reason` / `selection_omitted_count` remain compatibility summaries. Bounded plain-search selection is computed once and its selected page is reused by compact/envelope serialization. Search `query_context.row_selectors` records the applied selector configuration. Selection-only omission updates matched/omitted lower bounds without setting `truncated`, `has_more`, or `next_cursor`. When a later limit truncates selected rows, limit truncation remains visible but `next_cursor` is suppressed because raw database cursors cannot preserve selector state; incoming `--cursor` values are rejected with either selector for the same reason. Generated compact and issue-draft replay commands retain the selector. Count, aggregation, named-query, recipe-list, results-only, metadata-free array, unsupported formatted, and summary-only compact shapes reject `--first-per-file` / `--sample`, while every recipe shape rejects grouped-only `--per-file-limit`. | | Search selection edge cases | Issue-draft roots independently retain per-query `selection_accounting`, including zero-draft and exhausted-total-limit queries. Byte-bounded compact and array envelopes rewrite `returned` to the emitted row count while preserving logical `limit_omitted_count`; hard-cap omissions remain separate in `metadata.byte_limit_omitted_count`. | +| Ad-hoc search SARIF | `search --format sarif` stores completion metadata on each SARIF run. The run and its single `queries[]` summary report `source_result_count`, `source_result_count_authoritative`, emitted `result_count`, the applied `limit_per_query` / `result_limit`, conservative `minimum_omitted_result_count`, and `truncated` state. Source and emitted counts use the final SARIF result/location unit, including exact-search occurrence expansion. Guarded searches retain their bounded candidate budget instead of failing during a completion recount; their source count is an explicitly non-authoritative lower bound and their truncation state remains conservative. Facet-filtered exact searches use an exhaustive source count rather than the display candidate window. Ad-hoc search does not expose a continuation cursor, so `cursoring_available` is `false` and `next_cursor` is null; a shell-quoted `replay_command` preserves option-like queries and active search controls. The completion vocabulary intentionally matches recipe SARIF, and empty runs carry the same fields with zero counts. | | Ad-hoc issue-draft selection | `search --format issue-drafts` reads the complete filtered ad-hoc population, then applies `--first-per-file`, deterministic `--sample`, and `min(--limit, --total-limit)` in that order. Guarded searches retain their finite candidate inspection contract: `source_total_count` is omitted, `source_minimum_count` reports the observed lower bound, `source_total_count_authoritative=false`, `source_fetch_limit` reports the bounded fetch, and `truncated=true` preserves incomplete-population state. Existing `result_count`, `result_limit`, `omitted_count`, and `truncated` fields describe the returned selection accurately; additive `source_total_count`, `returned_count`, `limit_per_query`, `total_limit`, `first_per_file`, and `sample` fields make the applied contract auditable. Replay commands are serialized from normalized parsed options, use POSIX-safe single-quote escaping, and retain raw/exact/prefix modes, path/language/facet/guard filters, selection controls, evidence formatting, duplicate preflight, and issue hints. | | Recipe SARIF | `search --recipe --format sarif` emits one result per bounded recipe result. Rule IDs use `recipe/query`; standard `fingerprints.cdidx/v1` values are derived from the normalized source location; result properties preserve recipe/query identity, severity, confidence, and per-query truncation; run properties preserve scope, applied result limits, aggregate counts, and conservative omitted-result metadata. Bound SARIF with `--limit` / `--total-limit`; row selectors such as `--sample`, `--first-per-file`, and `--per-file-limit` are rejected instead of being silently ignored. Recipe severity maps `critical` / `high` to `error`, `medium` to `warning`, and `low` / `info` to `note`. | | Recipe classifier output | Recipe run JSON may add `audit_classifications` to individual `CompactSearchResult` rows when a recipe classifier can classify the hit, and query/count payloads may add `classifier_counts` when classified rows are present. These fields are additive; use them to separate triage domains such as DTO/result-wrapper `.Result` properties versus Task/ValueTask blocking waits without changing the raw search query. | @@ -4776,6 +4777,7 @@ help はすべてこのレジストリを参照します。field 名は大文字 | `--count --json` envelope | `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`find`、`impact`、`unused` の count-only JSON は単一の自動化向け object です。常に `count`、適用済み `query_context`、freshness metadata(`indexed_file_count`、`indexed_at`、`freshness_available`)、trust flag の `degraded` / `authoritative_count` を含みます。matched-file total を持つ command は `files` と古い互換 alias の `file_count` も含みます。`file_count` は `files` と同じ値を持つ互換用 field として残り、少なくとも次の major release までは削除予定はありません。`unused --count --json` は `returned_bucket_counts`、`returned_contract_domain_counts`、`summary.by_bucket` / `summary.by_confidence` / `summary.by_contract_domain` も含みます。`authoritative_count=false` は readiness または graph/exact trust signal により count が authoritative ではないことを示し、freshness field は count に使った index snapshot を説明します。 | | search row selection | row を返す plain search / recipe path は `ApplySearchOutputSelection` を共有し、`--first-per-file` と固定 seed の決定的な `--sample` を、有効な query ごとの limit / 残り total limit より先に適用します。sample 用 fetch envelope は少なくとも要求 sample 数を基準に sizing します。aggregate / compact の query DTO、plain compact root、run summary、issue-draft の source DTO、NDJSON terminal、bounded array envelope の stream terminal は `source_total`、`selected_total`、`returned`、`selector_omitted_count`、`limit_omitted_count` を公開し、`source_total_authoritative` / `source_total_lower_bound` で完全な population と bounded な観測を区別します。guard filter、origin / facet の後段 filter、candidate window の枯渇、recipe の file-reject 後段 filter は lower-bound authority にします。適用順の `selectors` entry は各段階の input / output / omission count と sample の size / mode / seed を保持し、nullable な `selection_reason` / `selection_omitted_count` は互換用 summary として維持します。bounded plain-search selection は一度だけ計算し、その selected page を compact / envelope serialize で再利用します。search の `query_context.row_selectors` は適用済み selector 設定を記録します。selection だけによる省略は matched / omitted の lower bound を更新しますが、`truncated`、`has_more`、`next_cursor` は設定しません。後続の limit が選択済み row を truncate する場合、limit truncation は表示しますが raw database cursor は selector state を保持できないため `next_cursor` を抑止し、同じ理由で selector と受け取った `--cursor` の併用も拒否します。compact / issue-draft の生成 replay command は selector を保持します。count、aggregation、named-query、recipe-list、results-only、metadata を持たない array、非対応 formatted、summary-only compact の shape は `--first-per-file` / `--sample` を拒否し、すべての recipe shape は grouped 専用の `--per-file-limit` を拒否します。 | | search selection の edge case | issue-draft の root は query ごとの `selection_accounting` を独立して保持するため、draft が 0 件の場合や total limit を使い切った query も accounting を失いません。byte 上限付き compact / array envelope は `returned` を実際の出力 row 数へ更新し、論理的な `limit_omitted_count` を保持します。hard cap による省略は `metadata.byte_limit_omitted_count` で別に報告します。 | +| ad-hoc search SARIF | `search --format sarif` は completion metadata を SARIF の各 run に格納します。run と単一の `queries[]` summary は `source_result_count`、`source_result_count_authoritative`、出力済み `result_count`、適用された `limit_per_query` / `result_limit`、保守的な `minimum_omitted_result_count`、`truncated` state を返します。source / emitted count は exact search の occurrence 展開を含む最終的な SARIF result / location 単位を使用します。guard 付き search は completion の再計数で失敗せず bounded candidate budget を維持し、source count を明示的に non-authoritative な lower bound として返して truncation state を保守的に保ちます。facet filter 付き exact search は表示用 candidate window ではなく exhaustive な source count を使います。ad-hoc search は継続 cursor を公開しないため、`cursoring_available` は `false`、`next_cursor` は null となり、shell quote 済みの `replay_command` が option のような query と有効な search control を保持します。completion vocabulary は意図的に recipe SARIF と共通化し、空 run も count が 0 の同じ field を保持します。 | | ad-hoc issue-draft selection | `search --format issue-drafts` は filter 済みの ad-hoc 母集団全体を読み、`--first-per-file`、決定的な `--sample`、`min(--limit, --total-limit)` の順に適用します。guard 付き検索は有限の candidate inspection 契約を維持し、`source_total_count` を省略し、観測下限を `source_minimum_count`、非 authoritative 状態を `source_total_count_authoritative=false`、bounded fetch を `source_fetch_limit` で報告し、母集団が未完了であることを `truncated=true` で保持します。既存の `result_count`、`result_limit`、`omitted_count`、`truncated` field は返却 selection を正確に表し、additive な `source_total_count`、`returned_count`、`limit_per_query`、`total_limit`、`first_per_file`、`sample` field により適用済み契約を監査できます。replay command は正規化済み parse option から serialize し、POSIX-safe な単一引用符 escape を使い、raw / exact / prefix mode、path / language / facet / guard filter、selection control、evidence formatting、duplicate preflight、issue hint を維持します。 | | Recipe SARIF | `search --recipe --format sarif` は、上限付き recipe result ごとに result を1件出力します。rule ID は `recipe/query` を使い、標準の `fingerprints.cdidx/v1` は正規化済み source location から導出します。result properties は recipe/query identity、severity、confidence、query ごとの truncation を保持し、run properties は scope、適用済み result limit、集計 count、保守的な omitted-result metadata を保持します。SARIF の上限には `--limit` / `--total-limit` を使い、`--sample`、`--first-per-file`、`--per-file-limit` のような row selector は黙って無視せず拒否します。recipe severity は `critical` / `high` を `error`、`medium` を `warning`、`low` / `info` を `note` に対応付けます。 | | Recipe classifier output | recipe classifier が hit を分類できる場合、recipe run JSON は個別の `CompactSearchResult` row に `audit_classifications` を追加することがあり、分類済み row がある query / count payload は `classifier_counts` を追加することがあります。これらは additive field です。raw search query を変えずに、DTO / result-wrapper の `.Result` property と Task / ValueTask の blocking wait などの triage domain を分離するために使います。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index aa9f267af..1a9218e46 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -203,6 +203,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Symbols JSON array, LSP, quickfix, and SARIF location formats share one editor-format fixture; definition SARIF severity coverage reuses that fixture and asserts informational `note` output separately from warning-level diagnostic output. 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 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. @@ -1104,6 +1105,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" symbols JSON array、LSP、quickfix、SARIF location format は1つの editor-format fixture を共有し、definition SARIF severity のテストも同じ fixture を再利用して、情報レベルの `note` 出力を warning レベルの診断出力とは分けて検証してください。 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 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/changelog.d/unreleased/4844.fixed.md b/changelog.d/unreleased/4844.fixed.md new file mode 100644 index 000000000..c40eb3974 --- /dev/null +++ b/changelog.d/unreleased/4844.fixed.md @@ -0,0 +1,22 @@ +--- +category: fixed +issues: + - 4844 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.Locations.cs + - src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs + - src/CodeIndex/Cli/QueryCommandRunner.Search.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Ad-hoc search SARIF now discloses incomplete output (#4844, follow-up to #1747)** — every search SARIF run reports source, returned, and omitted result counts, count authority, applied limits, truncation state, cursor availability, and a replay command, including complete, empty, and bounded guarded runs. + +## 日本語 + +- **ad-hoc search の SARIF が不完全な出力を明示するようになりました(#4844、#1747 の follow-up)** — search SARIF の各 run は、complete / empty / bounded guard run を含め、source・返却・省略 result count、count の確定性、適用済み limit、truncation state、cursor availability、replay command を返します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Locations.cs b/src/CodeIndex/Cli/QueryCommandRunner.Locations.cs index 2f9d0ec1d..2f632fe13 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Locations.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Locations.cs @@ -124,7 +124,10 @@ private static void WriteLspLocations(IEnumerable locations, JsonSe jsonOptions); } - private static bool TryWriteEmptyFormattedResult(QueryCommandOptions options, JsonSerializerOptions jsonOptions) + private static bool TryWriteEmptyFormattedResult( + QueryCommandOptions options, + JsonSerializerOptions jsonOptions, + JsonObject? sarifRunProperties = null) { if (options.OutputFormat == OutputFormatCount) { @@ -150,7 +153,7 @@ private static bool TryWriteEmptyFormattedResult(QueryCommandOptions options, Js return true; if (options.OutputFormat == OutputFormatSarif) { - WriteSarif(Array.Empty<(string Path, int Line, int Column, string Message, string RuleId)>(), jsonOptions); + WriteSarif(Array.Empty(), jsonOptions, runProperties: sarifRunProperties); return true; } return false; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs b/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs index 33e6c262f..5ff4d5a6a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs @@ -31,6 +31,54 @@ private static string FormatSymbolLocationLabel(SymbolResult result) return string.IsNullOrWhiteSpace(result.Name) ? kind : $"{kind} {result.Name}"; } + private static JsonObject BuildAdHocSearchSarifRunProperties( + QueryCommandOptions options, + SearchOutputSelection selection, + AdHocSearchSarifSourceResultCount sourceResultCount, + int returnedResultCount) + { + var omittedResultCount = Math.Max(0, sourceResultCount.Count - returnedResultCount); + var truncated = selection.Truncated || omittedResultCount > 0 || !sourceResultCount.Authoritative; + var replayCommand = BuildAdHocSearchReplayCommand(options, OutputFormatSarif); + var querySummary = new JsonObject + { + ["name"] = "ad-hoc", + ["query"] = options.Query, + ["source_result_count"] = sourceResultCount.Count, + ["source_result_count_authoritative"] = sourceResultCount.Authoritative, + ["result_count"] = returnedResultCount, + ["result_limit"] = options.Limit, + ["truncated"] = truncated, + ["minimum_omitted_result_count"] = omittedResultCount, + ["next_cursor"] = null, + ["replay_command"] = replayCommand, + }; + if (selection.TruncationReason is "sample" or "first_per_file") + { + querySummary["selection_reason"] = selection.TruncationReason; + querySummary["selection_omitted_count"] = selection.SelectionOmittedCount; + } + + return new JsonObject + { + ["format"] = "search", + ["query_count"] = 1, + ["source_result_count"] = sourceResultCount.Count, + ["source_result_count_authoritative"] = sourceResultCount.Authoritative, + ["result_count"] = returnedResultCount, + ["limit_per_query"] = options.Limit, + ["queries"] = new JsonArray(querySummary), + ["truncation"] = new JsonObject + { + ["truncated"] = truncated, + ["truncated_query_count"] = truncated ? 1 : 0, + ["minimum_omitted_result_count"] = omittedResultCount, + }, + ["cursoring_available"] = false, + ["replay_command"] = replayCommand, + }; + } + private static void WriteSarif( IEnumerable<(string Path, int Line, int Column, string Message, string RuleId)> items, JsonSerializerOptions jsonOptions, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs index 7e6b02934..060416ecf 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -784,6 +784,7 @@ and not OutputFormatCompact ? CountSearchMatches(reader, options, exactSearch) : default; var displayRows = ReadSearchDisplayRows(reader, options, exactSearch, out var boundedSelection); + var sarifSourceRows = displayRows; var selection = boundedSelection ?? ApplySearchOutputSelection(displayRows, options); displayRows = selection.Rows; if (displayRows.Count == 0) @@ -807,7 +808,14 @@ and not OutputFormatCompact } if (options.Json && TryWriteEmptySearchJsonWithOptionalByteLimit(options, jsonOptions, out var emptyJsonExitCode)) return emptyJsonExitCode == CommandExitCodes.Success ? ZeroResultExitCode(options) : emptyJsonExitCode; - if (options.Json && TryWriteEmptyFormattedResult(options, jsonOptions)) + var emptySarifRunProperties = options.OutputFormat == OutputFormatSarif + ? BuildAdHocSearchSarifRunProperties( + options, + selection, + CountAdHocSearchSarifSourceResults(reader, options, exactSearch, sarifSourceRows), + returnedResultCount: 0) + : null; + if (options.Json && TryWriteEmptyFormattedResult(options, jsonOptions, emptySarifRunProperties)) return ZeroResultExitCode(options); if (options.Json) { @@ -930,7 +938,16 @@ selection.TruncationReason is "sample" or "first_per_file" } if (options.OutputFormat == OutputFormatSarif) { - WriteSarif(displayRows.SelectMany(row => ToSearchSarifItems(row, options.Query, exactSearch)).Take(options.Limit), jsonOptions); + var sarifItems = displayRows + .SelectMany(row => ToSearchSarifItems(row, options.Query, exactSearch)) + .Take(options.Limit) + .ToList(); + var runProperties = BuildAdHocSearchSarifRunProperties( + options, + selection, + CountAdHocSearchSarifSourceResults(reader, options, exactSearch, sarifSourceRows), + sarifItems.Count); + WriteSarif(sarifItems, jsonOptions, runProperties: runProperties); return CommandExitCodes.Success; } if (options.JsonOutputFormat == JsonOutputFormatArray) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 324a1117d..ff8f2a16f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -3268,6 +3268,9 @@ private static string FormatNullableIssueDraftSelectionValue(int? value) => value?.ToString(CultureInfo.InvariantCulture) ?? "null"; private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOptions options) + => BuildAdHocSearchReplayCommand(options, OutputFormatIssueDrafts); + + private static string BuildAdHocSearchReplayCommand(QueryCommandOptions options, string outputFormat) { var args = new List { @@ -3275,18 +3278,24 @@ private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOption "search", "--query", options.Query!, - "--format", - OutputFormatIssueDrafts, - "--limit", - options.Limit.ToString(CultureInfo.InvariantCulture), }; + AddReplayValueOption(args, "--format", outputFormat); + AddReplayValueOption(args, "--limit", options.Limit.ToString(CultureInfo.InvariantCulture)); if (options.DbPathExplicit) AddReplayValueOption(args, "--db", options.DbPath); + if (!string.IsNullOrWhiteSpace(options.DataDir)) + AddReplayValueOption(args, "--data-dir", options.DataDir); if (options.SourceOnly) args.Add("--source-only"); + else if (options.AuditScopeExplicit) + AddReplayValueOption(args, "--audit-scope", options.AuditScope); if (!string.IsNullOrWhiteSpace(options.Lang)) AddReplayValueOption(args, "--lang", options.Lang); + if (!string.IsNullOrWhiteSpace(options.SolutionFilter)) + AddReplayValueOption(args, "--solution", options.SolutionFilter); + foreach (var projectFilter in options.ProjectFilters) + AddReplayValueOption(args, "--project", projectFilter); foreach (var pathPattern in options.PathPatterns) AddReplayValueOption(args, "--path", pathPattern); foreach (var excludePath in options.ExcludePaths) @@ -3295,6 +3304,12 @@ private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOption args.Add("--exclude-tests"); if (options.IncludeGenerated) args.Add("--include-generated"); + if (options.ExcludeComments) + args.Add("--exclude-comments"); + if (options.ExcludeStrings) + args.Add("--exclude-strings"); + if (options.ExcludeFixtures) + args.Add("--exclude-fixtures"); if (options.Since.HasValue) AddReplayValueOption(args, "--since", options.Since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); if (options.NoDedup) @@ -3307,22 +3322,16 @@ private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOption args.Add("--exact"); if (options.ExactSubstring) args.Add("--exact-substring"); - if (options.TokenBoundary) - args.Add("--token-boundary"); if (options.Prefix) args.Add("--prefix"); + if (options.TokenBoundary) + args.Add("--token-boundary"); foreach (var guardFilter in options.GuardFilters) AddReplayValueOption(args, BuildSearchGuardReplayOptionName(guardFilter), guardFilter.Query); if (options.GuardFilters.Count > 0 && options.GuardWindow != DbReader.DefaultSearchGuardWindow) AddReplayValueOption(args, "--guard-window", options.GuardWindow.ToString(CultureInfo.InvariantCulture)); if (options.GuardFilters.Count > 0 && options.GuardScope != SearchGuardScope.Window) AddReplayValueOption(args, "--guard-scope", FormatSearchGuardScope(options.GuardScope)); - if (options.ExcludeComments) - args.Add("--exclude-comments"); - if (options.ExcludeStrings) - args.Add("--exclude-strings"); - if (options.ExcludeFixtures) - args.Add("--exclude-fixtures"); foreach (var origin in options.MatchOrigins) AddReplayValueOption(args, "--origin", origin); foreach (var origin in options.ExcludeOrigins) @@ -3335,25 +3344,28 @@ private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOption AddReplayValueOption(args, "--snippet-lines", options.SnippetLines.ToString(CultureInfo.InvariantCulture)); AddReplayValueOption(args, "--snippet-focus", FormatSearchSnippetFocusMode(options.SnippetFocus)); AddReplayValueOption(args, "--max-line-width", options.MaxLineWidth.ToString(CultureInfo.InvariantCulture)); - if (options.MaxJsonBytes.HasValue) - AddReplayValueOption(args, "--max-json-bytes", options.MaxJsonBytes.Value.ToString(CultureInfo.InvariantCulture)); - if (!string.IsNullOrWhiteSpace(options.OpenIssuesPath)) - AddReplayValueOption(args, "--open-issues", options.OpenIssuesPath); - if (!string.IsNullOrWhiteSpace(options.OpenIssuesRepository)) - AddReplayValueOption(args, "--repo", options.OpenIssuesRepository); - if (options.IssueState != IssueDuplicatePreflight.DefaultIssueState) - AddReplayValueOption(args, "--issue-state", options.IssueState); - if (options.DuplicatePreflightTuningExplicit) - { - if (string.Equals(options.DuplicateConfidence, IssueDuplicatePreflight.CustomDuplicateConfidence, StringComparison.Ordinal)) - AddReplayValueOption(args, "--duplicate-threshold", options.DuplicateThreshold.ToString("0.###", CultureInfo.InvariantCulture)); - else - AddReplayValueOption(args, "--duplicate-confidence", options.DuplicateConfidence); + if (string.Equals(outputFormat, OutputFormatIssueDrafts, StringComparison.Ordinal)) + { + if (options.MaxJsonBytes.HasValue) + AddReplayValueOption(args, "--max-json-bytes", options.MaxJsonBytes.Value.ToString(CultureInfo.InvariantCulture)); + if (!string.IsNullOrWhiteSpace(options.OpenIssuesPath)) + AddReplayValueOption(args, "--open-issues", options.OpenIssuesPath); + if (!string.IsNullOrWhiteSpace(options.OpenIssuesRepository)) + AddReplayValueOption(args, "--repo", options.OpenIssuesRepository); + if (options.IssueState != IssueDuplicatePreflight.DefaultIssueState) + AddReplayValueOption(args, "--issue-state", options.IssueState); + if (options.DuplicatePreflightTuningExplicit) + { + if (string.Equals(options.DuplicateConfidence, IssueDuplicatePreflight.CustomDuplicateConfidence, StringComparison.Ordinal)) + AddReplayValueOption(args, "--duplicate-threshold", options.DuplicateThreshold.ToString("0.###", CultureInfo.InvariantCulture)); + else + AddReplayValueOption(args, "--duplicate-confidence", options.DuplicateConfidence); + } + foreach (var label in options.IssueLabels) + AddReplayValueOption(args, "--issue-label", label); + if (!string.IsNullOrWhiteSpace(options.IssueTitle)) + AddReplayValueOption(args, "--issue-title", options.IssueTitle); } - foreach (var label in options.IssueLabels) - AddReplayValueOption(args, "--issue-label", label); - if (!string.IsNullOrWhiteSpace(options.IssueTitle)) - AddReplayValueOption(args, "--issue-title", options.IssueTitle); return string.Join(" ", args.Select(QuoteReplayShellArg)); } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index 0f88e43e2..61b737e28 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -550,6 +550,43 @@ private static QueryCountResult CountSearchMatches(DbReader reader, QueryCommand options.GuardScope, options.TokenBoundary); + private static AdHocSearchSarifSourceResultCount CountAdHocSearchSarifSourceResults( + DbReader reader, + QueryCommandOptions options, + bool exact, + IReadOnlyList boundedSourceRows) + { + if (options.GuardFilters.Count > 0) + return CountAdHocSearchSarifResultUnits(boundedSourceRows, options.Query!, exact, authoritative: false); + + if (!exact) + return new AdHocSearchSarifSourceResultCount(CountSearchMatches(reader, options, exact).Count, Authoritative: true); + + var rows = BuildSearchDisplayRows( + ReadSearchResults(reader, options, exact, int.MaxValue), + options, + exact); + return CountAdHocSearchSarifResultUnits(rows, options.Query!, exact, authoritative: true); + } + + private static AdHocSearchSarifSourceResultCount CountAdHocSearchSarifResultUnits( + IReadOnlyList rows, + string query, + bool exact, + bool authoritative) + { + var count = 0L; + foreach (var row in rows) + { + count += ToSearchSarifItems(row, query, exact).LongCount(); + if (count >= int.MaxValue) + return new AdHocSearchSarifSourceResultCount(int.MaxValue, Authoritative: false); + } + return new AdHocSearchSarifSourceResultCount((int)count, authoritative); + } + + private sealed record AdHocSearchSarifSourceResultCount(int Count, bool Authoritative); + private static void WriteGroupedSearchResultsHuman(List rows, QueryCommandOptions options) { foreach (var group in BuildSearchFileGroups(rows, options)) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index d653fa0f1..c93c4ac3e 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1539,9 +1539,9 @@ public void RunSearch_EditorFormatsApplyLimitAfterOccurrenceExpansion_Issue4437( } [Fact] - public void RunSearch_FormatSarifEmitsResultsArray() + public void RunSearch_FormatSarifReportsCompletionForLimitedCompleteEmptyAndMergedRuns_Issue4844() { - var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_format_sarif"); + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_format_sarif_4844"); try { var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); @@ -1551,18 +1551,17 @@ public void RunSearch_FormatSarifEmitsResultsArray() "csharp", "public class App { void Run() { Authenticate(); } }"); - var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + var (completeExitCode, completeStdout, completeStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( ["Authenticate", "--db", dbPath, "--format", "sarif"], _jsonOptions)); - Assert.Equal(CommandExitCodes.Success, exitCode); - Assert.Equal(string.Empty, stderr); - using var document = ParseJsonOutput(stdout); - var root = document.RootElement; - Assert.Equal("2.1.0", root.GetProperty("version").GetString()); - var run = root.GetProperty("runs")[0]; - var rule = Assert.Single(run.GetProperty("tool").GetProperty("driver").GetProperty("rules").EnumerateArray()); - var result = Assert.Single(run.GetProperty("results").EnumerateArray()); + Assert.Equal(CommandExitCodes.Success, completeExitCode); + Assert.Equal(string.Empty, completeStderr); + using var completeDocument = ParseJsonOutput(completeStdout); + Assert.Equal("2.1.0", completeDocument.RootElement.GetProperty("version").GetString()); + var completeRun = completeDocument.RootElement.GetProperty("runs")[0]; + var rule = Assert.Single(completeRun.GetProperty("tool").GetProperty("driver").GetProperty("rules").EnumerateArray()); + var result = Assert.Single(completeRun.GetProperty("results").EnumerateArray()); Assert.Equal("search", rule.GetProperty("id").GetString()); Assert.Equal("cdidx search", rule.GetProperty("name").GetString()); @@ -1575,11 +1574,197 @@ public void RunSearch_FormatSarifEmitsResultsArray() var region = result.GetProperty("locations")[0].GetProperty("physicalLocation").GetProperty("region"); Assert.Equal(33, region.GetProperty("startColumn").GetInt32()); Assert.Equal(45, region.GetProperty("endColumn").GetInt32()); - } - finally - { - TestProjectHelper.DeleteDirectory(projectRoot); - } + AssertAdHocSearchSarifCompletion( + completeRun, + sourceResultCount: 1, + returnedResultCount: 1, + resultLimit: 20, + truncated: false); + + for (var i = 1; i < 126; i++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"src/app{i:D3}.cs", + "csharp", + $"public class App{i:D3} {{ void Run() {{ Authenticate(); }} }}"); + } + + var (limitedExitCode, limitedStdout, limitedStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [ + "Authenticate", + "--db", dbPath, + "--format", "sarif", + "--limit", "1", + "--path", "src/**", + "--exclude-tests", + "--origin", "code", + ], + _jsonOptions)); + var (emptyExitCode, emptyStdout, emptyStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["MissingNeedle", "--db", dbPath, "--format", "sarif", "--limit", "1"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, limitedExitCode); + Assert.Equal(CommandExitCodes.Success, emptyExitCode); + Assert.Equal(string.Empty, limitedStderr); + Assert.Equal(string.Empty, emptyStderr); + using var limitedDocument = ParseJsonOutput(limitedStdout); + using var emptyDocument = ParseJsonOutput(emptyStdout); + var limitedRun = limitedDocument.RootElement.GetProperty("runs")[0]; + var emptyRun = emptyDocument.RootElement.GetProperty("runs")[0]; + Assert.Single(limitedRun.GetProperty("results").EnumerateArray()); + Assert.Empty(emptyRun.GetProperty("results").EnumerateArray()); + AssertAdHocSearchSarifCompletion( + limitedRun, + sourceResultCount: 126, + returnedResultCount: 1, + resultLimit: 1, + truncated: true); + var limitedReplayCommand = limitedRun.GetProperty("properties").GetProperty("replay_command").GetString(); + Assert.Contains("--path 'src/**'", limitedReplayCommand, StringComparison.Ordinal); + Assert.Contains("--exclude-tests", limitedReplayCommand, StringComparison.Ordinal); + Assert.Contains("--origin code", limitedReplayCommand, StringComparison.Ordinal); + AssertAdHocSearchSarifCompletion( + emptyRun, + sourceResultCount: 0, + returnedResultCount: 0, + resultLimit: 1, + truncated: false); + + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/repeat.cs", + "csharp", + "public class RepeatFixture { void Run() { OccurrenceNeedle(); OccurrenceNeedle(); OccurrenceNeedle(); OccurrenceNeedle(); } }"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/dash.cs", + "csharp", + "public class DashFixture { // -TODO\n}"); + var (expandedExitCode, expandedStdout, expandedStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["OccurrenceNeedle", "--db", dbPath, "--format", "sarif", "--exact-substring", "--origin", "code", "--limit", "2"], + _jsonOptions)); + var (rawFtsExitCode, rawFtsStdout, rawFtsStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["Authenticate OR OccurrenceNeedle", "--db", dbPath, "--format", "sarif", "--fts", "--limit", "1"], + _jsonOptions)); + var (dashExitCode, dashStdout, dashStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--", "-TODO", "--db", dbPath, "--format", "sarif", "--exact-substring", "--limit", "1"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, expandedExitCode); + Assert.Equal(CommandExitCodes.Success, rawFtsExitCode); + Assert.Equal(CommandExitCodes.Success, dashExitCode); + Assert.Equal(string.Empty, expandedStderr); + Assert.Equal(string.Empty, rawFtsStderr); + Assert.Equal(string.Empty, dashStderr); + using var expandedDocument = ParseJsonOutput(expandedStdout); + using var rawFtsDocument = ParseJsonOutput(rawFtsStdout); + using var dashDocument = ParseJsonOutput(dashStdout); + var expandedRun = expandedDocument.RootElement.GetProperty("runs")[0]; + AssertAdHocSearchSarifCompletion( + expandedRun, + sourceResultCount: 4, + returnedResultCount: 2, + resultLimit: 2, + truncated: true); + var rawFtsReplayCommand = rawFtsDocument.RootElement.GetProperty("runs")[0] + .GetProperty("properties") + .GetProperty("replay_command") + .GetString(); + Assert.Contains("--fts", rawFtsReplayCommand, StringComparison.Ordinal); + Assert.DoesNotContain("--raw-fts", rawFtsReplayCommand, StringComparison.Ordinal); + var dashReplayCommand = dashDocument.RootElement.GetProperty("runs")[0] + .GetProperty("properties") + .GetProperty("replay_command") + .GetString(); + Assert.Contains("search --query -TODO", dashReplayCommand, StringComparison.Ordinal); + + for (var i = 0; i <= DbReader.MaxGuardedSearchCandidates; i++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"src/guard{i:D4}.cs", + "csharp", + $"public class GuardFixture{i:D4} {{ void Run() {{ GuardNeedle(); Continue(); }} }}"); + } + var (guardedExitCode, guardedStdout, guardedStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["GuardNeedle", "--db", dbPath, "--format", "sarif", "--exact-substring", "--reject-after", "NeverPresent", "--limit", "1"], + _jsonOptions)); + + Assert.True( + guardedExitCode == CommandExitCodes.Success, + $"Expected guarded SARIF success, got {guardedExitCode}: {guardedStderr}"); + Assert.Equal(string.Empty, guardedStderr); + using var guardedDocument = ParseJsonOutput(guardedStdout); + var guardedRun = guardedDocument.RootElement.GetProperty("runs")[0]; + Assert.Single(guardedRun.GetProperty("results").EnumerateArray()); + AssertAdHocSearchSarifCompletion( + guardedRun, + sourceResultCount: 1, + returnedResultCount: 1, + resultLimit: 1, + truncated: true, + sourceResultCountAuthoritative: false); + Assert.Contains( + "--reject-after NeverPresent", + guardedRun.GetProperty("properties").GetProperty("replay_command").GetString(), + StringComparison.Ordinal); + + var mergedDocumentJson = "{\"version\":\"2.1.0\",\"runs\":[" + + completeRun.GetRawText() + + "," + + limitedRun.GetRawText() + + "," + + emptyRun.GetRawText() + + "]}"; + using var mergedDocument = ParseJsonOutput(mergedDocumentJson); + var mergedRuns = mergedDocument.RootElement.GetProperty("runs"); + Assert.Equal(3, mergedRuns.GetArrayLength()); + AssertAdHocSearchSarifCompletion(mergedRuns[0], 1, 1, 20, truncated: false); + AssertAdHocSearchSarifCompletion(mergedRuns[1], 126, 1, 1, truncated: true); + AssertAdHocSearchSarifCompletion(mergedRuns[2], 0, 0, 1, truncated: false); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + private static void AssertAdHocSearchSarifCompletion( + JsonElement run, + int sourceResultCount, + int returnedResultCount, + int resultLimit, + bool truncated, + bool sourceResultCountAuthoritative = true) + { + var omittedResultCount = sourceResultCount - returnedResultCount; + var properties = run.GetProperty("properties"); + Assert.Equal("search", properties.GetProperty("format").GetString()); + Assert.Equal(1, properties.GetProperty("query_count").GetInt32()); + Assert.Equal(sourceResultCount, properties.GetProperty("source_result_count").GetInt32()); + Assert.Equal(sourceResultCountAuthoritative, properties.GetProperty("source_result_count_authoritative").GetBoolean()); + Assert.Equal(returnedResultCount, properties.GetProperty("result_count").GetInt32()); + Assert.Equal(resultLimit, properties.GetProperty("limit_per_query").GetInt32()); + Assert.False(properties.GetProperty("cursoring_available").GetBoolean()); + Assert.Contains("--format sarif", properties.GetProperty("replay_command").GetString(), StringComparison.Ordinal); + + var truncation = properties.GetProperty("truncation"); + Assert.Equal(truncated, truncation.GetProperty("truncated").GetBoolean()); + Assert.Equal(truncated ? 1 : 0, truncation.GetProperty("truncated_query_count").GetInt32()); + Assert.Equal(omittedResultCount, truncation.GetProperty("minimum_omitted_result_count").GetInt32()); + + var query = Assert.Single(properties.GetProperty("queries").EnumerateArray()); + Assert.Equal("ad-hoc", query.GetProperty("name").GetString()); + Assert.Equal(sourceResultCount, query.GetProperty("source_result_count").GetInt32()); + Assert.Equal(sourceResultCountAuthoritative, query.GetProperty("source_result_count_authoritative").GetBoolean()); + Assert.Equal(returnedResultCount, query.GetProperty("result_count").GetInt32()); + Assert.Equal(resultLimit, query.GetProperty("result_limit").GetInt32()); + Assert.Equal(truncated, query.GetProperty("truncated").GetBoolean()); + Assert.Equal(omittedResultCount, query.GetProperty("minimum_omitted_result_count").GetInt32()); + Assert.Equal(JsonValueKind.Null, query.GetProperty("next_cursor").ValueKind); + Assert.Contains("--format sarif", query.GetProperty("replay_command").GetString(), StringComparison.Ordinal); } [Fact]