From ebc229ceebfec79cec712584aa495d1c631b0f54 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 22:45:22 +0900 Subject: [PATCH 1/3] Fix ad-hoc SARIF completion metadata (#4844) --- DEVELOPER_GUIDE.md | 2 + TESTING_GUIDE.md | 2 + changelog.d/unreleased/4844.fixed.md | 21 +++ .../Cli/QueryCommandRunner.Locations.cs | 7 +- src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs | 46 ++++++ .../Cli/QueryCommandRunner.Search.cs | 20 ++- .../Cli/QueryCommandRunner.SearchRecipes.cs | 71 ++++++--- .../QueryCommandRunnerSearchTests.cs | 135 +++++++++++++++--- 8 files changed, 267 insertions(+), 37 deletions(-) create mode 100644 changelog.d/unreleased/4844.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2a25324f7..7c0c2452d 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1552,6 +1552,7 @@ access. | Bounded response edge cases | `impact` applies the cursor offset only to the selected nested collection so definition pages do not repeat or alter caller/fallback mode. Plain `map --compact` preserves its established section arrays and truncation payload; a collection projection is rejected when `--summary-only` or an excluding `--sections` filter would remove it. Explicit definition body fields override compact defaults. Profile and verbose records are moved into `metadata.stream_control_records`, and parser/capture failures emit an error envelope only when it fits the active hard byte cap. | | `--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. | | Recipe row selection | Row-producing recipe paths share `ApplySearchOutputSelection`: `--first-per-file` and deterministic `--sample` run before the effective per-query / remaining total limit. Sample fetch envelopes are sized from at least the requested sample target. Aggregate and compact query DTOs expose nullable `selection_reason` / `selection_omitted_count`; issue-draft source DTOs preserve them and recipe NDJSON forwards their aggregate to the terminal record. 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, and summary-only compact shapes reject `--first-per-file` / `--sample`, while every recipe shape rejects grouped-only `--per-file-limit`. | +| Ad-hoc search SARIF | `search --format sarif` stores completion metadata on each SARIF run. The run and its single `queries[]` summary report authoritative `source_result_count`, emitted `result_count`, the applied `limit_per_query` / `result_limit`, conservative `minimum_omitted_result_count`, and `truncated` state. 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 the query and active search controls. The completion vocabulary intentionally matches recipe SARIF, and empty runs carry the same fields with zero counts. | | 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. | | NDJSON terminal records | Default NDJSON for `search`, `symbols`, and `files` appends one final `terminal_record` after result rows; search also emits it for zero-result responses, while raw `symbols` and `files` keep zero-result NDJSON empty. Recipe/audit search row streams share the same writer. Terminals report returned and observed total counts, `total_count_authoritative` / `total_count_lower_bound`, selection or interruption reason, applied limits, omitted rows, and recovery guidance. `--max-json-bytes` covers the complete stdout stream, including newlines and this terminal record; a cap that cannot fit the terminal fails before stdout. Capped output rejects `--profile`, `--verbose`, and `--json-envelope`. Byte-cap partial output exits with `CommandExitCodes.PartialResult` (`11`) unless `--allow-partial` explicitly opts into exit `0`. `--results-only` is the explicit terminal-record opt-out for these NDJSON row streams and is rejected with array, compact, summary, or count output. | @@ -4736,6 +4737,7 @@ help はすべてこのレジストリを参照します。field 名は大文字 | bounded 応答の edge case | `impact` は選択された nested collection だけに cursor offset を適用するため、definition page の重複や caller / fallback mode の変化を防ぎます。通常の `map --compact` は既存の section array と truncation payload を維持し、collection projection が `--summary-only` または除外する `--sections` filter で失われる組み合わせは拒否します。明示的な definition body field は compact default より優先します。profile / verbose record は `metadata.stream_control_records` へ移し、parser / capture failure の error envelope は active な hard byte cap に収まる場合だけ出力します。 | | `--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 を説明します。 | | recipe row selection | row を返す recipe path は `ApplySearchOutputSelection` を共有し、`--first-per-file` と決定的な `--sample` を、有効な query ごとの limit / 残り total limit より先に適用します。sample 用 fetch envelope は少なくとも要求 sample 数を基準に sizing します。aggregate / compact の query DTO は nullable な `selection_reason` / `selection_omitted_count` を公開し、issue-draft の source DTO もこれらを保持し、recipe NDJSON は集計値を terminal record へ渡します。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、summary-only compact の shape は `--first-per-file` / `--sample` を拒否し、すべての recipe shape は grouped 専用の `--per-file-limit` を拒否します。 | +| ad-hoc search SARIF | `search --format sarif` は completion metadata を SARIF の各 run に格納します。run と単一の `queries[]` summary は authoritative な `source_result_count`、出力済み `result_count`、適用された `limit_per_query` / `result_limit`、保守的な `minimum_omitted_result_count`、`truncated` state を返します。ad-hoc search は継続 cursor を公開しないため、`cursoring_available` は `false`、`next_cursor` は null となり、shell quote 済みの `replay_command` が query と有効な search control を保持します。completion vocabulary は意図的に recipe SARIF と共通化し、空 run も count が 0 の同じ field を保持します。 | | 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 を分離するために使います。 | | NDJSON terminal record | `search`、`symbols`、`files` の既定 NDJSON は result row の後に最後の `terminal_record` を 1 件追加します。`search` は 0 件応答にも終端を出力しますが、raw `symbols` / `files` の 0 件 NDJSON は空のままです。recipe / audit search の row stream も同じ writer を使います。終端は返却件数と観測済み総件数、`total_count_authoritative` / `total_count_lower_bound`、selection または中断理由、適用上限、省略行数、復旧案内を報告します。`--max-json-bytes` は改行と終端レコードを含む stdout stream 全体を対象にし、終端自体が収まらない cap は stdout 出力前に失敗します。上限付き出力は `--profile`、`--verbose`、`--json-envelope` を拒否します。byte cap による部分出力は、`--allow-partial` で終了コード `0` を明示許可しない限り `CommandExitCodes.PartialResult`(`11`)を返します。`--results-only` はこれらの NDJSON row stream から終端レコードを明示的に除外するための option であり、array / compact / summary / count 出力との組み合わせは拒否されます。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index f0749bc0b..0e83a5512 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -202,6 +202,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, empty, and synthetically merged multi-run documents. Assert source/emitted/omitted counts, applied limits, truncation, null cursor state, replay commands, 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. @@ -1100,6 +1101,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、empty、合成した multi-run document で1つの fixture を共有します。source / emitted / omitted count、適用済み limit、truncation、null cursor state、replay command、および各 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..603fadff1 --- /dev/null +++ b/changelog.d/unreleased/4844.fixed.md @@ -0,0 +1,21 @@ +--- +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 + - 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, applied limits, truncation state, cursor availability, and a replay command, including complete and empty runs. + +## 日本語 + +- **ad-hoc search の SARIF が不完全な出力を明示するようになりました(#4844、#1747 の follow-up)** — search SARIF の各 run は、complete / empty run を含め、source・返却・省略 result 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 5e75539ee..7734308f4 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Locations.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Locations.cs @@ -113,7 +113,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) { @@ -139,7 +142,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..df8c869fb 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs @@ -31,6 +31,52 @@ private static string FormatSymbolLocationLabel(SymbolResult result) return string.IsNullOrWhiteSpace(result.Name) ? kind : $"{kind} {result.Name}"; } + private static JsonObject BuildAdHocSearchSarifRunProperties( + QueryCommandOptions options, + SearchOutputSelection selection, + int sourceResultCount, + int returnedResultCount) + { + var omittedResultCount = Math.Max(0, sourceResultCount - returnedResultCount); + var truncated = selection.Truncated || omittedResultCount > 0; + var replayCommand = BuildAdHocSearchReplayCommand(options, OutputFormatSarif); + var querySummary = new JsonObject + { + ["name"] = "ad-hoc", + ["query"] = options.Query, + ["source_result_count"] = sourceResultCount, + ["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, + ["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 a8918bc98..8ede139ad 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -741,7 +741,14 @@ private static int RunSearchCore( } 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, + CountSearchMatches(reader, options, exactSearch).Count, + returnedResultCount: 0) + : null; + if (options.Json && TryWriteEmptyFormattedResult(options, jsonOptions, emptySarifRunProperties)) return ZeroResultExitCode(options); if (options.Json) { @@ -852,7 +859,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, + CountSearchMatches(reader, options, exactSearch).Count, + 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 d2c81d354..966972318 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -3049,6 +3049,9 @@ private static string BuildAdHocSearchIssueDraftBody( } private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOptions options) + => BuildAdHocSearchReplayCommand(options, OutputFormatIssueDrafts); + + private static string BuildAdHocSearchReplayCommand(QueryCommandOptions options, string outputFormat) { var args = new List { @@ -3056,21 +3059,39 @@ private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOption "search", options.Query!, "--format", - OutputFormatIssueDrafts, + outputFormat, "--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) AddReplayValueOption(args, "--exclude-path", excludePath); if (options.ExcludeTests) 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) @@ -3081,6 +3102,19 @@ private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOption args.Add("--exact"); if (options.ExactSubstring) args.Add("--exact-substring"); + if (options.RawFts) + args.Add("--raw-fts"); + if (options.Prefix) + args.Add("--prefix"); + if (options.TokenBoundary) + args.Add("--token-boundary"); + AddSearchRecipeRowSelectionReplayOptions(args, options); + foreach (var origin in options.MatchOrigins) + AddReplayValueOption(args, "--origin", origin); + foreach (var origin in options.ExcludeOrigins) + AddReplayValueOption(args, "--exclude-origin", origin); + foreach (var resultKind in options.ResultKinds) + AddReplayValueOption(args, "--result-kind", resultKind); foreach (var guardFilter in options.GuardFilters) AddReplayValueOption(args, BuildSearchGuardReplayOptionName(guardFilter), guardFilter.Query); if (options.GuardFilters.Count > 0 && options.GuardWindow != DbReader.DefaultSearchGuardWindow) @@ -3090,23 +3124,26 @@ 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 (!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 (!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/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 7f1ba29f7..b8086ef5e 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1538,9 +1538,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); @@ -1550,18 +1550,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()); @@ -1574,11 +1573,115 @@ 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); + + 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) + { + 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(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(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] From cee697e9dc6b33f73e0bc321e2a120f70d19c753 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 23:14:35 +0900 Subject: [PATCH 2/3] Address adversarial SARIF review (#4844) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4844.fixed.md | 1 + .../Cli/QueryCommandRunner.Search.cs | 4 +- .../Cli/QueryCommandRunner.SearchRecipes.cs | 12 ++--- .../Cli/QueryCommandRunner.SearchResults.cs | 24 ++++++++++ .../QueryCommandRunnerSearchTests.cs | 48 +++++++++++++++++++ 7 files changed, 85 insertions(+), 12 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7c0c2452d..3bac4a323 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1552,7 +1552,7 @@ access. | Bounded response edge cases | `impact` applies the cursor offset only to the selected nested collection so definition pages do not repeat or alter caller/fallback mode. Plain `map --compact` preserves its established section arrays and truncation payload; a collection projection is rejected when `--summary-only` or an excluding `--sections` filter would remove it. Explicit definition body fields override compact defaults. Profile and verbose records are moved into `metadata.stream_control_records`, and parser/capture failures emit an error envelope only when it fits the active hard byte cap. | | `--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. | | Recipe row selection | Row-producing recipe paths share `ApplySearchOutputSelection`: `--first-per-file` and deterministic `--sample` run before the effective per-query / remaining total limit. Sample fetch envelopes are sized from at least the requested sample target. Aggregate and compact query DTOs expose nullable `selection_reason` / `selection_omitted_count`; issue-draft source DTOs preserve them and recipe NDJSON forwards their aggregate to the terminal record. 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, and summary-only compact shapes reject `--first-per-file` / `--sample`, while every recipe shape rejects grouped-only `--per-file-limit`. | -| Ad-hoc search SARIF | `search --format sarif` stores completion metadata on each SARIF run. The run and its single `queries[]` summary report authoritative `source_result_count`, emitted `result_count`, the applied `limit_per_query` / `result_limit`, conservative `minimum_omitted_result_count`, and `truncated` state. 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 the query and active search controls. The completion vocabulary intentionally matches recipe SARIF, and empty runs carry the same fields with zero counts. | +| Ad-hoc search SARIF | `search --format sarif` stores completion metadata on each SARIF run. The run and its single `queries[]` summary report authoritative `source_result_count`, 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. 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. | | 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. | | NDJSON terminal records | Default NDJSON for `search`, `symbols`, and `files` appends one final `terminal_record` after result rows; search also emits it for zero-result responses, while raw `symbols` and `files` keep zero-result NDJSON empty. Recipe/audit search row streams share the same writer. Terminals report returned and observed total counts, `total_count_authoritative` / `total_count_lower_bound`, selection or interruption reason, applied limits, omitted rows, and recovery guidance. `--max-json-bytes` covers the complete stdout stream, including newlines and this terminal record; a cap that cannot fit the terminal fails before stdout. Capped output rejects `--profile`, `--verbose`, and `--json-envelope`. Byte-cap partial output exits with `CommandExitCodes.PartialResult` (`11`) unless `--allow-partial` explicitly opts into exit `0`. `--results-only` is the explicit terminal-record opt-out for these NDJSON row streams and is rejected with array, compact, summary, or count output. | @@ -4737,7 +4737,7 @@ help はすべてこのレジストリを参照します。field 名は大文字 | bounded 応答の edge case | `impact` は選択された nested collection だけに cursor offset を適用するため、definition page の重複や caller / fallback mode の変化を防ぎます。通常の `map --compact` は既存の section array と truncation payload を維持し、collection projection が `--summary-only` または除外する `--sections` filter で失われる組み合わせは拒否します。明示的な definition body field は compact default より優先します。profile / verbose record は `metadata.stream_control_records` へ移し、parser / capture failure の error envelope は active な hard byte cap に収まる場合だけ出力します。 | | `--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 を説明します。 | | recipe row selection | row を返す recipe path は `ApplySearchOutputSelection` を共有し、`--first-per-file` と決定的な `--sample` を、有効な query ごとの limit / 残り total limit より先に適用します。sample 用 fetch envelope は少なくとも要求 sample 数を基準に sizing します。aggregate / compact の query DTO は nullable な `selection_reason` / `selection_omitted_count` を公開し、issue-draft の source DTO もこれらを保持し、recipe NDJSON は集計値を terminal record へ渡します。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、summary-only compact の shape は `--first-per-file` / `--sample` を拒否し、すべての recipe shape は grouped 専用の `--per-file-limit` を拒否します。 | -| ad-hoc search SARIF | `search --format sarif` は completion metadata を SARIF の各 run に格納します。run と単一の `queries[]` summary は authoritative な `source_result_count`、出力済み `result_count`、適用された `limit_per_query` / `result_limit`、保守的な `minimum_omitted_result_count`、`truncated` state を返します。ad-hoc search は継続 cursor を公開しないため、`cursoring_available` は `false`、`next_cursor` は null となり、shell quote 済みの `replay_command` が query と有効な search control を保持します。completion vocabulary は意図的に recipe SARIF と共通化し、空 run も count が 0 の同じ field を保持します。 | +| ad-hoc search SARIF | `search --format sarif` は completion metadata を SARIF の各 run に格納します。run と単一の `queries[]` summary は authoritative な `source_result_count`、出力済み `result_count`、適用された `limit_per_query` / `result_limit`、保守的な `minimum_omitted_result_count`、`truncated` state を返します。source / emitted count は exact search の occurrence 展開を含む最終的な SARIF result / location 単位を使用します。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 を保持します。 | | 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 を分離するために使います。 | | NDJSON terminal record | `search`、`symbols`、`files` の既定 NDJSON は result row の後に最後の `terminal_record` を 1 件追加します。`search` は 0 件応答にも終端を出力しますが、raw `symbols` / `files` の 0 件 NDJSON は空のままです。recipe / audit search の row stream も同じ writer を使います。終端は返却件数と観測済み総件数、`total_count_authoritative` / `total_count_lower_bound`、selection または中断理由、適用上限、省略行数、復旧案内を報告します。`--max-json-bytes` は改行と終端レコードを含む stdout stream 全体を対象にし、終端自体が収まらない cap は stdout 出力前に失敗します。上限付き出力は `--profile`、`--verbose`、`--json-envelope` を拒否します。byte cap による部分出力は、`--allow-partial` で終了コード `0` を明示許可しない限り `CommandExitCodes.PartialResult`(`11`)を返します。`--results-only` はこれらの NDJSON row stream から終端レコードを明示的に除外するための option であり、array / compact / summary / count 出力との組み合わせは拒否されます。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 0e83a5512..25ed29fdf 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -202,7 +202,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, empty, and synthetically merged multi-run documents. Assert source/emitted/omitted counts, applied limits, truncation, null cursor state, replay commands, and unchanged rule/location/severity fields on every run. + Ad-hoc search SARIF completion coverage shares one fixture across complete, 1-of-126 limited, occurrence-expanded limited, empty, and synthetically merged multi-run documents. Assert source/emitted/omitted counts in SARIF result units, applied limits, truncation, null cursor state, raw-FTS and option-like-query replay commands, 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. @@ -1101,7 +1101,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、empty、合成した multi-run document で1つの fixture を共有します。source / emitted / omitted count、適用済み limit、truncation、null cursor state、replay command、および各 run で rule / location / severity field が不変であることを検証してください。 + ad-hoc search SARIF の completion coverage は complete、1-of-126 の limited、occurrence 展開後の limited、empty、合成した multi-run document で1つの fixture を共有します。SARIF result 単位の source / emitted / omitted count、適用済み limit、truncation、null cursor state、raw FTS と option のような query の replay command、および各 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 index 603fadff1..bff878621 100644 --- a/changelog.d/unreleased/4844.fixed.md +++ b/changelog.d/unreleased/4844.fixed.md @@ -7,6 +7,7 @@ affected: - 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 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs index 8ede139ad..69f6ad78a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -745,7 +745,7 @@ private static int RunSearchCore( ? BuildAdHocSearchSarifRunProperties( options, selection, - CountSearchMatches(reader, options, exactSearch).Count, + CountAdHocSearchSarifSourceResults(reader, options, exactSearch), returnedResultCount: 0) : null; if (options.Json && TryWriteEmptyFormattedResult(options, jsonOptions, emptySarifRunProperties)) @@ -866,7 +866,7 @@ selection.TruncationReason is "sample" or "first_per_file" var runProperties = BuildAdHocSearchSarifRunProperties( options, selection, - CountSearchMatches(reader, options, exactSearch).Count, + CountAdHocSearchSarifSourceResults(reader, options, exactSearch), sarifItems.Count); WriteSarif(sarifItems, jsonOptions, runProperties: runProperties); return CommandExitCodes.Success; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 966972318..d7e173882 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -3057,12 +3057,12 @@ private static string BuildAdHocSearchReplayCommand(QueryCommandOptions options, { "cdidx", "search", - options.Query!, - "--format", - outputFormat, - "--limit", - options.Limit.ToString(CultureInfo.InvariantCulture), }; + if (options.Query!.StartsWith('-')) + args.Add("--"); + args.Add(options.Query); + AddReplayValueOption(args, "--format", outputFormat); + AddReplayValueOption(args, "--limit", options.Limit.ToString(CultureInfo.InvariantCulture)); if (options.DbPathExplicit) AddReplayValueOption(args, "--db", options.DbPath); @@ -3103,7 +3103,7 @@ private static string BuildAdHocSearchReplayCommand(QueryCommandOptions options, if (options.ExactSubstring) args.Add("--exact-substring"); if (options.RawFts) - args.Add("--raw-fts"); + args.Add("--fts"); if (options.Prefix) args.Add("--prefix"); if (options.TokenBoundary) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index 74010a5f6..c29732dc1 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -503,6 +503,30 @@ private static QueryCountResult CountSearchMatches(DbReader reader, QueryCommand options.GuardScope, options.TokenBoundary); + private static int CountAdHocSearchSarifSourceResults( + DbReader reader, + QueryCommandOptions options, + bool exact) + { + if (!exact) + return CountSearchMatches(reader, options, exact).Count; + + var rows = HasSearchOriginFilters(options) + ? ReadOriginFilteredSearchDisplayRows(reader, options, exact, int.MaxValue) + : BuildSearchDisplayRows( + ReadSearchResults(reader, options, exact, int.MaxValue), + options, + exact); + var count = 0L; + foreach (var row in rows) + { + count += ToSearchSarifItems(row, options.Query!, exact).LongCount(); + if (count >= int.MaxValue) + return int.MaxValue; + } + return (int)count; + } + 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 b8086ef5e..d14c4dd00 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1631,6 +1631,54 @@ public void RunSearch_FormatSarifReportsCompletionForLimitedCompleteEmptyAndMerg 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", "--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 -- -TODO", dashReplayCommand, StringComparison.Ordinal); + var mergedDocumentJson = "{\"version\":\"2.1.0\",\"runs\":[" + completeRun.GetRawText() + "," From 5c1041a3e88f875f756539c7edfcc100a84f9119 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 23:59:29 +0900 Subject: [PATCH 3/3] Preserve bounded SARIF completion counts (#4844) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4844.fixed.md | 4 +- src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs | 12 +++--- .../Cli/QueryCommandRunner.Search.cs | 5 ++- .../Cli/QueryCommandRunner.SearchResults.cs | 37 ++++++++++++------ .../QueryCommandRunnerSearchTests.cs | 38 ++++++++++++++++++- 7 files changed, 77 insertions(+), 27 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 3bac4a323..0dbfda3a0 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1552,7 +1552,7 @@ access. | Bounded response edge cases | `impact` applies the cursor offset only to the selected nested collection so definition pages do not repeat or alter caller/fallback mode. Plain `map --compact` preserves its established section arrays and truncation payload; a collection projection is rejected when `--summary-only` or an excluding `--sections` filter would remove it. Explicit definition body fields override compact defaults. Profile and verbose records are moved into `metadata.stream_control_records`, and parser/capture failures emit an error envelope only when it fits the active hard byte cap. | | `--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. | | Recipe row selection | Row-producing recipe paths share `ApplySearchOutputSelection`: `--first-per-file` and deterministic `--sample` run before the effective per-query / remaining total limit. Sample fetch envelopes are sized from at least the requested sample target. Aggregate and compact query DTOs expose nullable `selection_reason` / `selection_omitted_count`; issue-draft source DTOs preserve them and recipe NDJSON forwards their aggregate to the terminal record. 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, and summary-only compact shapes reject `--first-per-file` / `--sample`, while every recipe shape rejects grouped-only `--per-file-limit`. | -| Ad-hoc search SARIF | `search --format sarif` stores completion metadata on each SARIF run. The run and its single `queries[]` summary report authoritative `source_result_count`, 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. 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 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. | | 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. | | NDJSON terminal records | Default NDJSON for `search`, `symbols`, and `files` appends one final `terminal_record` after result rows; search also emits it for zero-result responses, while raw `symbols` and `files` keep zero-result NDJSON empty. Recipe/audit search row streams share the same writer. Terminals report returned and observed total counts, `total_count_authoritative` / `total_count_lower_bound`, selection or interruption reason, applied limits, omitted rows, and recovery guidance. `--max-json-bytes` covers the complete stdout stream, including newlines and this terminal record; a cap that cannot fit the terminal fails before stdout. Capped output rejects `--profile`, `--verbose`, and `--json-envelope`. Byte-cap partial output exits with `CommandExitCodes.PartialResult` (`11`) unless `--allow-partial` explicitly opts into exit `0`. `--results-only` is the explicit terminal-record opt-out for these NDJSON row streams and is rejected with array, compact, summary, or count output. | @@ -4737,7 +4737,7 @@ help はすべてこのレジストリを参照します。field 名は大文字 | bounded 応答の edge case | `impact` は選択された nested collection だけに cursor offset を適用するため、definition page の重複や caller / fallback mode の変化を防ぎます。通常の `map --compact` は既存の section array と truncation payload を維持し、collection projection が `--summary-only` または除外する `--sections` filter で失われる組み合わせは拒否します。明示的な definition body field は compact default より優先します。profile / verbose record は `metadata.stream_control_records` へ移し、parser / capture failure の error envelope は active な hard byte cap に収まる場合だけ出力します。 | | `--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 を説明します。 | | recipe row selection | row を返す recipe path は `ApplySearchOutputSelection` を共有し、`--first-per-file` と決定的な `--sample` を、有効な query ごとの limit / 残り total limit より先に適用します。sample 用 fetch envelope は少なくとも要求 sample 数を基準に sizing します。aggregate / compact の query DTO は nullable な `selection_reason` / `selection_omitted_count` を公開し、issue-draft の source DTO もこれらを保持し、recipe NDJSON は集計値を terminal record へ渡します。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、summary-only compact の shape は `--first-per-file` / `--sample` を拒否し、すべての recipe shape は grouped 専用の `--per-file-limit` を拒否します。 | -| ad-hoc search SARIF | `search --format sarif` は completion metadata を SARIF の各 run に格納します。run と単一の `queries[]` summary は authoritative な `source_result_count`、出力済み `result_count`、適用された `limit_per_query` / `result_limit`、保守的な `minimum_omitted_result_count`、`truncated` state を返します。source / emitted count は exact search の occurrence 展開を含む最終的な SARIF result / location 単位を使用します。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 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 を保持します。 | | 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 を分離するために使います。 | | NDJSON terminal record | `search`、`symbols`、`files` の既定 NDJSON は result row の後に最後の `terminal_record` を 1 件追加します。`search` は 0 件応答にも終端を出力しますが、raw `symbols` / `files` の 0 件 NDJSON は空のままです。recipe / audit search の row stream も同じ writer を使います。終端は返却件数と観測済み総件数、`total_count_authoritative` / `total_count_lower_bound`、selection または中断理由、適用上限、省略行数、復旧案内を報告します。`--max-json-bytes` は改行と終端レコードを含む stdout stream 全体を対象にし、終端自体が収まらない cap は stdout 出力前に失敗します。上限付き出力は `--profile`、`--verbose`、`--json-envelope` を拒否します。byte cap による部分出力は、`--allow-partial` で終了コード `0` を明示許可しない限り `CommandExitCodes.PartialResult`(`11`)を返します。`--results-only` はこれらの NDJSON row stream から終端レコードを明示的に除外するための option であり、array / compact / summary / count 出力との組み合わせは拒否されます。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 25ed29fdf..efd80d519 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -202,7 +202,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, occurrence-expanded limited, empty, and synthetically merged multi-run documents. Assert source/emitted/omitted counts in SARIF result units, applied limits, truncation, null cursor state, raw-FTS and option-like-query replay commands, and unchanged rule/location/severity fields on every run. + 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. @@ -1101,7 +1101,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、occurrence 展開後の limited、empty、合成した multi-run document で1つの fixture を共有します。SARIF result 単位の source / emitted / omitted count、適用済み limit、truncation、null cursor state、raw FTS と option のような query の replay command、および各 run で rule / location / severity field が不変であることを検証してください。 + 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 index bff878621..c40eb3974 100644 --- a/changelog.d/unreleased/4844.fixed.md +++ b/changelog.d/unreleased/4844.fixed.md @@ -15,8 +15,8 @@ affected: ## 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, applied limits, truncation state, cursor availability, and a replay command, including complete and empty runs. +- **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 run を含め、source・返却・省略 result count、適用済み limit、truncation state、cursor availability、replay command を返します。 +- **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.Sarif.cs b/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs index df8c869fb..5ff4d5a6a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs @@ -34,17 +34,18 @@ private static string FormatSymbolLocationLabel(SymbolResult result) private static JsonObject BuildAdHocSearchSarifRunProperties( QueryCommandOptions options, SearchOutputSelection selection, - int sourceResultCount, + AdHocSearchSarifSourceResultCount sourceResultCount, int returnedResultCount) { - var omittedResultCount = Math.Max(0, sourceResultCount - returnedResultCount); - var truncated = selection.Truncated || omittedResultCount > 0; + 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, + ["source_result_count"] = sourceResultCount.Count, + ["source_result_count_authoritative"] = sourceResultCount.Authoritative, ["result_count"] = returnedResultCount, ["result_limit"] = options.Limit, ["truncated"] = truncated, @@ -62,7 +63,8 @@ private static JsonObject BuildAdHocSearchSarifRunProperties( { ["format"] = "search", ["query_count"] = 1, - ["source_result_count"] = sourceResultCount, + ["source_result_count"] = sourceResultCount.Count, + ["source_result_count_authoritative"] = sourceResultCount.Authoritative, ["result_count"] = returnedResultCount, ["limit_per_query"] = options.Limit, ["queries"] = new JsonArray(querySummary), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs index 69f6ad78a..22c8ebda6 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -725,6 +725,7 @@ private static int RunSearchCore( ? CountSearchMatches(reader, options, exactSearch) : default; var displayRows = ReadSearchDisplayRows(reader, options, exactSearch); + var sarifSourceRows = displayRows; var selection = ApplySearchOutputSelection(displayRows, options); displayRows = selection.Rows; if (displayRows.Count == 0) @@ -745,7 +746,7 @@ private static int RunSearchCore( ? BuildAdHocSearchSarifRunProperties( options, selection, - CountAdHocSearchSarifSourceResults(reader, options, exactSearch), + CountAdHocSearchSarifSourceResults(reader, options, exactSearch, sarifSourceRows), returnedResultCount: 0) : null; if (options.Json && TryWriteEmptyFormattedResult(options, jsonOptions, emptySarifRunProperties)) @@ -866,7 +867,7 @@ selection.TruncationReason is "sample" or "first_per_file" var runProperties = BuildAdHocSearchSarifRunProperties( options, selection, - CountAdHocSearchSarifSourceResults(reader, options, exactSearch), + CountAdHocSearchSarifSourceResults(reader, options, exactSearch, sarifSourceRows), sarifItems.Count); WriteSarif(sarifItems, jsonOptions, runProperties: runProperties); return CommandExitCodes.Success; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index c29732dc1..861ab512d 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -503,30 +503,43 @@ private static QueryCountResult CountSearchMatches(DbReader reader, QueryCommand options.GuardScope, options.TokenBoundary); - private static int CountAdHocSearchSarifSourceResults( + private static AdHocSearchSarifSourceResultCount CountAdHocSearchSarifSourceResults( DbReader reader, QueryCommandOptions options, - bool exact) + bool exact, + IReadOnlyList boundedSourceRows) { + if (options.GuardFilters.Count > 0) + return CountAdHocSearchSarifResultUnits(boundedSourceRows, options.Query!, exact, authoritative: false); + if (!exact) - return CountSearchMatches(reader, options, exact).Count; + return new AdHocSearchSarifSourceResultCount(CountSearchMatches(reader, options, exact).Count, Authoritative: true); - var rows = HasSearchOriginFilters(options) - ? ReadOriginFilteredSearchDisplayRows(reader, options, exact, int.MaxValue) - : BuildSearchDisplayRows( - ReadSearchResults(reader, options, exact, int.MaxValue), - options, - exact); + 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, options.Query!, exact).LongCount(); + count += ToSearchSarifItems(row, query, exact).LongCount(); if (count >= int.MaxValue) - return int.MaxValue; + return new AdHocSearchSarifSourceResultCount(int.MaxValue, Authoritative: false); } - return (int)count; + 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 d14c4dd00..1c6384d62 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1642,7 +1642,7 @@ public void RunSearch_FormatSarifReportsCompletionForLimitedCompleteEmptyAndMerg "csharp", "public class DashFixture { // -TODO\n}"); var (expandedExitCode, expandedStdout, expandedStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - ["OccurrenceNeedle", "--db", dbPath, "--format", "sarif", "--exact-substring", "--limit", "2"], + ["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"], @@ -1679,6 +1679,37 @@ public void RunSearch_FormatSarifReportsCompletionForLimitedCompleteEmptyAndMerg .GetString(); Assert.Contains("search -- -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() + "," @@ -1704,13 +1735,15 @@ private static void AssertAdHocSearchSarifCompletion( int sourceResultCount, int returnedResultCount, int resultLimit, - bool truncated) + 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()); @@ -1724,6 +1757,7 @@ private static void AssertAdHocSearchSarifCompletion( 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());