From 0a44cf0c198c1c95bf71af9c8b25abe4f30c6cc4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 18:37:17 +0900 Subject: [PATCH 1/4] Fix issue-draft replay metadata (#4838) --- DEVELOPER_GUIDE.md | 2 + README.md | 4 + TESTING_GUIDE.md | 2 + changelog.d/unreleased/4838.fixed.md | 20 +++ .../Cli/QueryCommandRunner.SearchRecipes.cs | 115 +++++++++---- src/CodeIndex/Cli/SearchAuditRecipes.cs | 14 +- .../QueryCommandRunnerSearchTests.cs | 153 ++++++++++++++++++ 7 files changed, 282 insertions(+), 28 deletions(-) create mode 100644 changelog.d/unreleased/4838.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 7af236430..ccd6b18b4 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1533,6 +1533,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int | 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 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. Existing `result_count`, `result_limit`, `omitted_count`, and `truncated` fields now 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. | | 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. | @@ -4698,6 +4699,7 @@ AI エージェント向け検索ルールのテンプレートについては | 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 issue-draft selection | `search --format issue-drafts` は filter 済みの ad-hoc 母集団全体を読み、`--first-per-file`、決定的な `--sample`、`min(--limit, --total-limit)` の順に適用します。既存の `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 を分離するために使います。 | | 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/README.md b/README.md index c9afb16b1..3e65ed251 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ The default NDJSON output of `search`, `symbols`, and `files` always ends with a Recipe/audit compact pagination returns an opaque `next_cursor`. Replay it unchanged with the same child query and filters as `--cursor `; a search-score cursor can begin with `-`, and that leading minus is part of the cursor value. +Ad-hoc `search --format issue-drafts` computes `source_total_count`, `returned_count`, `omitted_count`, and `truncated` from the complete filtered population before applying `--first-per-file`, deterministic `--sample`, and the effective `--limit` / `--total-limit`. Its source metadata also records those selector values, and the shell-safe replay command preserves every result-changing selector so rerunning it reproduces the same selected evidence. + When the byte cap omits rows, these commands return partial-result exit code `11`; pass `--allow-partial` to opt into exit code `0` while retaining the same terminal metadata. Ordinary `--limit` truncation remains a successful, explicitly described stream. Array and compact outputs keep their documented whole-response behavior; check `cdidx --help` before relying on partial output. High-volume `definition`, `find`, `status`, `hotspots`, `references`, `callers`, `callees`, `impact`, and `map` responses also support an opt-in bounded envelope through `--fields`, `--cursor`, compact output where advertised, and a total `--max-json-bytes` budget. Its metadata reports returned/total/omitted counts and an opaque `next_cursor`; replay that cursor with the same query, filters, and sort arguments. The response also exposes its 10,000-row safety window and reports when that window is exhausted instead of emitting an unusable cursor. Existing compact location responses retain their top-level keys and lightweight `file` / `line` rows while adding the shared metadata; `refs` / `stats` aliases and matching read-only `batch` children use the same envelope and hard cap. `hotspots` and `impact` page their active primary collection, while dotted projections such as `callers.path,callers.depth` select nested rows and report that collection's total. `map --sections` selects whole response sections; a bounded projection such as `--fields top_files.path` instead pages that section's rows and avoids building unrelated ranked sections. For `definition --body`, `body`, `body_content`, and `all` retain the explicit body; projections that exclude it avoid materializing body text. @@ -515,6 +517,8 @@ JSON 形式では `--max-json-bytes` を文書全体の UTF-8 byte cap として recipe / audit の compact pagination は opaque な `next_cursor` を返します。同じ child query と filter を指定し、値を変更せず `--cursor ` として再利用してください。search-score cursor は `-` で始まる場合があり、その先頭のマイナス記号も cursor 値の一部です。 +ad-hoc の `search --format issue-drafts` は、filter 済み母集団全体から `source_total_count`、`returned_count`、`omitted_count`、`truncated` を算出してから、`--first-per-file`、決定的な `--sample`、有効な `--limit` / `--total-limit` を適用します。source metadata はこれらの selector 値も保持し、shell-safe な replay command は結果を変えるすべての selector を維持するため、再実行時に同じ evidence 集合を再現できます。 + byte cap により行を省略した場合、これらのコマンドは partial-result 終了コード `11` を返します。同じ終端 metadata を維持したまま終了コード `0` を明示的に許容するには `--allow-partial` を指定します。通常の `--limit` による切り詰めは、理由が明示された成功 stream のままです。array / compact 出力は文書化済みの whole-response 挙動を維持します。部分出力へ依存する前に `cdidx --help` を確認してください。 高ボリュームな `definition`、`find`、`status`、`hotspots`、`references`、`callers`、`callees`、`impact`、`map` の応答は、`--fields`、`--cursor`、対応 command の compact 出力、応答全体に対する `--max-json-bytes` により opt-in の bounded envelope も利用できます。metadata は返却 / 総 / 省略件数と opaque な `next_cursor` を返します。次ページでは同じ query、filter、sort 引数とともにその cursor を再利用してください。応答は 10,000 row の safety window も公開し、上限到達時には利用不能な cursor を返さず、window の消費完了を報告します。既存の compact location 応答はトップレベル key と軽量な `file` / `line` row を維持したまま共通 metadata を追加し、`refs` / `stats` alias と対応する read-only `batch` 子 command にも同じ envelope と hard cap を適用します。`hotspots` と `impact` は active な主要 collection をページングし、`callers.path,callers.depth` のような dotted projection は nested row とその collection の総件数を返します。`map --sections` は section 全体を選びますが、`--fields top_files.path` のような bounded projection はその section の row をページングし、無関係な ranking section を構築しません。`definition --body` では `body`、`body_content`、`all` が明示的な body を保持し、body を除外する projection では本文を取得しません。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index e2c7d78a6..cae51ce6f 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -724,6 +724,7 @@ Use the inventory below before adding or moving a test class: - Latest-indexed-HEAD precedence should be asserted for status and analysis result shapes from one seeded repository rather than duplicating identical Git and database setup. - Commits-ahead ancestor and missing-stamp behavior should share the same multi-commit repository; the missing case only requires a fresh result object without `IndexedHeadSha`. - Shared file-URI escaping and LSP round-trip parity should use one path/root case rather than duplicating equivalent percent-encoding setup in separate tests. +- Ad-hoc issue-draft replay coverage should seed one 126-row fixture and compare the original and replayed selection plus metadata in one test; parse the emitted restricted POSIX quoting in-process instead of launching a platform-specific shell. - No-timeout sentinel coverage should exercise zero and infinite budgets in one contract test; both follow the same caller-cancellation path and do not need duplicate scope setup. - Bounded HTTP private-file success and overflow cases should share one temporary project and use distinct child paths instead of allocating and cleaning two standalone system-temp files. - Bounded HTTP memory-safety coverage checks huge declared-length allocation avoidance and whole pooled-buffer clearing in one contract test. @@ -1612,6 +1613,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - latest indexed HEAD の優先順位は1つの seed 済み repo から status / analysis result shape の両方で検証し、同一 Git / DB setup を重複させない。 - commits-ahead の ancestor / missing-stamp behavior は同じ multi-commit repo を共有する。missing case は `IndexedHeadSha` のない新しい result object だけで検証できる。 - shared file-URI escaping と LSP round-trip parity は1つの path / root case で検証し、同等の percent-encoding setup を別 test で重複させない。 +- ad-hoc issue-draft replay coverage は 126 row の fixture を1つだけ seed し、元の selection / metadata と replay 後の値を1つの test で比較してください。platform 固有 shell を起動せず、出力された制限付き POSIX quoting を process 内で parse します。 - no-timeout sentinel coverage は zero / infinite budget を1つの contract test で検証する。どちらも同じ caller-cancellation path に従うため scope setup を重複させない。 - bounded HTTP private-file の success / overflow case は1つの temporary project と別々の child path を共有し、standalone system-temp file を2回確保・cleanup しない。 - bounded HTTP の memory-safety coverage は、巨大な declared length の allocation 回避と pooled buffer 全体の clear を1つの contract test で検証する。 diff --git a/changelog.d/unreleased/4838.fixed.md b/changelog.d/unreleased/4838.fixed.md new file mode 100644 index 000000000..464cf7342 --- /dev/null +++ b/changelog.d/unreleased/4838.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4838 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Ad-hoc issue drafts now preserve truncation metadata and replay selectors (#4838)** — `search --format issue-drafts` reports the complete filtered source population, returned and omitted counts, applied per-query/total limits and row selectors, while shell-safe replay commands retain every result-changing option. + +## 日本語 + +- **ad-hoc issue draft が truncation metadata と replay selector を維持するようになりました (#4838)** — `search --format issue-drafts` は filter 済み source 母集団全体、返却 / 省略件数、適用した query ごと / 全体 limit と row selector を報告し、shell-safe な replay command は結果を変えるすべての option を保持します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index d2c81d354..df3577f49 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -1291,23 +1291,13 @@ private static int RunSearchIssueDrafts( return WithDb(options, jsonOptions, reader => { - var results = reader.Search( - options.Query!, - options.Limit, - options.Lang, - options.RawFts, - options.PathPatterns, - options.ExcludePaths, - options.ExcludeTests, - !options.NoDedup, - options.Since, - exact, - options.Prefix, - !options.NoVisibilityRank, - guardFilters: options.GuardFilters, - guardWindow: options.GuardWindow, - guardScope: options.GuardScope); - var rows = BuildSearchDisplayRows(results, options, exact); + var results = ReadSearchResults(reader, options, exact, int.MaxValue); + var sourceRows = BuildSearchDisplayRows(results, options, exact); + var resultLimit = GetAdHocIssueDraftResultLimit(options); + var outputSelection = ApplySearchOutputSelection(sourceRows, options, resultLimit); + var rows = outputSelection.Rows; + var omittedCount = Math.Max(0, outputSelection.OriginalCount - rows.Count); + var selectionReason = GetSearchRecipeSelectionReason(outputSelection); var queryResult = new SearchRecipeQueryResultJsonResult( "ad-hoc", options.Query!, @@ -1330,14 +1320,14 @@ private static int RunSearchIssueDrafts( null, rows.Count, rows.Count, - rows.Count, - 0, - null, - null, - options.Limit, - 0, + outputSelection.OriginalCount, + omittedCount, + selectionReason, + selectionReason != null ? outputSelection.SelectionOmittedCount : null, + resultLimit, + omittedCount, BuildSearchRecipeTopFiles(rows), - false, + outputSelection.Truncated, null, rows.Select(row => row.Compact).ToList()); var drafts = rows.Count == 0 @@ -1371,6 +1361,11 @@ private static int RunSearchIssueDrafts( }); } + private static int GetAdHocIssueDraftResultLimit(QueryCommandOptions options) + => options.TotalLimit.HasValue + ? Math.Min(options.Limit, options.TotalLimit.Value) + : options.Limit; + private static List CollectSearchRecipeQueryResults( DbReader reader, IReadOnlyList recipeQueries, @@ -2382,11 +2377,17 @@ private static SearchIssueDraftJsonResult ToAdHocSearchIssueDraft( queryResult.Count, queryResult.ResultLimit, queryResult.OmittedCount, - null, - null, + queryResult.SelectionReason, + queryResult.SelectionOmittedCount, queryResult.MinimumOmittedResultCount, queryResult.Truncated, - queryResult.NextCursor), + queryResult.NextCursor, + queryResult.MinimumMatchedCount, + queryResult.Count, + options.Limit, + options.TotalLimit, + options.FirstPerFile, + options.SampleSize), new SuggestionIssueDraftDuplicatePreflightJsonResult( preflight.Checked, duplicateMatches.Count, @@ -2714,6 +2715,10 @@ private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, Q if (options.DbPathExplicit) AddReplayValueOption(args, "--db", options.DbPath); + 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); foreach (var pathPattern in options.PathPatterns) @@ -2741,9 +2746,25 @@ private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, Q 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) + AddReplayValueOption(args, "--exclude-origin", origin); + foreach (var kind in options.ResultKinds) + AddReplayValueOption(args, "--result-kind", kind); + if (options.TotalLimit.HasValue) + AddReplayValueOption(args, "--total-limit", options.TotalLimit.Value.ToString(CultureInfo.InvariantCulture)); 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)) @@ -3041,19 +3062,34 @@ private static string BuildAdHocSearchIssueDraftBody( sb.AppendLine("## Search metadata"); sb.AppendLine("- draft_id: `search/ad-hoc`"); sb.AppendLine($"- result_count: `{queryResult.Count}`"); + sb.AppendLine($"- source_total_count: `{queryResult.MinimumMatchedCount}`"); + sb.AppendLine($"- returned_count: `{queryResult.Count}`"); + sb.AppendLine($"- limit_per_query: `{options.Limit}`"); + sb.AppendLine($"- total_limit: `{FormatNullableIssueDraftSelectionValue(options.TotalLimit)}`"); + sb.AppendLine($"- first_per_file: `{options.FirstPerFile.ToString().ToLowerInvariant()}`"); + sb.AppendLine($"- sample: `{FormatNullableIssueDraftSelectionValue(options.SampleSize)}`"); sb.AppendLine($"- result_limit: `{queryResult.ResultLimit}`"); sb.AppendLine($"- omitted_count: `{queryResult.OmittedCount}`"); + if (!string.IsNullOrWhiteSpace(queryResult.SelectionReason)) + { + sb.AppendLine($"- selection_reason: `{queryResult.SelectionReason}`"); + sb.AppendLine($"- selection_omitted_count: `{queryResult.SelectionOmittedCount.GetValueOrDefault()}`"); + } sb.AppendLine($"- minimum_omitted_result_count: `{queryResult.MinimumOmittedResultCount}`"); sb.AppendLine($"- exact_substring: `{queryResult.ExactSubstring.ToString().ToLowerInvariant()}`"); return sb.ToString().TrimEnd(); } + private static string FormatNullableIssueDraftSelectionValue(int? value) + => value?.ToString(CultureInfo.InvariantCulture) ?? "null"; + private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOptions options) { var args = new List { "cdidx", "search", + "--query", options.Query!, "--format", OutputFormatIssueDrafts, @@ -3063,6 +3099,8 @@ private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOption if (options.DbPathExplicit) AddReplayValueOption(args, "--db", options.DbPath); + if (options.SourceOnly) + args.Add("--source-only"); if (!string.IsNullOrWhiteSpace(options.Lang)) AddReplayValueOption(args, "--lang", options.Lang); foreach (var pathPattern in options.PathPatterns) @@ -3077,19 +3115,42 @@ private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOption args.Add("--no-dedup"); if (options.NoVisibilityRank) args.Add("--no-visibility-rank"); + if (options.RawFts) + args.Add("--fts"); if (options.Exact) args.Add("--exact"); if (options.ExactSubstring) args.Add("--exact-substring"); + if (options.TokenBoundary) + args.Add("--token-boundary"); + if (options.Prefix) + args.Add("--prefix"); 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) + AddReplayValueOption(args, "--exclude-origin", origin); + foreach (var kind in options.ResultKinds) + AddReplayValueOption(args, "--result-kind", kind); + if (options.TotalLimit.HasValue) + AddReplayValueOption(args, "--total-limit", options.TotalLimit.Value.ToString(CultureInfo.InvariantCulture)); + AddSearchRecipeRowSelectionReplayOptions(args, options); 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)) diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index e3b914b54..dc68683cf 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -4601,7 +4601,19 @@ internal sealed record SearchIssueDraftSourceJsonResult( [property: JsonPropertyName("minimum_omitted_result_count")] int MinimumOmittedResultCount, [property: JsonPropertyName("truncated")] bool Truncated, [property: JsonPropertyName("next_cursor")] - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? NextCursor); + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? NextCursor, + [property: JsonPropertyName("source_total_count")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? SourceTotalCount = null, + [property: JsonPropertyName("returned_count")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? ReturnedCount = null, + [property: JsonPropertyName("limit_per_query")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? LimitPerQuery = null, + [property: JsonPropertyName("total_limit")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? TotalLimit = null, + [property: JsonPropertyName("first_per_file")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] bool? FirstPerFile = null, + [property: JsonPropertyName("sample")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? Sample = null); internal sealed record SearchRecipeScopeJsonResult( [property: JsonPropertyName("name")] string Name, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 7f1ba29f7..600acc108 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Text; using System.Text.RegularExpressions; using System.Text.Json; using CodeIndex.Cli; @@ -7949,6 +7950,101 @@ public void Run() } } + [Fact] + public void RunSearch_AdHocIssueDraftsPreserveSourceTotalsSelectorsAndReplay_Issue4838() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_issue_draft_replay_4838"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var i = 0; i < 126; i++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"src/replay's/match{i:D3}.cs", + "csharp", + $"public sealed class Issue4838Needle{i:D3} {{ }}\n"); + } + + var args = new[] + { + "--query", "Issue4838Needle", + "--db", dbPath, + "--format", "issue-drafts", + "--exact-substring", + "--path", "src/replay's/**", + "--exclude-path", "src/replay's/ignored/**", + "--exclude-tests", + "--no-dedup", + "--no-visibility-rank", + "--first-per-file", + "--sample", "3", + "--limit", "20", + "--total-limit", "1", + "--snippet-lines", "0", + "--snippet-focus", "leftmost", + "--max-line-width", "80", + "--issue-title", "Owner's replay probe", + "--issue-label", "bug", + "--max-json-bytes", "200000" + }; + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(args, _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var root = document.RootElement; + var draft = Assert.Single(root.GetProperty("drafts").EnumerateArray()); + var source = draft.GetProperty("source"); + var body = draft.GetProperty("body").GetString()!; + var replayCommand = ExtractIssueDraftReplayCommand(body); + var replayWords = ParseReplayShellCommand(replayCommand); + + Assert.Equal(1, root.GetProperty("result_count").GetInt32()); + Assert.Equal(126, source.GetProperty("source_total_count").GetInt32()); + Assert.Equal(1, source.GetProperty("returned_count").GetInt32()); + Assert.Equal(20, source.GetProperty("limit_per_query").GetInt32()); + Assert.Equal(1, source.GetProperty("total_limit").GetInt32()); + Assert.True(source.GetProperty("first_per_file").GetBoolean()); + Assert.Equal(3, source.GetProperty("sample").GetInt32()); + Assert.Equal(1, source.GetProperty("result_limit").GetInt32()); + Assert.Equal(125, source.GetProperty("omitted_count").GetInt32()); + Assert.Equal(125, source.GetProperty("minimum_omitted_result_count").GetInt32()); + Assert.Equal("sample", source.GetProperty("selection_reason").GetString()); + Assert.Equal(123, source.GetProperty("selection_omitted_count").GetInt32()); + Assert.True(source.GetProperty("truncated").GetBoolean()); + Assert.Contains("- source_total_count: `126`", body, StringComparison.Ordinal); + Assert.Contains("- returned_count: `1`", body, StringComparison.Ordinal); + Assert.Contains("- total_limit: `1`", body, StringComparison.Ordinal); + Assert.Contains("- sample: `3`", body, StringComparison.Ordinal); + Assert.Equal("cdidx", replayWords[0]); + Assert.Equal("search", replayWords[1]); + Assert.Contains("--query", replayWords); + Assert.Contains("--total-limit", replayWords); + Assert.Contains("--first-per-file", replayWords); + Assert.Contains("--sample", replayWords); + Assert.Contains("src/replay's/**", replayWords); + Assert.Contains("Owner's replay probe", replayWords); + + var (replayExitCode, replayStdout, replayStderr) = CaptureConsole(() => + QueryCommandRunner.RunSearch(replayWords.Skip(2).ToArray(), _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, replayExitCode); + Assert.Equal(string.Empty, replayStderr); + using var replayDocument = ParseJsonOutput(replayStdout); + var replayDraft = Assert.Single(replayDocument.RootElement.GetProperty("drafts").EnumerateArray()); + Assert.Equal(source.GetRawText(), replayDraft.GetProperty("source").GetRawText()); + Assert.Equal( + draft.GetProperty("evidence_paths").GetRawText(), + replayDraft.GetProperty("evidence_paths").GetRawText()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_AdHocIssueDraftDuplicateThresholdFiltersMatches_Issue3827() { @@ -12856,6 +12952,63 @@ private static string QuoteReplayShellArgForAssertion(string arg) return "'" + arg.Replace("'", "'\\''", StringComparison.Ordinal) + "'"; } + private static string ExtractIssueDraftReplayCommand(string body) + { + const string startMarker = "## Replay command\n```sh\n"; + const string endMarker = "\n```"; + var start = body.IndexOf(startMarker, StringComparison.Ordinal); + Assert.True(start >= 0); + start += startMarker.Length; + var end = body.IndexOf(endMarker, start, StringComparison.Ordinal); + Assert.True(end > start); + return body[start..end]; + } + + private static List ParseReplayShellCommand(string command) + { + var words = new List(); + var current = new StringBuilder(); + var inSingleQuotes = false; + var tokenStarted = false; + + for (var i = 0; i < command.Length; i++) + { + var ch = command[i]; + if (!inSingleQuotes && char.IsWhiteSpace(ch)) + { + if (tokenStarted) + { + words.Add(current.ToString()); + current.Clear(); + tokenStarted = false; + } + continue; + } + + if (ch == '\'') + { + inSingleQuotes = !inSingleQuotes; + tokenStarted = true; + continue; + } + + if (!inSingleQuotes && ch == '\\' && i + 1 < command.Length) + { + current.Append(command[++i]); + tokenStarted = true; + continue; + } + + current.Append(ch); + tokenStarted = true; + } + + Assert.False(inSingleQuotes); + if (tokenStarted) + words.Add(current.ToString()); + return words; + } + private static string BuildOverlappingChunkContent(int startLine, int endLine, string targetLine) { return string.Join('\n', Enumerable.Range(startLine, (endLine - startLine) + 1) From 3da55a185a94cc22e348bf79a29c4db13c2feab5 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 18:54:36 +0900 Subject: [PATCH 2/4] Preserve generated search replay selection (#4838) --- src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs | 6 ++++++ tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs | 5 ++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index df3577f49..683f2d45f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -940,6 +940,8 @@ private static void AddSearchRecipeCompactReplayOptions(List args, Query AddReplayValueOption(args, "--exclude-path", excludePath); if (options.ExcludeTests) args.Add("--exclude-tests"); + if (options.IncludeGenerated) + args.Add("--include-generated"); if (options.Since.HasValue) AddReplayValueOption(args, "--since", options.Since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); if (options.NoDedup) @@ -2727,6 +2729,8 @@ private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, Q AddReplayValueOption(args, "--exclude-path", excludePath); if (options.ExcludeTests) args.Add("--exclude-tests"); + if (options.IncludeGenerated) + args.Add("--include-generated"); if (options.Since.HasValue) AddReplayValueOption(args, "--since", options.Since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); if (options.NoDedup) @@ -3109,6 +3113,8 @@ private static string BuildAdHocSearchIssueDraftReplayCommand(QueryCommandOption AddReplayValueOption(args, "--exclude-path", excludePath); if (options.ExcludeTests) args.Add("--exclude-tests"); + if (options.IncludeGenerated) + args.Add("--include-generated"); if (options.Since.HasValue) AddReplayValueOption(args, "--since", options.Since.Value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); if (options.NoDedup) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 600acc108..e14fb4f5d 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -7963,7 +7963,8 @@ public void RunSearch_AdHocIssueDraftsPreserveSourceTotalsSelectorsAndReplay_Iss dbPath, $"src/replay's/match{i:D3}.cs", "csharp", - $"public sealed class Issue4838Needle{i:D3} {{ }}\n"); + $"public sealed class Issue4838Needle{i:D3} {{ }}\n", + isGenerated: true); } var args = new[] @@ -7975,6 +7976,7 @@ public void RunSearch_AdHocIssueDraftsPreserveSourceTotalsSelectorsAndReplay_Iss "--path", "src/replay's/**", "--exclude-path", "src/replay's/ignored/**", "--exclude-tests", + "--include-generated", "--no-dedup", "--no-visibility-rank", "--first-per-file", @@ -8022,6 +8024,7 @@ public void RunSearch_AdHocIssueDraftsPreserveSourceTotalsSelectorsAndReplay_Iss Assert.Equal("search", replayWords[1]); Assert.Contains("--query", replayWords); Assert.Contains("--total-limit", replayWords); + Assert.Contains("--include-generated", replayWords); Assert.Contains("--first-per-file", replayWords); Assert.Contains("--sample", replayWords); Assert.Contains("src/replay's/**", replayWords); From 5b45ce51be550cae5aab7febdcbd8c3b59174cce Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 20:00:41 +0900 Subject: [PATCH 3/4] Handle guarded issue-draft source bounds (#4838) --- DEVELOPER_GUIDE.md | 4 +- README.md | 4 +- TESTING_GUIDE.md | 4 +- changelog.d/unreleased/4838.fixed.md | 4 +- .../Cli/QueryCommandRunner.SearchRecipes.cs | 64 ++++++++++++--- src/CodeIndex/Cli/SearchAuditRecipes.cs | 8 +- .../QueryCommandRunnerSearchTests.cs | 81 +++++++++++++++++++ 7 files changed, 149 insertions(+), 20 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 3f37b9771..f9a0b94ad 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1533,7 +1533,7 @@ For the AI agent search-rule template, see [AI Integration](USER_GUIDE.md#ai-int | 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 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. Existing `result_count`, `result_limit`, `omitted_count`, and `truncated` fields now 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. | +| 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. | | 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. | @@ -4699,7 +4699,7 @@ AI エージェント向け検索ルールのテンプレートについては | 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 issue-draft selection | `search --format issue-drafts` は filter 済みの ad-hoc 母集団全体を読み、`--first-per-file`、決定的な `--sample`、`min(--limit, --total-limit)` の順に適用します。既存の `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 を維持します。 | +| 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 を分離するために使います。 | | 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/README.md b/README.md index 3e65ed251..602c84fdb 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ The default NDJSON output of `search`, `symbols`, and `files` always ends with a Recipe/audit compact pagination returns an opaque `next_cursor`. Replay it unchanged with the same child query and filters as `--cursor `; a search-score cursor can begin with `-`, and that leading minus is part of the cursor value. -Ad-hoc `search --format issue-drafts` computes `source_total_count`, `returned_count`, `omitted_count`, and `truncated` from the complete filtered population before applying `--first-per-file`, deterministic `--sample`, and the effective `--limit` / `--total-limit`. Its source metadata also records those selector values, and the shell-safe replay command preserves every result-changing selector so rerunning it reproduces the same selected evidence. +Ad-hoc `search --format issue-drafts` computes `source_total_count`, `returned_count`, `omitted_count`, and `truncated` from the complete filtered population before applying `--first-per-file`, deterministic `--sample`, and the effective `--limit` / `--total-limit`. Guarded searches retain their candidate safety cap instead of attempting an unbounded count; they omit `source_total_count` and expose `source_minimum_count`, `source_total_count_authoritative: false`, and `source_fetch_limit`. Source metadata also records selector values, and the shell-safe replay command preserves every result-changing selector so rerunning it reproduces the same selected evidence. When the byte cap omits rows, these commands return partial-result exit code `11`; pass `--allow-partial` to opt into exit code `0` while retaining the same terminal metadata. Ordinary `--limit` truncation remains a successful, explicitly described stream. Array and compact outputs keep their documented whole-response behavior; check `cdidx --help` before relying on partial output. @@ -517,7 +517,7 @@ JSON 形式では `--max-json-bytes` を文書全体の UTF-8 byte cap として recipe / audit の compact pagination は opaque な `next_cursor` を返します。同じ child query と filter を指定し、値を変更せず `--cursor ` として再利用してください。search-score cursor は `-` で始まる場合があり、その先頭のマイナス記号も cursor 値の一部です。 -ad-hoc の `search --format issue-drafts` は、filter 済み母集団全体から `source_total_count`、`returned_count`、`omitted_count`、`truncated` を算出してから、`--first-per-file`、決定的な `--sample`、有効な `--limit` / `--total-limit` を適用します。source metadata はこれらの selector 値も保持し、shell-safe な replay command は結果を変えるすべての selector を維持するため、再実行時に同じ evidence 集合を再現できます。 +ad-hoc の `search --format issue-drafts` は、filter 済み母集団全体から `source_total_count`、`returned_count`、`omitted_count`、`truncated` を算出してから、`--first-per-file`、決定的な `--sample`、有効な `--limit` / `--total-limit` を適用します。guard 付き検索は非上限 count を試みず candidate safety cap を維持し、`source_total_count` を省略して `source_minimum_count`、`source_total_count_authoritative: false`、`source_fetch_limit` を公開します。source metadata は selector 値も保持し、shell-safe な replay command は結果を変えるすべての selector を維持するため、再実行時に同じ evidence 集合を再現できます。 byte cap により行を省略した場合、これらのコマンドは partial-result 終了コード `11` を返します。同じ終端 metadata を維持したまま終了コード `0` を明示的に許容するには `--allow-partial` を指定します。通常の `--limit` による切り詰めは、理由が明示された成功 stream のままです。array / compact 出力は文書化済みの whole-response 挙動を維持します。部分出力へ依存する前に `cdidx --help` を確認してください。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 2ec9061ce..7339e9cf7 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -725,7 +725,7 @@ Use the inventory below before adding or moving a test class: - Latest-indexed-HEAD precedence should be asserted for status and analysis result shapes from one seeded repository rather than duplicating identical Git and database setup. - Commits-ahead ancestor and missing-stamp behavior should share the same multi-commit repository; the missing case only requires a fresh result object without `IndexedHeadSha`. - Shared file-URI escaping and LSP round-trip parity should use one path/root case rather than duplicating equivalent percent-encoding setup in separate tests. -- Ad-hoc issue-draft replay coverage should seed one 126-row fixture and compare the original and replayed selection plus metadata in one test; parse the emitted restricted POSIX quoting in-process instead of launching a platform-specific shell. +- Ordinary ad-hoc issue-draft replay coverage should seed one 126-row fixture and compare the original and replayed selection plus metadata in one test; parse the emitted restricted POSIX quoting in-process instead of launching a platform-specific shell. Keep the broad guarded-search safety regression separate because it crosses the candidate cap and verifies lower-bound source metadata; use one indexed file with sentinel chunks rather than hundreds of files. - No-timeout sentinel coverage should exercise zero and infinite budgets in one contract test; both follow the same caller-cancellation path and do not need duplicate scope setup. - Bounded HTTP private-file success and overflow cases should share one temporary project and use distinct child paths instead of allocating and cleaning two standalone system-temp files. - Bounded HTTP memory-safety coverage checks huge declared-length allocation avoidance and whole pooled-buffer clearing in one contract test. @@ -1615,7 +1615,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - latest indexed HEAD の優先順位は1つの seed 済み repo から status / analysis result shape の両方で検証し、同一 Git / DB setup を重複させない。 - commits-ahead の ancestor / missing-stamp behavior は同じ multi-commit repo を共有する。missing case は `IndexedHeadSha` のない新しい result object だけで検証できる。 - shared file-URI escaping と LSP round-trip parity は1つの path / root case で検証し、同等の percent-encoding setup を別 test で重複させない。 -- ad-hoc issue-draft replay coverage は 126 row の fixture を1つだけ seed し、元の selection / metadata と replay 後の値を1つの test で比較してください。platform 固有 shell を起動せず、出力された制限付き POSIX quoting を process 内で parse します。 +- 通常の ad-hoc issue-draft replay coverage は 126 row の fixture を1つだけ seed し、元の selection / metadata と replay 後の値を1つの test で比較してください。platform 固有 shell を起動せず、出力された制限付き POSIX quoting を process 内で parse します。broad な guard 付き検索の safety regression は candidate cap を越えて source lower-bound metadata を検証するため分離し、数百 file ではなく sentinel chunk を持つ1つの indexed file を使います。 - no-timeout sentinel coverage は zero / infinite budget を1つの contract test で検証する。どちらも同じ caller-cancellation path に従うため scope setup を重複させない。 - bounded HTTP private-file の success / overflow case は1つの temporary project と別々の child path を共有し、standalone system-temp file を2回確保・cleanup しない。 - bounded HTTP の memory-safety coverage は、巨大な declared length の allocation 回避と pooled buffer 全体の clear を1つの contract test で検証する。 diff --git a/changelog.d/unreleased/4838.fixed.md b/changelog.d/unreleased/4838.fixed.md index 464cf7342..7553382b7 100644 --- a/changelog.d/unreleased/4838.fixed.md +++ b/changelog.d/unreleased/4838.fixed.md @@ -13,8 +13,8 @@ affected: ## English -- **Ad-hoc issue drafts now preserve truncation metadata and replay selectors (#4838)** — `search --format issue-drafts` reports the complete filtered source population, returned and omitted counts, applied per-query/total limits and row selectors, while shell-safe replay commands retain every result-changing option. +- **Ad-hoc issue drafts now preserve truncation metadata and replay selectors (#4838)** — `search --format issue-drafts` reports the complete filtered source population when authoritative (or an explicit guarded-search lower bound and fetch cap), returned and omitted counts, applied per-query/total limits and row selectors, while shell-safe replay commands retain every result-changing option. ## 日本語 -- **ad-hoc issue draft が truncation metadata と replay selector を維持するようになりました (#4838)** — `search --format issue-drafts` は filter 済み source 母集団全体、返却 / 省略件数、適用した query ごと / 全体 limit と row selector を報告し、shell-safe な replay command は結果を変えるすべての option を保持します。 +- **ad-hoc issue draft が truncation metadata と replay selector を維持するようになりました (#4838)** — `search --format issue-drafts` は authoritative な場合の filter 済み source 母集団全体(guard 付き検索では明示的な下限値と fetch 上限)、返却 / 省略件数、適用した query ごと / 全体 limit と row selector を報告し、shell-safe な replay command は結果を変えるすべての option を保持します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 683f2d45f..c17a02fe6 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -1293,9 +1293,18 @@ private static int RunSearchIssueDrafts( return WithDb(options, jsonOptions, reader => { - var results = ReadSearchResults(reader, options, exact, int.MaxValue); - var sourceRows = BuildSearchDisplayRows(results, options, exact); var resultLimit = GetAdHocIssueDraftResultLimit(options); + var sourceTotalCountAuthoritative = options.GuardFilters.Count == 0; + var sourceFetchLimit = sourceTotalCountAuthoritative + ? int.MaxValue + : GetSearchRecipeFetchLimit(options, resultLimit); + var results = ReadSearchResults( + reader, + options, + exact, + sourceFetchLimit, + guardRequestedLimit: resultLimit); + var sourceRows = BuildSearchDisplayRows(results, options, exact); var outputSelection = ApplySearchOutputSelection(sourceRows, options, resultLimit); var rows = outputSelection.Rows; var omittedCount = Math.Max(0, outputSelection.OriginalCount - rows.Count); @@ -1329,12 +1338,20 @@ private static int RunSearchIssueDrafts( resultLimit, omittedCount, BuildSearchRecipeTopFiles(rows), - outputSelection.Truncated, + outputSelection.Truncated || !sourceTotalCountAuthoritative, null, rows.Select(row => row.Compact).ToList()); var drafts = rows.Count == 0 ? [] - : new List { ToAdHocSearchIssueDraft(options, queryResult, preflight) }; + : new List + { + ToAdHocSearchIssueDraft( + options, + queryResult, + preflight, + sourceTotalCountAuthoritative, + sourceTotalCountAuthoritative ? null : sourceFetchLimit) + }; var json = JsonSerializer.Serialize( new SearchIssueDraftExportJsonResult( @@ -2336,7 +2353,9 @@ private static SearchIssueDraftJsonResult ToSearchIssueDraft( private static SearchIssueDraftJsonResult ToAdHocSearchIssueDraft( QueryCommandOptions options, SearchRecipeQueryResultJsonResult queryResult, - IssueDuplicatePreflight preflight) + IssueDuplicatePreflight preflight, + bool sourceTotalCountAuthoritative, + int? sourceFetchLimit) { var labels = BuildAdHocIssueDraftLabels(options); var title = BuildAdHocSearchIssueDraftTitle(options); @@ -2350,7 +2369,14 @@ private static SearchIssueDraftJsonResult ToAdHocSearchIssueDraft( var missingLabels = BuildMissingIssueDraftLabels(labels, preflight); var labelWarning = BuildIssueDraftLabelWarning(missingLabels, preflight); var duplicateProbeTriage = BuildSearchIssueDraftTriage(queryResult, preflight.Checked, 0); - var duplicateProbeBody = BuildAdHocSearchIssueDraftBody(queryResult, evidencePaths, evidence, duplicateProbeTriage, options); + var duplicateProbeBody = BuildAdHocSearchIssueDraftBody( + queryResult, + evidencePaths, + evidence, + duplicateProbeTriage, + options, + sourceTotalCountAuthoritative, + sourceFetchLimit); var duplicateMatches = preflight.FindMatches( title, labels, @@ -2367,7 +2393,14 @@ private static SearchIssueDraftJsonResult ToAdHocSearchIssueDraft( evidencePaths, evidence, triage, - BuildAdHocSearchIssueDraftBody(queryResult, evidencePaths, evidence, triage, options), + BuildAdHocSearchIssueDraftBody( + queryResult, + evidencePaths, + evidence, + triage, + options, + sourceTotalCountAuthoritative, + sourceFetchLimit), new SearchIssueDraftSourceJsonResult( null, null, @@ -2384,12 +2417,15 @@ private static SearchIssueDraftJsonResult ToAdHocSearchIssueDraft( queryResult.MinimumOmittedResultCount, queryResult.Truncated, queryResult.NextCursor, - queryResult.MinimumMatchedCount, + sourceTotalCountAuthoritative ? queryResult.MinimumMatchedCount : null, queryResult.Count, options.Limit, options.TotalLimit, options.FirstPerFile, - options.SampleSize), + options.SampleSize, + queryResult.MinimumMatchedCount, + sourceTotalCountAuthoritative, + sourceFetchLimit), new SuggestionIssueDraftDuplicatePreflightJsonResult( preflight.Checked, duplicateMatches.Count, @@ -3029,7 +3065,9 @@ private static string BuildAdHocSearchIssueDraftBody( IReadOnlyList evidencePaths, IReadOnlyList evidence, IssueDraftTriageMetadataJsonResult triage, - QueryCommandOptions options) + QueryCommandOptions options, + bool sourceTotalCountAuthoritative, + int? sourceFetchLimit) { var sb = new StringBuilder(); sb.AppendLine("## Summary"); @@ -3066,7 +3104,11 @@ private static string BuildAdHocSearchIssueDraftBody( sb.AppendLine("## Search metadata"); sb.AppendLine("- draft_id: `search/ad-hoc`"); sb.AppendLine($"- result_count: `{queryResult.Count}`"); - sb.AppendLine($"- source_total_count: `{queryResult.MinimumMatchedCount}`"); + sb.AppendLine($"- source_total_count: `{(sourceTotalCountAuthoritative ? queryResult.MinimumMatchedCount.ToString(CultureInfo.InvariantCulture) : "unknown")}`"); + sb.AppendLine($"- source_minimum_count: `{queryResult.MinimumMatchedCount}`"); + sb.AppendLine($"- source_total_count_authoritative: `{sourceTotalCountAuthoritative.ToString().ToLowerInvariant()}`"); + if (sourceFetchLimit.HasValue) + sb.AppendLine($"- source_fetch_limit: `{sourceFetchLimit.Value.ToString(CultureInfo.InvariantCulture)}`"); sb.AppendLine($"- returned_count: `{queryResult.Count}`"); sb.AppendLine($"- limit_per_query: `{options.Limit}`"); sb.AppendLine($"- total_limit: `{FormatNullableIssueDraftSelectionValue(options.TotalLimit)}`"); diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index dc68683cf..0188e75e7 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -4613,7 +4613,13 @@ internal sealed record SearchIssueDraftSourceJsonResult( [property: JsonPropertyName("first_per_file")] [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] bool? FirstPerFile = null, [property: JsonPropertyName("sample")] - [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? Sample = null); + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? Sample = null, + [property: JsonPropertyName("source_minimum_count")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? SourceMinimumCount = null, + [property: JsonPropertyName("source_total_count_authoritative")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] bool? SourceTotalCountAuthoritative = null, + [property: JsonPropertyName("source_fetch_limit")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? SourceFetchLimit = null); internal sealed record SearchRecipeScopeJsonResult( [property: JsonPropertyName("name")] string Name, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index e14fb4f5d..914891af5 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -8005,6 +8005,9 @@ public void RunSearch_AdHocIssueDraftsPreserveSourceTotalsSelectorsAndReplay_Iss Assert.Equal(1, root.GetProperty("result_count").GetInt32()); Assert.Equal(126, source.GetProperty("source_total_count").GetInt32()); + Assert.Equal(126, source.GetProperty("source_minimum_count").GetInt32()); + Assert.True(source.GetProperty("source_total_count_authoritative").GetBoolean()); + Assert.False(source.TryGetProperty("source_fetch_limit", out _)); Assert.Equal(1, source.GetProperty("returned_count").GetInt32()); Assert.Equal(20, source.GetProperty("limit_per_query").GetInt32()); Assert.Equal(1, source.GetProperty("total_limit").GetInt32()); @@ -8048,6 +8051,84 @@ public void RunSearch_AdHocIssueDraftsPreserveSourceTotalsSelectorsAndReplay_Iss } } + [Fact] + public void RunSearch_AdHocIssueDraftGuardsUseBoundedSourcePopulation_Issue4838() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_issue_draft_guard_4838"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/guarded.cs", + "csharp", + "Issue4838GuardNeedle return;\n"); + var chunks = Enumerable.Range(0, 1001) + .Select(index => new ChunkRecord + { + ChunkIndex = index, + StartLine = (index * 2) + 1, + EndLine = index == 0 ? 2 : (index * 2) + 1, + Content = index == 0 + ? "Issue4838GuardNeedle\nreturn;\n" + : $"Issue4838GuardNeedle candidate {index};\n" + }) + .ToArray(); + ReplaceIndexedChunks(dbPath, "src/guarded.cs", chunks); + + var args = new[] + { + "--query", "Issue4838GuardNeedle", + "--db", dbPath, + "--format", "issue-drafts", + "--exact-substring", + "--require-after", "return", + "--guard-window", "1", + "--limit", "1", + "--snippet-lines", "0", + "--max-json-bytes", "200000" + }; + + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(args, _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var draft = Assert.Single(document.RootElement.GetProperty("drafts").EnumerateArray()); + var source = draft.GetProperty("source"); + var body = draft.GetProperty("body").GetString()!; + var replayWords = ParseReplayShellCommand(ExtractIssueDraftReplayCommand(body)); + + Assert.False(source.TryGetProperty("source_total_count", out _)); + Assert.Equal(1, source.GetProperty("source_minimum_count").GetInt32()); + Assert.False(source.GetProperty("source_total_count_authoritative").GetBoolean()); + Assert.Equal(200, source.GetProperty("source_fetch_limit").GetInt32()); + Assert.Equal(1, source.GetProperty("returned_count").GetInt32()); + Assert.Equal(0, source.GetProperty("omitted_count").GetInt32()); + Assert.True(source.GetProperty("truncated").GetBoolean()); + Assert.Contains("- source_total_count: `unknown`", body, StringComparison.Ordinal); + Assert.Contains("- source_minimum_count: `1`", body, StringComparison.Ordinal); + Assert.Contains("--require-after", replayWords); + Assert.Contains("return", replayWords); + + var (replayExitCode, replayStdout, replayStderr) = CaptureConsole(() => + QueryCommandRunner.RunSearch(replayWords.Skip(2).ToArray(), _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, replayExitCode); + Assert.Equal(string.Empty, replayStderr); + using var replayDocument = ParseJsonOutput(replayStdout); + var replayDraft = Assert.Single(replayDocument.RootElement.GetProperty("drafts").EnumerateArray()); + Assert.Equal(source.GetRawText(), replayDraft.GetProperty("source").GetRawText()); + Assert.Equal( + draft.GetProperty("evidence_paths").GetRawText(), + replayDraft.GetProperty("evidence_paths").GetRawText()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_AdHocIssueDraftDuplicateThresholdFiltersMatches_Issue3827() { From e392d9eff645af55ce3336d84bd1bf349d1d5f22 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 27 Jul 2026 20:45:02 +0900 Subject: [PATCH 4/4] Make issue-draft replay tests line-ending independent (#4838) --- tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 914891af5..a8a0dcfb3 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -8000,7 +8000,7 @@ public void RunSearch_AdHocIssueDraftsPreserveSourceTotalsSelectorsAndReplay_Iss var draft = Assert.Single(root.GetProperty("drafts").EnumerateArray()); var source = draft.GetProperty("source"); var body = draft.GetProperty("body").GetString()!; - var replayCommand = ExtractIssueDraftReplayCommand(body); + var replayCommand = ExtractIssueDraftReplayCommand(body.ReplaceLineEndings("\r\n")); var replayWords = ParseReplayShellCommand(replayCommand); Assert.Equal(1, root.GetProperty("result_count").GetInt32()); @@ -13040,12 +13040,13 @@ private static string ExtractIssueDraftReplayCommand(string body) { const string startMarker = "## Replay command\n```sh\n"; const string endMarker = "\n```"; - var start = body.IndexOf(startMarker, StringComparison.Ordinal); + var normalizedBody = body.ReplaceLineEndings("\n"); + var start = normalizedBody.IndexOf(startMarker, StringComparison.Ordinal); Assert.True(start >= 0); start += startMarker.Length; - var end = body.IndexOf(endMarker, start, StringComparison.Ordinal); + var end = normalizedBody.IndexOf(endMarker, start, StringComparison.Ordinal); Assert.True(end > start); - return body[start..end]; + return normalizedBody[start..end]; } private static List ParseReplayShellCommand(string command)