From 58e64d7ae12ff263c6bd3e8c451cb89cadc748da Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 10 Aug 2026 15:53:12 +0900 Subject: [PATCH 1/3] Fix compact issue-draft summary budgeting (#5058) --- DEVELOPER_GUIDE.md | 2 + USER_GUIDE.md | 32 ++-- changelog.d/unreleased/5058.fixed.md | 20 +++ src/CodeIndex/Cli/JsonOutputContracts.cs | 2 + .../Cli/QueryCommandRunner.SearchRecipes.cs | 159 +++++++++++++++++- src/CodeIndex/Cli/SearchAuditRecipes.cs | 43 +++++ .../QueryCommandRunnerSearchTests.cs | 156 ++++++++++++++++- 7 files changed, 392 insertions(+), 22 deletions(-) create mode 100644 changelog.d/unreleased/5058.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index fbd519329..ee64513a1 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1972,6 +1972,7 @@ access. | Human-readable default | Query commands (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `excerpt`, `map`, `inspect`, `outline`, `suggestions`) default to **human-readable output**. | | `--json` | Emits JSON lines output, one JSON object per line, designed for easy parsing by AI agents. | | Delegated audit command identity | `audit` delegates recipe execution to `search` internally while retaining the public `audit` identity in human usage, recovery hints, and generated replay commands. Explicit `audit --json` usage errors emit stable versioned command-error objects with `command: "audit"` and no human-readable `usage`; direct `search` errors retain `search` identity. | +| Recipe issue-draft summary | `search --recipe ... --format issue-drafts --summary-only` and its `audit` alias use a dedicated summary DTO instead of rendering full issue bodies. Each positive recipe query contributes one compact `drafts[]` row with query/title identity, result and file counts, at most five counted evidence paths plus explicit path omission metadata, labels, severity, confidence, optional result cursor, and a full-detail replay command. The root reports `total_count`, its authority/lower bound, `returned_count`, `omitted_count`, `truncated`, and an uncapped summary `recovery_command`. `--max-json-bytes` measures the exact UTF-8 document plus its final newline and removes only complete trailing summary rows; a cap below the zero-row envelope returns typed `E028_RESPONSE_BUDGET_TOO_SMALL` guidance under the invoked `search` or `audit` identity. Full issue-draft mode retains the established recipe metadata, evidence, source, triage, and rendered body shape. | | `definition --json` miss | A default-format definition lookup that finds no matching symbol emits the shared versioned `E018_QUERY_NOT_FOUND` command-error object and exits `2`, with or without `--body`; it never succeeds with empty stdout. Bounded-envelope controls move the object to `metadata.error` and keep `results` empty instead of projecting it as a location row. The object is preflighted against `--max-json-bytes`; an impossible cap returns a usage error without oversized stdout. `--count` still returns its structured zero-count object, and explicit location formats retain their existing format-specific empty-result output. | | Raw discovery JSON shape | `symbols` and `files` build each result row through the same DTO path for array, NDJSON, and envelope output. `symbols --json=array` therefore preserves `exact_index_available` just like NDJSON. Every cardinality and `--max-json-bytes` path keeps the selected flat shape: zero-result NDJSON is an empty stream, `--json=array` is always an array, and byte-capped output omits whole trailing rows without changing the top-level type. Bounded projections keep rows in `results`, pagination facts in `metadata`, and exact-query readiness in `metadata.response_context`; they never reuse a result row as response context. Use `--format compact` or `--json-envelope` when truncation and freshness metadata must accompany the results. | | Generated-code filtering metadata | DB-backed discovery `query_context` always reports `include_generated`, `generated_code_policy`, and `generated_file_filter_available`. The `files --count --json` and every JSON `map` summary (including `issue-drafts`) also report `generated_file_count_excluded` and `generated_file_count_excluded_authoritative`. The excluded count is `0` when generated files are included. For a legacy DB without `files.generated` when filtering is requested, the policy is `unavailable`, the count is `null`, and the authoritative/available flags are `false` rather than claiming that an unavailable filter ran; explicit `--include-generated` remains `include` with an authoritative excluded count of `0`. Byte-capped and uncapped raw discovery arrays retain SQLite trust diagnostics even when the query returns no result rows. | @@ -5669,6 +5670,7 @@ help はすべてこのレジストリを参照します。field 名は大文字 | human-readable default | query command(`search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`excerpt`、`map`、`inspect`、`outline`、`suggestions`)は既定で**人間向け出力**です。 | | `--json` | JSON lines output(1 行 1 JSON object)に切り替えます。AI agent が容易に parse できるよう設計されています。 | | 委譲された audit command identity | `audit` は内部で recipe 実行を `search` へ委譲しますが、人間向け usage、復旧 hint、生成する replay command では公開された `audit` identity を維持します。明示的な `audit --json` の usage error は `command: "audit"` を持つ安定した version 付き command-error object を出力し、人間向けの `usage` を含めません。直接の `search` error は `search` identity を維持します。 | +| recipe issue-draft summary | `search --recipe ... --format issue-drafts --summary-only` と `audit` alias は、完全な issue body を描画せず専用 summary DTO を使います。結果がある各 recipe query は、query / title identity、result / file 件数、最大 5 件の count 付き evidence path と明示的な path 省略 metadata、label、severity、confidence、任意の result cursor、full-detail replay command を持つ compact な `drafts[]` row を 1 件生成します。root は `total_count` とその authority / lower bound、`returned_count`、`omitted_count`、`truncated`、上限なし summary 用の `recovery_command` を返します。`--max-json-bytes` は最後の改行を含む正確な UTF-8 document を計測し、末尾の完全な summary row だけを省略します。0 row envelope も収まらない上限では、呼び出された `search` または `audit` identity の型付き `E028_RESPONSE_BUDGET_TOO_SMALL` guidance を返します。full issue-draft mode は既存の recipe metadata、evidence、source、triage、描画済み body shape を維持します。 | | `definition --json` の未検出 | 既定 format の definition lookup で一致する symbol がない場合、`--body` の有無にかかわらず、共通の versioned `E018_QUERY_NOT_FOUND` command-error object を出力して終了コード `2` を返します。空の stdout のまま成功することはありません。bounded-envelope control の使用時は object を location row として projection せず `metadata.error` に移し、`results` は空のままにします。この object は `--max-json-bytes` に対して事前検査され、収まらない上限では oversized stdout を出さず usage error を返します。`--count` は引き続き構造化された 0 件 object を返し、明示的な location format も既存の format 固有の empty-result output を維持します。 | | raw discovery JSON shape | `symbols` と `files` は、array、NDJSON、envelope の各出力で同じ DTO 経路から result row を構築します。そのため `symbols --json=array` も NDJSON と同様に `exact_index_available` を保持します。結果件数や `--max-json-bytes` の有無にかかわらず選択した flat shape を維持し、0 件の NDJSON は空 stream、`--json=array` は常に array となり、byte cap 到達時は top-level type を変えずに末尾の完全な row を省略します。bounded projection は row を `results`、pagination fact を `metadata`、exact-query readiness を `metadata.response_context` に保持し、result row を response context として再利用しません。truncation / freshness metadata も結果と一緒に必要な場合は `--format compact` または `--json-envelope` を使用します。 | | generated-code filtering metadata | DB-backed discovery の `query_context` は常に `include_generated`、`generated_code_policy`、`generated_file_filter_available` を返します。`files --count --json` と `issue-drafts` を含むすべての JSON `map` summary は、`generated_file_count_excluded` と `generated_file_count_excluded_authoritative` も返します。generated file を含める場合、除外数は `0` です。`files.generated` が無い legacy DB で filter が要求された場合、未実行の filter を実行済みと誤認させないよう、policy は `unavailable`、count は `null`、authoritative / available flag は `false` になります。明示的な `--include-generated` は `include` のままで、authoritative な除外数 `0` を返します。byte cap の有無にかかわらず、raw discovery array は query result row が 0 件でも SQLite trust diagnostics を維持します。 | diff --git a/USER_GUIDE.md b/USER_GUIDE.md index aeaa8b022..c9d679f86 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1674,12 +1674,18 @@ tune duplicate preflight strictness; the JSON summary reports `confidence` and `minimum_score`. Draft bodies include evidence paths, representative source snippets, omitted-result metadata, and recipe metadata. Add `--summary-only` to recipe issue-draft export when agents only need compact top-level metadata: -the output keeps issue-ready draft evidence and per-draft source metadata, omits -the full top-level `recipe` metadata, emits `recipe_summary`, and includes -`query_freshness` for zero-result child queries. Add `--snippet-lines 0` for -path/line-only evidence and combine issue-draft export with `--max-json-bytes ` -when an automation budget must fail closed. These drafts are triage aids; review -duplicate guidance and current open issues before filing. +the output uses a dedicated summary contract with one compact row per positive +query instead of embedding full issue bodies, source rows, or repeated recipe +metadata. Each row reports counts, severity/confidence, labels, at most five +evidence paths with explicit omission counts, and a full-detail replay command. +The root reports total/returned/omitted row counts, whether the total is +authoritative, `query_freshness`, and an uncapped `recovery_command`. Combine it +with `--max-json-bytes ` to measure the complete UTF-8 document (including +its final newline) and keep only whole rows that fit. If even the zero-row +envelope cannot fit, the command fails closed with `E028` and preserves the +invoked `search` or `audit` command in its retry guidance. Without +`--summary-only`, the full issue-draft contract remains unchanged. These drafts +are triage aids; review duplicate guidance and current open issues before filing. ### Debugging queries @@ -5139,11 +5145,15 @@ duplicate-preflight metadata を持つ issue draft object を出力します。 JSON summary には `confidence` と `minimum_score` が出力されます。draft body は evidence path、 代表的な source snippet、omitted-result metadata、recipe metadata を含みます。エージェントが compact な top-level metadata だけを必要とする場合は、recipe issue-draft export に `--summary-only` を追加します。 -この出力は Issue 作成に必要な draft evidence と draft ごとの source metadata を保ち、top-level の -完全な `recipe` metadata を省略し、`recipe_summary` と zero-result child query 用の `query_freshness` を出力します。 -path / line だけの evidence にしたい場合は `--snippet-lines 0` を追加し、automation budget を超える出力を閉じたい場合は -issue-draft export に `--max-json-bytes ` を併用します。これらの draft は triage aid なので、起票前に -duplicate guidance と現在の open issue を確認してください。 +この出力は専用の summary contract を使い、完全な Issue body、source row、query ごとに重複する +recipe metadata を埋め込まず、結果がある query ごとに compact な row を1件出力します。各 row は +count、severity / confidence、label、最大5件の evidence path と明示的な省略件数、完全な詳細を取得する +replay command を返します。root は total / returned / omitted row count、total が authoritative か、 +`query_freshness`、上限なしの `recovery_command` を返します。`--max-json-bytes ` を併用すると、末尾の +改行を含む UTF-8 document 全体を計測し、上限に収まる完全な row だけを保持します。row 0件の envelope +さえ収まらない場合は `E028` で fail-closed にし、retry guidance でも実行元の `search` または `audit` +command を維持します。`--summary-only` を付けない完全版 issue-draft contract は変更しません。これらの +draft は triage aid なので、起票前に duplicate guidance と現在の open issue を確認してください。 ### クエリのデバッグ diff --git a/changelog.d/unreleased/5058.fixed.md b/changelog.d/unreleased/5058.fixed.md new file mode 100644 index 000000000..2f466d2b3 --- /dev/null +++ b/changelog.d/unreleased/5058.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 5058 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md + - DEVELOPER_GUIDE.md +--- + +## English + +- **Recipe issue-draft summaries now fit bounded automation payloads (#5058; follow-up to #4064, #4118, and #4308)** — `--summary-only` now uses a dedicated compact contract with one whole row per positive query, bounded evidence paths, explicit omission and total-authority metadata, full-detail replay commands, and an uncapped recovery command. `--max-json-bytes` measures the complete UTF-8 document including its final newline, truncates only at row boundaries, and preserves the invoked `search` or `audit` identity in `E028` guidance; full issue-draft output remains unchanged. + +## 日本語 + +- **recipe issue-draft summary が automation の上限付き payload に収まるよう修正しました (#5058、#4064、#4118、#4308 の follow-up)** — `--summary-only` は、結果がある query ごとの完全な row、上限付き evidence path、明示的な省略件数と total の authority metadata、完全な詳細を取得する replay command、上限なしの recovery command を持つ専用 compact contract を使います。`--max-json-bytes` は末尾改行を含む UTF-8 document 全体を計測し、row 境界だけで truncate し、`E028` guidance でも実行元の `search` または `audit` identity を維持します。完全版 issue-draft output は変更しません。 diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index f76348c03..0d1f3365e 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -1339,6 +1339,8 @@ internal sealed record ValidateConfigJsonResult( [JsonSerializable(typeof(SearchRecipeScopeJsonResult))] [JsonSerializable(typeof(SearchRecipeExcludedDiagnosticJsonResult))] [JsonSerializable(typeof(SearchIssueDraftExportJsonResult))] +[JsonSerializable(typeof(SearchIssueDraftSummaryExportJsonResult))] +[JsonSerializable(typeof(SearchIssueDraftSummaryJsonResult))] [JsonSerializable(typeof(SearchIssueDraftJsonResult))] [JsonSerializable(typeof(SearchIssueDraftEvidenceJsonResult))] [JsonSerializable(typeof(SearchIssueDraftSourceJsonResult))] diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index fcd65c3c0..831713fa6 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -1851,18 +1851,31 @@ private static int RunSearchRecipeIssueDrafts( out _, out var freshnessObservations, out var hasFailures); + if (options.SummaryOnly) + { + return WriteSearchRecipeIssueDraftSummary( + recipe, + selection.Queries, + scope, + total, + BuildSearchRecipeQueryFreshness(freshnessContext, freshnessObservations), + queryResults, + preflight, + options, + jsonOptions, + hasFailures); + } + var drafts = queryResults .Where(queryResult => queryResult.Count > 0) .Select(queryResult => ToSearchIssueDraft(recipe, queryResult, preflight, options)) .ToList(); - var fullRecipeMetadata = options.SummaryOnly ? null : ToSearchRecipeListItem(recipe, selection.Queries); - var recipeSummaryMetadata = options.SummaryOnly ? ToSearchRecipeCompactListItem(recipe, selection.Queries) : null; var json = JsonSerializer.Serialize( new SearchIssueDraftExportJsonResult( JsonOutputContract.ApiVersion, - fullRecipeMetadata, - recipeSummaryMetadata, - options.SummaryOnly ? "summary" : "full", + ToSearchRecipeListItem(recipe, selection.Queries), + null, + "full", scope, selection.Queries.Count, total, @@ -1883,11 +1896,134 @@ private static int RunSearchRecipeIssueDrafts( options, "issue-draft", "Reduce --limit, use --snippet-lines 0, or increase --max-json-bytes.", - jsonOptions), + jsonOptions, + options.InvocationContext.CommandName), hasFailures); }); } + private static int WriteSearchRecipeIssueDraftSummary( + SearchAuditRecipe recipe, + IReadOnlyList selectedQueries, + SearchRecipeScopeJsonResult scope, + int resultCount, + SearchRecipeQueryFreshnessJsonResult queryFreshness, + IReadOnlyList queryResults, + IssueDuplicatePreflight preflight, + QueryCommandOptions options, + JsonSerializerOptions jsonOptions, + bool hasFailures) + { + const int evidencePathLimit = 5; + var summaries = queryResults + .Where(queryResult => queryResult.Count > 0) + .Select(queryResult => + { + var labels = queryResult.RecommendedLabels + .Concat(options.IssueLabels) + .Where(label => !string.IsNullOrWhiteSpace(label)) + .Select(label => label.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(label => label, StringComparer.OrdinalIgnoreCase) + .ToList(); + var fileCount = queryResult.Results + .Select(result => result.Path) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.Ordinal) + .Count(); + var evidencePaths = queryResult.TopFiles + .Take(evidencePathLimit) + .ToList(); + var omittedEvidencePathCount = Math.Max(0, fileCount - evidencePaths.Count); + return new SearchIssueDraftSummaryJsonResult( + $"{recipe.Name}/{queryResult.Name}", + queryResult.Name, + BuildSearchIssueDraftTitle(recipe, queryResult), + queryResult.Count, + fileCount, + queryResult.MinimumMatchedCount, + queryResult.MinimumOmittedResultCount, + queryResult.Truncated, + evidencePaths, + fileCount, + evidencePaths.Count, + omittedEvidencePathCount, + omittedEvidencePathCount > 0, + labels, + queryResult.Severity, + GetSearchRecipeConfidence(queryResult.Count), + queryResult.NextCursor, + BuildSearchRecipeReplayCommand( + recipe, + options, + queryResult.Name, + includeMaxJsonBytes: false)); + }) + .ToList(); + var duplicatePreflight = new SuggestionIssueDraftPreflightSummaryJsonResult( + preflight.Checked, + preflight.Source, + preflight.OpenIssueCount, + options.DuplicateConfidence, + options.DuplicateThreshold); + var recipeSummary = ToSearchRecipeCompactListItem(recipe, selectedQueries); + var recoveryCommand = BuildSearchRecipeReplayCommand( + recipe, + options, + summaryOnly: true, + includeMaxJsonBytes: false); + var totalCount = summaries.Count; + var totalCountAuthoritative = !hasFailures; + string? envelopeJson = null; + + for (var returnedCount = totalCount; returnedCount >= 0; returnedCount--) + { + var omittedCount = totalCount - returnedCount; + var payload = new SearchIssueDraftSummaryExportJsonResult( + JsonOutputContract.ApiVersion, + recipeSummary, + "summary", + scope, + selectedQueries.Count, + resultCount, + queryFreshness, + totalCount, + totalCount, + totalCountAuthoritative, + totalCountAuthoritative ? null : totalCount, + returnedCount, + omittedCount, + omittedCount > 0, + duplicatePreflight, + summaries.Take(returnedCount).ToList(), + recoveryCommand); + var json = JsonSerializer.Serialize( + payload, + CliJsonSerializerContextFactory.Create(jsonOptions).SearchIssueDraftSummaryExportJsonResult); + json = AddActiveSqliteDiagnostics(json); + envelopeJson = json; + if (!options.MaxJsonBytes.HasValue + || GetJsonDocumentByteCount(json) <= options.MaxJsonBytes.Value) + { + Console.WriteLine(json); + return CompleteSearchRecipeOutput(CommandExitCodes.Success, hasFailures); + } + } + + return CompleteSearchRecipeOutput( + WriteJsonObjectWithOptionalByteLimit( + envelopeJson!, + options, + "issue-draft summary envelope", + "Increase --max-json-bytes; the summary envelope cannot be reduced further.", + jsonOptions, + options.InvocationContext.CommandName), + hasFailures); + } + + private static int GetJsonDocumentByteCount(string json) + => Encoding.UTF8.GetByteCount(json) + Encoding.UTF8.GetByteCount(Environment.NewLine); + private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) { if (!TryResolveSearchRecipeSelection(options, out var selection, out var selectionError)) @@ -5740,7 +5876,12 @@ private static void AppendSearchIssueDraftOmittedResults( sb.AppendLine($"- next_cursor: `{queryResult.NextCursor}`"); } - private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, QueryCommandOptions options, string? queryName = null) + private static string BuildSearchRecipeReplayCommand( + SearchAuditRecipe recipe, + QueryCommandOptions options, + string? queryName = null, + bool summaryOnly = false, + bool includeMaxJsonBytes = true) { var recipeSelector = string.IsNullOrWhiteSpace(queryName) ? recipe.Name @@ -5749,6 +5890,8 @@ private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, Q options.InvocationContext.AddRecipeCommandPrefix(args, recipeSelector); args.Add("--format"); args.Add(OutputFormatIssueDrafts); + if (summaryOnly) + args.Add("--summary-only"); args.Add("--limit"); args.Add(options.Limit.ToString(CultureInfo.InvariantCulture)); @@ -5804,7 +5947,7 @@ private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, Q 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) + if (includeMaxJsonBytes && 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); diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 9b395c973..87b9ec7d0 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -4892,6 +4892,49 @@ internal sealed record SearchIssueDraftExportJsonResult( [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List? SelectionAccounting); +internal sealed record SearchIssueDraftSummaryExportJsonResult( + [property: JsonPropertyName("api_version")] string ApiVersion, + [property: JsonPropertyName("recipe_summary")] SearchRecipeCompactListItemJsonResult RecipeSummary, + [property: JsonPropertyName("metadata_mode")] string MetadataMode, + [property: JsonPropertyName("scope")] SearchRecipeScopeJsonResult Scope, + [property: JsonPropertyName("query_count")] int QueryCount, + [property: JsonPropertyName("result_count")] int ResultCount, + [property: JsonPropertyName("query_freshness")] SearchRecipeQueryFreshnessJsonResult QueryFreshness, + [property: JsonPropertyName("count")] int Count, + [property: JsonPropertyName("total_count")] int TotalCount, + [property: JsonPropertyName("total_count_authoritative")] bool TotalCountAuthoritative, + [property: JsonPropertyName("total_count_lower_bound")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + int? TotalCountLowerBound, + [property: JsonPropertyName("returned_count")] int ReturnedCount, + [property: JsonPropertyName("omitted_count")] int OmittedCount, + [property: JsonPropertyName("truncated")] bool Truncated, + [property: JsonPropertyName("duplicate_preflight")] SuggestionIssueDraftPreflightSummaryJsonResult DuplicatePreflight, + [property: JsonPropertyName("drafts")] List Drafts, + [property: JsonPropertyName("recovery_command")] string RecoveryCommand); + +internal sealed record SearchIssueDraftSummaryJsonResult( + [property: JsonPropertyName("draft_id")] string DraftId, + [property: JsonPropertyName("query_name")] string QueryName, + [property: JsonPropertyName("title")] string Title, + [property: JsonPropertyName("result_count")] int ResultCount, + [property: JsonPropertyName("file_count")] int FileCount, + [property: JsonPropertyName("minimum_matched_count")] int MinimumMatchedCount, + [property: JsonPropertyName("minimum_omitted_result_count")] int MinimumOmittedResultCount, + [property: JsonPropertyName("results_truncated")] bool ResultsTruncated, + [property: JsonPropertyName("evidence_paths")] List EvidencePaths, + [property: JsonPropertyName("evidence_path_count")] int EvidencePathCount, + [property: JsonPropertyName("evidence_paths_returned_count")] int EvidencePathsReturnedCount, + [property: JsonPropertyName("evidence_paths_omitted_count")] int EvidencePathsOmittedCount, + [property: JsonPropertyName("evidence_paths_truncated")] bool EvidencePathsTruncated, + [property: JsonPropertyName("labels")] List Labels, + [property: JsonPropertyName("severity")] string Severity, + [property: JsonPropertyName("confidence")] string Confidence, + [property: JsonPropertyName("next_cursor")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + string? NextCursor, + [property: JsonPropertyName("replay_command")] string ReplayCommand); + internal sealed record SearchIssueDraftSelectionAccountingJsonResult( [property: JsonPropertyName("recipe")] [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index bed35e491..84a9be356 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Reflection; using System.Text; +using System.Text.Encodings.Web; using System.Text.RegularExpressions; using System.Text.Json; using CodeIndex.Cli; @@ -12075,7 +12076,6 @@ public void Run(System.Exception ex) var recipeSummary = root.GetProperty("recipe_summary"); var freshness = root.GetProperty("query_freshness"); var draft = Assert.Single(root.GetProperty("drafts").EnumerateArray()); - var source = draft.GetProperty("source"); if (root.TryGetProperty("recipe", out var recipeMetadata)) Assert.Equal(JsonValueKind.Null, recipeMetadata.ValueKind); @@ -12085,6 +12085,11 @@ public void Run(System.Exception ex) Assert.False(recipeSummary.TryGetProperty("queries", out _)); Assert.Equal(1, root.GetProperty("count").GetInt32()); Assert.Equal(1, root.GetProperty("result_count").GetInt32()); + Assert.Equal(1, root.GetProperty("total_count").GetInt32()); + Assert.True(root.GetProperty("total_count_authoritative").GetBoolean()); + Assert.Equal(1, root.GetProperty("returned_count").GetInt32()); + Assert.Equal(0, root.GetProperty("omitted_count").GetInt32()); + Assert.False(root.GetProperty("truncated").GetBoolean()); Assert.Equal(1, freshness.GetProperty("positive_evidence_query_count").GetInt32()); Assert.Equal(1, freshness.GetProperty("zero_result_query_count").GetInt32()); Assert.Equal("clean", freshness.GetProperty("state").GetString()); @@ -12092,8 +12097,12 @@ public void Run(System.Exception ex) Assert.Equal(1, freshness.GetProperty("clean_zero_match_query_count").GetInt32()); Assert.Contains(freshness.GetProperty("clean_zero_match_query_names").EnumerateArray(), name => name.GetString() == "unbounded-json-parse"); Assert.Empty(freshness.GetProperty("stale_query_names").EnumerateArray()); - Assert.Equal("raw-diagnostic-echo", source.GetProperty("query_name").GetString()); - Assert.Contains(source.GetProperty("risk_evidence").EnumerateArray(), evidence => evidence.GetString()!.Contains("raw exception messages", StringComparison.Ordinal)); + Assert.Equal("raw-diagnostic-echo", draft.GetProperty("query_name").GetString()); + Assert.Equal("low", draft.GetProperty("confidence").GetString()); + Assert.True(draft.GetProperty("file_count").GetInt32() > 0); + Assert.False(draft.TryGetProperty("body", out _)); + Assert.False(draft.TryGetProperty("source", out _)); + Assert.False(draft.TryGetProperty("evidence", out _)); } finally { @@ -12163,6 +12172,147 @@ public void Run() } } + [Fact] + public void RunSearch_RecipeIssueDraftSummaryBudgetsWholeUtf8RowsAndPreservesFullOutput_Issue5058() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_issue_draft_summary_5058"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var index = 0; index < 6; index++) + { + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, + dbPath, + $"src/日本語-{index}.cs", + "csharp", + "Console.Error.WriteLine(ex.Message);\n"); + } + TestProjectHelper.InsertFreshIndexedFile( + projectRoot, + dbPath, + "src/broad-catch.cs", + "csharp", + "try { } catch (Exception ex) { Console.Error.WriteLine(ex.Message); }\n"); + + string[] SummaryArgs(params string[] extraArgs) => + [ + "--recipe", "risky-code", + "--include-query", "raw-diagnostic-echo", + "--include-query", "broad-exception-catch", + "--db", dbPath, + "--format", "issue-drafts", + "--summary-only", + "--snippet-lines", "0", + .. extraArgs, + ]; + var relaxedJsonOptions = new JsonSerializerOptions(_jsonOptions) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + var (summaryExitCode, summaryStdout, summaryStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + SummaryArgs("--max-json-bytes", "120000"), + relaxedJsonOptions)); + + Assert.Equal(CommandExitCodes.Success, summaryExitCode); + Assert.Equal(string.Empty, summaryStderr); + Assert.True(Encoding.UTF8.GetByteCount(summaryStdout) <= 120000); + using var summaryDocument = ParseJsonOutput(summaryStdout); + var summaryRoot = summaryDocument.RootElement; + Assert.Equal(2, summaryRoot.GetProperty("total_count").GetInt32()); + Assert.Equal(2, summaryRoot.GetProperty("returned_count").GetInt32()); + Assert.Equal(0, summaryRoot.GetProperty("omitted_count").GetInt32()); + Assert.False(summaryRoot.GetProperty("truncated").GetBoolean()); + var summaries = summaryRoot.GetProperty("drafts").EnumerateArray().ToArray(); + Assert.Equal(2, summaries.Length); + var rawDiagnosticSummary = Assert.Single( + summaries, + item => item.GetProperty("query_name").GetString() == "raw-diagnostic-echo"); + Assert.Equal(7, rawDiagnosticSummary.GetProperty("file_count").GetInt32()); + Assert.Equal(5, rawDiagnosticSummary.GetProperty("evidence_paths_returned_count").GetInt32()); + Assert.Equal(2, rawDiagnosticSummary.GetProperty("evidence_paths_omitted_count").GetInt32()); + Assert.True(rawDiagnosticSummary.GetProperty("evidence_paths_truncated").GetBoolean()); + Assert.Contains( + "cdidx search --recipe risky-code/raw-diagnostic-echo --format issue-drafts", + rawDiagnosticSummary.GetProperty("replay_command").GetString(), + StringComparison.Ordinal); + Assert.False(rawDiagnosticSummary.TryGetProperty("body", out _)); + Assert.False(rawDiagnosticSummary.TryGetProperty("source", out _)); + Assert.False(rawDiagnosticSummary.TryGetProperty("evidence", out _)); + + var fullByteCount = Encoding.UTF8.GetByteCount(summaryStdout); + var truncatedBudget = fullByteCount - 1; + var (truncatedExitCode, truncatedStdout, truncatedStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + SummaryArgs("--max-json-bytes", truncatedBudget.ToString(CultureInfo.InvariantCulture)), + relaxedJsonOptions)); + + Assert.Equal(CommandExitCodes.Success, truncatedExitCode); + Assert.Equal(string.Empty, truncatedStderr); + Assert.True(Encoding.UTF8.GetByteCount(truncatedStdout) <= truncatedBudget); + using var truncatedDocument = ParseJsonOutput(truncatedStdout); + var truncatedRoot = truncatedDocument.RootElement; + Assert.Equal(2, truncatedRoot.GetProperty("total_count").GetInt32()); + Assert.Equal(1, truncatedRoot.GetProperty("returned_count").GetInt32()); + Assert.Equal(1, truncatedRoot.GetProperty("omitted_count").GetInt32()); + Assert.True(truncatedRoot.GetProperty("truncated").GetBoolean()); + Assert.Single(truncatedRoot.GetProperty("drafts").EnumerateArray()); + + var utf8Budget = summaryStdout.Length; + Assert.True(fullByteCount > utf8Budget); + var (utf8ExitCode, utf8Stdout, utf8Stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + SummaryArgs("--max-json-bytes", utf8Budget.ToString(CultureInfo.InvariantCulture)), + relaxedJsonOptions)); + + Assert.Equal(CommandExitCodes.Success, utf8ExitCode); + Assert.Equal(string.Empty, utf8Stderr); + Assert.True(Encoding.UTF8.GetByteCount(utf8Stdout) <= utf8Budget); + using var utf8Document = ParseJsonOutput(utf8Stdout); + Assert.True(utf8Document.RootElement.GetProperty("returned_count").GetInt32() < 2); + + var (envelopeExitCode, envelopeStdout, envelopeStderr) = CaptureConsole(() => ProgramRunner.Run( + ["audit", "risky-code", .. SummaryArgs("--max-json-bytes", "1").Skip(2)], + relaxedJsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.UsageError, envelopeExitCode); + Assert.Equal(string.Empty, envelopeStderr); + using var envelopeDocument = ParseJsonOutput(envelopeStdout); + var envelopeRoot = envelopeDocument.RootElement; + Assert.Equal("E028_RESPONSE_BUDGET_TOO_SMALL", envelopeRoot.GetProperty("error_code").GetString()); + Assert.Equal("audit", envelopeRoot.GetProperty("command").GetString()); + Assert.StartsWith("cdidx audit ", envelopeRoot.GetProperty("usage").GetString(), StringComparison.Ordinal); + Assert.Equal( + "audit", + envelopeRoot.GetProperty("retry").GetProperty("command").GetString()); + + var (fullExitCode, fullStdout, fullStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [ + "--recipe", "risky-code/raw-diagnostic-echo", + "--db", dbPath, + "--format", "issue-drafts", + "--snippet-lines", "0", + ], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, fullExitCode); + Assert.Equal(string.Empty, fullStderr); + using var fullDocument = ParseJsonOutput(fullStdout); + var fullRoot = fullDocument.RootElement; + Assert.Equal("full", fullRoot.GetProperty("metadata_mode").GetString()); + Assert.True(fullRoot.TryGetProperty("recipe", out _)); + Assert.False(fullRoot.TryGetProperty("recipe_summary", out _)); + var fullDraft = Assert.Single(fullRoot.GetProperty("drafts").EnumerateArray()); + Assert.True(fullDraft.TryGetProperty("body", out _)); + Assert.True(fullDraft.TryGetProperty("source", out _)); + Assert.True(fullDraft.TryGetProperty("evidence", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_AdHocIssueDraftsUseTitleLabelsAndDuplicatePreflight_Issue3520() { From effc781198ef761c220bcd63c68fd7de2177d5a3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 10 Aug 2026 16:19:15 +0900 Subject: [PATCH 2/3] Align bounded summary row counts (#5058) --- TESTING_GUIDE.md | 2 ++ changelog.d/unreleased/5058.fixed.md | 1 + src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs | 2 +- tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs | 5 ++++- 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index da1f9715c..8a3996006 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -20,6 +20,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack - `WorkspaceCheckTruncationIssue5055Tests` owns the bounded `workspace_check` list contract. Keep zero, below-limit, exact-limit, and over-limit coverage for all six categories, plus the 1260/20/1240 example and one shared filesystem fixture for raw JSON, list-only projection, compact output, byte-budget trimming, deterministic order, and human sample labeling. +- Recipe issue-draft summary coverage in `QueryCommandRunnerSearchTests` keeps the dedicated compact DTO separate from full draft bodies, asserts returned/total/omitted row cardinality (including `count == drafts.length`), measures the complete UTF-8 document with multibyte paths and its final newline, verifies whole-row truncation and typed `E028` audit identity, and preserves the full-output contract. - Marker-gate reference coverage keeps cross-language positive syntax in `ReferenceExtractorMarkerGateTests.cs`, with dedicated GraphQL/HTML/Markdown and XAML/XML cases proving that markerless continuation lines still advance parser state. `ReferenceExtractorPerformanceBudgetTests` owns three complementary regressions: Kotlin infix detection must not enumerate a large known-name set, Docker instruction classification must allocate nothing and scale with the instruction prefix rather than irrelevant line length, and warmed C#/Java/Kotlin markerless-decoy extraction must produce no references while staying below 24 MiB of measured allocations and the 3-second primary-target runaway guard. Keep the broad allocation/time guards on `net8.0`; keep the focused positive and state-preservation tests cross-target. - Framework: xUnit - Target frameworks: `net8.0` and `net9.0` @@ -1016,6 +1017,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック - `WorkspaceCheckTruncationIssue5055Tests` は上限付き `workspace_check` 一覧の契約を担当します。6カテゴリすべての0件・上限未満・上限一致・上限超過、1260/20/1240 の例、および raw JSON、一覧単独 projection、compact 出力、byte budget による短縮、決定的順序、human 向け sample 表示を1つの共有 filesystem fixture で検証する coverage を維持してください。 +- `QueryCommandRunnerSearchTests` の recipe issue-draft summary coverage は、専用 compact DTO を完全版 draft body から分離し、返却 / total / omitted row の cardinality(`count == drafts.length` を含む)、multibyte path と末尾改行を含む UTF-8 document 全体の計測、完全な row 単位の truncation、型付き `E028` の audit identity、完全版 output contract の維持を検証します。 - marker gate の reference coverage は、言語横断の正しい構文を `ReferenceExtractorMarkerGateTests.cs` にまとめ、GraphQL/HTML/Markdown と XAML/XML の専用 case で marker のない継続行でも parser state が更新されることを固定します。`ReferenceExtractorPerformanceBudgetTests` は相補的な3つの回帰契約を所有します。Kotlin infix 検出では大きな既知名集合を列挙しないこと、Docker instruction 分類では allocation が0で無関係な行長ではなく instruction prefix に応じて scale すること、warmup 済みの C#/Java/Kotlin markerless-decoy 抽出では reference が0件のまま計測 allocation 24 MiB未満かつ primary target の3秒 runaway guard内であることを検証します。広い allocation / time guard は `net8.0` に限定し、focused な正例とstate維持のテストはcross-targetのままにしてください。 - フレームワーク: xUnit - メインのテストプロジェクト: `tests/CodeIndex.Tests/CodeIndex.Tests.csproj` diff --git a/changelog.d/unreleased/5058.fixed.md b/changelog.d/unreleased/5058.fixed.md index 2f466d2b3..53a33fa73 100644 --- a/changelog.d/unreleased/5058.fixed.md +++ b/changelog.d/unreleased/5058.fixed.md @@ -9,6 +9,7 @@ affected: - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs - USER_GUIDE.md - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md --- ## English diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 831713fa6..c3453b9dc 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -1987,7 +1987,7 @@ private static int WriteSearchRecipeIssueDraftSummary( selectedQueries.Count, resultCount, queryFreshness, - totalCount, + returnedCount, totalCount, totalCountAuthoritative, totalCountAuthoritative ? null : totalCount, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 84a9be356..5fde9faa2 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -12252,11 +12252,14 @@ string[] SummaryArgs(params string[] extraArgs) => Assert.True(Encoding.UTF8.GetByteCount(truncatedStdout) <= truncatedBudget); using var truncatedDocument = ParseJsonOutput(truncatedStdout); var truncatedRoot = truncatedDocument.RootElement; + Assert.Equal(1, truncatedRoot.GetProperty("count").GetInt32()); Assert.Equal(2, truncatedRoot.GetProperty("total_count").GetInt32()); Assert.Equal(1, truncatedRoot.GetProperty("returned_count").GetInt32()); Assert.Equal(1, truncatedRoot.GetProperty("omitted_count").GetInt32()); Assert.True(truncatedRoot.GetProperty("truncated").GetBoolean()); - Assert.Single(truncatedRoot.GetProperty("drafts").EnumerateArray()); + Assert.Equal( + truncatedRoot.GetProperty("count").GetInt32(), + truncatedRoot.GetProperty("drafts").GetArrayLength()); var utf8Budget = summaryStdout.Length; Assert.True(fullByteCount > utf8Budget); From c259e67de86b2e54d285f2b57867fd721d412128 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 10 Aug 2026 16:47:14 +0900 Subject: [PATCH 3/3] Address issue-draft summary review findings (#5058) --- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 11 +++-- changelog.d/unreleased/5058.fixed.md | 4 +- .../Cli/QueryCommandRunner.SearchRecipes.cs | 42 ++++++++++++------ src/CodeIndex/Cli/SearchAuditRecipes.cs | 24 +++++++++- .../QueryCommandRunnerSearchTests.cs | 44 ++++++++++++++++++- 6 files changed, 107 insertions(+), 22 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 8a3996006..72fa5b3c1 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -20,7 +20,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack - `WorkspaceCheckTruncationIssue5055Tests` owns the bounded `workspace_check` list contract. Keep zero, below-limit, exact-limit, and over-limit coverage for all six categories, plus the 1260/20/1240 example and one shared filesystem fixture for raw JSON, list-only projection, compact output, byte-budget trimming, deterministic order, and human sample labeling. -- Recipe issue-draft summary coverage in `QueryCommandRunnerSearchTests` keeps the dedicated compact DTO separate from full draft bodies, asserts returned/total/omitted row cardinality (including `count == drafts.length`), measures the complete UTF-8 document with multibyte paths and its final newline, verifies whole-row truncation and typed `E028` audit identity, and preserves the full-output contract. +- Recipe issue-draft summary coverage in `QueryCommandRunnerSearchTests` keeps the dedicated compact DTO separate from full draft bodies, asserts returned/total/omitted row cardinality (including `count == drafts.length` and positive queries hidden by `--total-limit`), preserves pre-limit evidence-path counts with authority/lower-bound metadata, measures the complete UTF-8 document with multibyte paths and its final newline, verifies whole-row truncation and typed `E028` audit identity, and preserves the full-output contract. - Marker-gate reference coverage keeps cross-language positive syntax in `ReferenceExtractorMarkerGateTests.cs`, with dedicated GraphQL/HTML/Markdown and XAML/XML cases proving that markerless continuation lines still advance parser state. `ReferenceExtractorPerformanceBudgetTests` owns three complementary regressions: Kotlin infix detection must not enumerate a large known-name set, Docker instruction classification must allocate nothing and scale with the instruction prefix rather than irrelevant line length, and warmed C#/Java/Kotlin markerless-decoy extraction must produce no references while staying below 24 MiB of measured allocations and the 3-second primary-target runaway guard. Keep the broad allocation/time guards on `net8.0`; keep the focused positive and state-preservation tests cross-target. - Framework: xUnit - Target frameworks: `net8.0` and `net9.0` @@ -1017,7 +1017,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック - `WorkspaceCheckTruncationIssue5055Tests` は上限付き `workspace_check` 一覧の契約を担当します。6カテゴリすべての0件・上限未満・上限一致・上限超過、1260/20/1240 の例、および raw JSON、一覧単独 projection、compact 出力、byte budget による短縮、決定的順序、human 向け sample 表示を1つの共有 filesystem fixture で検証する coverage を維持してください。 -- `QueryCommandRunnerSearchTests` の recipe issue-draft summary coverage は、専用 compact DTO を完全版 draft body から分離し、返却 / total / omitted row の cardinality(`count == drafts.length` を含む)、multibyte path と末尾改行を含む UTF-8 document 全体の計測、完全な row 単位の truncation、型付き `E028` の audit identity、完全版 output contract の維持を検証します。 +- `QueryCommandRunnerSearchTests` の recipe issue-draft summary coverage は、専用 compact DTO を完全版 draft body から分離し、返却 / total / omitted row の cardinality(`count == drafts.length` と `--total-limit` に隠れた positive query を含む)、limit 適用前の evidence-path count と authority / lower-bound metadata の維持、multibyte path と末尾改行を含む UTF-8 document 全体の計測、完全な row 単位の truncation、型付き `E028` の audit identity、完全版 output contract の維持を検証します。 - marker gate の reference coverage は、言語横断の正しい構文を `ReferenceExtractorMarkerGateTests.cs` にまとめ、GraphQL/HTML/Markdown と XAML/XML の専用 case で marker のない継続行でも parser state が更新されることを固定します。`ReferenceExtractorPerformanceBudgetTests` は相補的な3つの回帰契約を所有します。Kotlin infix 検出では大きな既知名集合を列挙しないこと、Docker instruction 分類では allocation が0で無関係な行長ではなく instruction prefix に応じて scale すること、warmup 済みの C#/Java/Kotlin markerless-decoy 抽出では reference が0件のまま計測 allocation 24 MiB未満かつ primary target の3秒 runaway guard内であることを検証します。広い allocation / time guard は `net8.0` に限定し、focused な正例とstate維持のテストはcross-targetのままにしてください。 - フレームワーク: xUnit - メインのテストプロジェクト: `tests/CodeIndex.Tests/CodeIndex.Tests.csproj` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index c9d679f86..e16517a8c 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1677,7 +1677,10 @@ to recipe issue-draft export when agents only need compact top-level metadata: the output uses a dedicated summary contract with one compact row per positive query instead of embedding full issue bodies, source rows, or repeated recipe metadata. Each row reports counts, severity/confidence, labels, at most five -evidence paths with explicit omission counts, and a full-detail replay command. +evidence paths with explicit omission counts, count authority/lower bounds when +the candidate window is incomplete, and a full-detail replay command. Positive +queries remain represented even when `--total-limit` leaves them with zero +returned results; the uncapped recovery command omits that run-wide limit. The root reports total/returned/omitted row counts, whether the total is authoritative, `query_freshness`, and an uncapped `recovery_command`. Combine it with `--max-json-bytes ` to measure the complete UTF-8 document (including @@ -5147,8 +5150,10 @@ JSON summary には `confidence` と `minimum_score` が出力されます。dra top-level metadata だけを必要とする場合は、recipe issue-draft export に `--summary-only` を追加します。 この出力は専用の summary contract を使い、完全な Issue body、source row、query ごとに重複する recipe metadata を埋め込まず、結果がある query ごとに compact な row を1件出力します。各 row は -count、severity / confidence、label、最大5件の evidence path と明示的な省略件数、完全な詳細を取得する -replay command を返します。root は total / returned / omitted row count、total が authoritative か、 +count、severity / confidence、label、最大5件の evidence path、明示的な省略件数、candidate window が +不完全な場合の count authority / lower bound、完全な詳細を取得する replay command を返します。 +`--total-limit` により返却 result が0件になった positive query も row として保持し、上限なしの recovery +command からは run 全体のその limit を除外します。root は total / returned / omitted row count、total が authoritative か、 `query_freshness`、上限なしの `recovery_command` を返します。`--max-json-bytes ` を併用すると、末尾の 改行を含む UTF-8 document 全体を計測し、上限に収まる完全な row だけを保持します。row 0件の envelope さえ収まらない場合は `E028` で fail-closed にし、retry guidance でも実行元の `search` または `audit` diff --git a/changelog.d/unreleased/5058.fixed.md b/changelog.d/unreleased/5058.fixed.md index 53a33fa73..a6a22870a 100644 --- a/changelog.d/unreleased/5058.fixed.md +++ b/changelog.d/unreleased/5058.fixed.md @@ -14,8 +14,8 @@ affected: ## English -- **Recipe issue-draft summaries now fit bounded automation payloads (#5058; follow-up to #4064, #4118, and #4308)** — `--summary-only` now uses a dedicated compact contract with one whole row per positive query, bounded evidence paths, explicit omission and total-authority metadata, full-detail replay commands, and an uncapped recovery command. `--max-json-bytes` measures the complete UTF-8 document including its final newline, truncates only at row boundaries, and preserves the invoked `search` or `audit` identity in `E028` guidance; full issue-draft output remains unchanged. +- **Recipe issue-draft summaries now fit bounded automation payloads (#5058; follow-up to #4064, #4118, and #4308)** — `--summary-only` now uses a dedicated compact contract with one whole row per positive query, including queries hidden by `--total-limit`, bounded evidence paths with truthful authority/lower-bound metadata, full-detail replay commands, and an uncapped recovery command. `--max-json-bytes` measures the complete UTF-8 document including its final newline, truncates only at row boundaries, and preserves the invoked `search` or `audit` identity in `E028` guidance; full issue-draft output remains unchanged. ## 日本語 -- **recipe issue-draft summary が automation の上限付き payload に収まるよう修正しました (#5058、#4064、#4118、#4308 の follow-up)** — `--summary-only` は、結果がある query ごとの完全な row、上限付き evidence path、明示的な省略件数と total の authority metadata、完全な詳細を取得する replay command、上限なしの recovery command を持つ専用 compact contract を使います。`--max-json-bytes` は末尾改行を含む UTF-8 document 全体を計測し、row 境界だけで truncate し、`E028` guidance でも実行元の `search` または `audit` identity を維持します。完全版 issue-draft output は変更しません。 +- **recipe issue-draft summary が automation の上限付き payload に収まるよう修正しました (#5058、#4064、#4118、#4308 の follow-up)** — `--summary-only` は、`--total-limit` に隠れた query を含む結果がある query ごとの完全な row、正確な authority / lower-bound metadata を伴う上限付き evidence path、完全な詳細を取得する replay command、上限なしの recovery command を持つ専用 compact contract を使います。`--max-json-bytes` は末尾改行を含む UTF-8 document 全体を計測し、row 境界だけで truncate し、`E028` guidance でも実行元の `search` または `audit` identity を維持します。完全版 issue-draft output は変更しません。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index c3453b9dc..5bdd0252c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -1916,7 +1916,7 @@ private static int WriteSearchRecipeIssueDraftSummary( { const int evidencePathLimit = 5; var summaries = queryResults - .Where(queryResult => queryResult.Count > 0) + .Where(queryResult => queryResult.MinimumMatchedCount > 0) .Select(queryResult => { var labels = queryResult.RecommendedLabels @@ -1926,12 +1926,9 @@ private static int WriteSearchRecipeIssueDraftSummary( .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(label => label, StringComparer.OrdinalIgnoreCase) .ToList(); - var fileCount = queryResult.Results - .Select(result => result.Path) - .Where(path => !string.IsNullOrWhiteSpace(path)) - .Distinct(StringComparer.Ordinal) - .Count(); - var evidencePaths = queryResult.TopFiles + var fileCount = queryResult.SummaryEvidencePathCount; + var fileCountAuthoritative = queryResult.SummaryEvidencePathCountAuthoritative; + var evidencePaths = queryResult.SummaryEvidencePaths .Take(evidencePathLimit) .ToList(); var omittedEvidencePathCount = Math.Max(0, fileCount - evidencePaths.Count); @@ -1941,17 +1938,23 @@ private static int WriteSearchRecipeIssueDraftSummary( BuildSearchIssueDraftTitle(recipe, queryResult), queryResult.Count, fileCount, + fileCountAuthoritative, + fileCountAuthoritative ? null : fileCount, queryResult.MinimumMatchedCount, queryResult.MinimumOmittedResultCount, queryResult.Truncated, evidencePaths, fileCount, + fileCountAuthoritative, + fileCountAuthoritative ? null : fileCount, evidencePaths.Count, omittedEvidencePathCount, - omittedEvidencePathCount > 0, + fileCountAuthoritative, + fileCountAuthoritative ? null : omittedEvidencePathCount, + omittedEvidencePathCount > 0 || !fileCountAuthoritative, labels, queryResult.Severity, - GetSearchRecipeConfidence(queryResult.Count), + GetSearchRecipeConfidence(queryResult.MinimumMatchedCount), queryResult.NextCursor, BuildSearchRecipeReplayCommand( recipe, @@ -1971,7 +1974,8 @@ private static int WriteSearchRecipeIssueDraftSummary( recipe, options, summaryOnly: true, - includeMaxJsonBytes: false); + includeMaxJsonBytes: false, + includeTotalLimit: false); var totalCount = summaries.Count; var totalCountAuthoritative = !hasFailures; string? envelopeJson = null; @@ -2324,6 +2328,12 @@ private static List CollectSearchRecipeQueryR results = ApplySearchRecipeFileRejectQueries(reader, results, options, recipeQuery); var rows = BuildSearchDisplayRows(results, options, exact, recipeQuery.Query, rawFtsOverride: false, recipeQuery: recipeQuery); rows = ApplySearchRecipeSemanticFilter(reader, options, recipeQuery, rows); + var summaryEvidencePaths = BuildSearchRecipeTopFiles(rows); + var summaryEvidencePathCount = rows + .Select(row => row.Result.Path) + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.Ordinal) + .Count(); var outputSelection = ApplySearchOutputSelection(rows, options, resultLimit, sourceTotalAuthoritative); rows = outputSelection.Rows; if (includeAuditClassifications) @@ -2376,7 +2386,12 @@ private static List CollectSearchRecipeQueryR outputSelection.Returned, outputSelection.SelectorOmittedCount, outputSelection.LimitOmittedCount, - outputSelection.Selectors)); + outputSelection.Selectors) + { + SummaryEvidencePaths = summaryEvidencePaths, + SummaryEvidencePathCount = summaryEvidencePathCount, + SummaryEvidencePathCountAuthoritative = sourceTotalAuthoritative, + }); if (freshnessContext != null) { freshnessObservations.Add(SuccessfulSearchQueryObservation( @@ -5881,7 +5896,8 @@ private static string BuildSearchRecipeReplayCommand( QueryCommandOptions options, string? queryName = null, bool summaryOnly = false, - bool includeMaxJsonBytes = true) + bool includeMaxJsonBytes = true, + bool includeTotalLimit = true) { var recipeSelector = string.IsNullOrWhiteSpace(queryName) ? recipe.Name @@ -5942,7 +5958,7 @@ private static string BuildSearchRecipeReplayCommand( AddReplayValueOption(args, "--exclude-origin", origin); foreach (var kind in options.ResultKinds) AddReplayValueOption(args, "--result-kind", kind); - if (options.TotalLimit.HasValue) + if (includeTotalLimit && 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)); diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 87b9ec7d0..575edae33 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -4718,7 +4718,17 @@ internal sealed record SearchRecipeQueryResultJsonResult( [property: JsonPropertyName("returned")] int Returned, [property: JsonPropertyName("selector_omitted_count")] int SelectorOmittedCount, [property: JsonPropertyName("limit_omitted_count")] int LimitOmittedCount, - [property: JsonPropertyName("selectors")] List Selectors); + [property: JsonPropertyName("selectors")] List Selectors) +{ + [JsonIgnore] + public List SummaryEvidencePaths { get; init; } = []; + + [JsonIgnore] + public int SummaryEvidencePathCount { get; init; } + + [JsonIgnore] + public bool SummaryEvidencePathCountAuthoritative { get; init; } +} internal sealed record SearchNamedBatchCountSummaryRunJsonResult( [property: JsonPropertyName("api_version")] string ApiVersion, @@ -4919,13 +4929,25 @@ internal sealed record SearchIssueDraftSummaryJsonResult( [property: JsonPropertyName("title")] string Title, [property: JsonPropertyName("result_count")] int ResultCount, [property: JsonPropertyName("file_count")] int FileCount, + [property: JsonPropertyName("file_count_authoritative")] bool FileCountAuthoritative, + [property: JsonPropertyName("file_count_lower_bound")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + int? FileCountLowerBound, [property: JsonPropertyName("minimum_matched_count")] int MinimumMatchedCount, [property: JsonPropertyName("minimum_omitted_result_count")] int MinimumOmittedResultCount, [property: JsonPropertyName("results_truncated")] bool ResultsTruncated, [property: JsonPropertyName("evidence_paths")] List EvidencePaths, [property: JsonPropertyName("evidence_path_count")] int EvidencePathCount, + [property: JsonPropertyName("evidence_path_count_authoritative")] bool EvidencePathCountAuthoritative, + [property: JsonPropertyName("evidence_path_count_lower_bound")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + int? EvidencePathCountLowerBound, [property: JsonPropertyName("evidence_paths_returned_count")] int EvidencePathsReturnedCount, [property: JsonPropertyName("evidence_paths_omitted_count")] int EvidencePathsOmittedCount, + [property: JsonPropertyName("evidence_paths_omitted_count_authoritative")] bool EvidencePathsOmittedCountAuthoritative, + [property: JsonPropertyName("evidence_paths_omitted_count_lower_bound")] + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + int? EvidencePathsOmittedCountLowerBound, [property: JsonPropertyName("evidence_paths_truncated")] bool EvidencePathsTruncated, [property: JsonPropertyName("labels")] List Labels, [property: JsonPropertyName("severity")] string Severity, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 5fde9faa2..6daba5e88 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -12156,15 +12156,34 @@ public void Run() using var document = ParseJsonOutput(stdout); var root = document.RootElement; var freshness = root.GetProperty("query_freshness"); + var drafts = root.GetProperty("drafts").EnumerateArray().ToArray(); - Assert.Equal(1, root.GetProperty("count").GetInt32()); + Assert.Equal(2, root.GetProperty("count").GetInt32()); Assert.Equal(1, root.GetProperty("result_count").GetInt32()); + Assert.Equal(2, root.GetProperty("total_count").GetInt32()); + Assert.Equal(2, root.GetProperty("returned_count").GetInt32()); + Assert.Equal(0, root.GetProperty("omitted_count").GetInt32()); + Assert.False(root.GetProperty("truncated").GetBoolean()); Assert.Equal(2, freshness.GetProperty("positive_evidence_query_count").GetInt32()); Assert.Equal(0, freshness.GetProperty("zero_result_query_count").GetInt32()); Assert.Equal("clean", freshness.GetProperty("state").GetString()); Assert.Equal(2, freshness.GetProperty("clean_query_count").GetInt32()); Assert.Equal(0, freshness.GetProperty("clean_zero_match_query_count").GetInt32()); Assert.Empty(freshness.GetProperty("stale_query_names").EnumerateArray()); + Assert.Equal(2, drafts.Length); + var totalLimitedDraft = Assert.Single( + drafts, + draft => draft.GetProperty("query_name").GetString() == "broad-exception-catch"); + Assert.Equal(0, totalLimitedDraft.GetProperty("result_count").GetInt32()); + Assert.Equal(1, totalLimitedDraft.GetProperty("minimum_matched_count").GetInt32()); + Assert.Equal(1, totalLimitedDraft.GetProperty("minimum_omitted_result_count").GetInt32()); + Assert.True(totalLimitedDraft.GetProperty("results_truncated").GetBoolean()); + Assert.False(totalLimitedDraft.GetProperty("evidence_path_count_authoritative").GetBoolean()); + Assert.True(totalLimitedDraft.GetProperty("evidence_paths_truncated").GetBoolean()); + Assert.DoesNotContain( + "--total-limit", + root.GetProperty("recovery_command").GetString(), + StringComparison.Ordinal); } finally { @@ -12241,6 +12260,29 @@ string[] SummaryArgs(params string[] extraArgs) => Assert.False(rawDiagnosticSummary.TryGetProperty("source", out _)); Assert.False(rawDiagnosticSummary.TryGetProperty("evidence", out _)); + var (limitedExitCode, limitedStdout, limitedStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + SummaryArgs("--limit", "1"), + relaxedJsonOptions)); + + Assert.Equal(CommandExitCodes.Success, limitedExitCode); + Assert.Equal(string.Empty, limitedStderr); + using var limitedDocument = ParseJsonOutput(limitedStdout); + var limitedRawDiagnosticSummary = Assert.Single( + limitedDocument.RootElement.GetProperty("drafts").EnumerateArray(), + item => item.GetProperty("query_name").GetString() == "raw-diagnostic-echo"); + Assert.Equal(1, limitedRawDiagnosticSummary.GetProperty("result_count").GetInt32()); + Assert.Equal(7, limitedRawDiagnosticSummary.GetProperty("minimum_matched_count").GetInt32()); + Assert.Equal(7, limitedRawDiagnosticSummary.GetProperty("file_count").GetInt32()); + Assert.False(limitedRawDiagnosticSummary.GetProperty("file_count_authoritative").GetBoolean()); + Assert.Equal(7, limitedRawDiagnosticSummary.GetProperty("file_count_lower_bound").GetInt32()); + Assert.Equal(7, limitedRawDiagnosticSummary.GetProperty("evidence_path_count").GetInt32()); + Assert.False(limitedRawDiagnosticSummary.GetProperty("evidence_path_count_authoritative").GetBoolean()); + Assert.Equal(7, limitedRawDiagnosticSummary.GetProperty("evidence_path_count_lower_bound").GetInt32()); + Assert.Equal(5, limitedRawDiagnosticSummary.GetProperty("evidence_paths_returned_count").GetInt32()); + Assert.Equal(2, limitedRawDiagnosticSummary.GetProperty("evidence_paths_omitted_count").GetInt32()); + Assert.False(limitedRawDiagnosticSummary.GetProperty("evidence_paths_omitted_count_authoritative").GetBoolean()); + Assert.Equal(2, limitedRawDiagnosticSummary.GetProperty("evidence_paths_omitted_count_lower_bound").GetInt32()); + var fullByteCount = Encoding.UTF8.GetByteCount(summaryStdout); var truncatedBudget = fullByteCount - 1; var (truncatedExitCode, truncatedStdout, truncatedStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(