diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 6d9c34b89..b8e637e7b 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1739,7 +1739,7 @@ access. | Search selection edge cases | Issue-draft roots independently retain per-query `selection_accounting`, including zero-draft and exhausted-total-limit queries. Byte-bounded compact and array envelopes rewrite `returned` to the emitted row count while preserving logical `limit_omitted_count`; hard-cap omissions remain separate in `metadata.byte_limit_omitted_count`. | | Ad-hoc search SARIF | `search --format sarif` stores completion metadata on each SARIF run. The run and its single `queries[]` summary report `source_result_count`, `source_result_count_authoritative`, emitted `result_count`, the applied `limit_per_query` / `result_limit`, conservative `minimum_omitted_result_count`, and `truncated` state. Source and emitted counts use the final SARIF result/location unit, including exact-search occurrence expansion. Guarded searches retain their bounded candidate budget instead of failing during a completion recount; their source count is an explicitly non-authoritative lower bound and their truncation state remains conservative. Facet-filtered exact searches use an exhaustive source count rather than the display candidate window. Ad-hoc search does not expose a continuation cursor, so `cursoring_available` is `false` and `next_cursor` is null; a shell-quoted `replay_command` preserves option-like queries and active search controls. The completion vocabulary intentionally matches recipe SARIF, and empty runs carry the same fields with zero counts. | | Ad-hoc issue-draft selection | `search --format issue-drafts` reads the complete filtered ad-hoc population, then applies `--first-per-file`, deterministic `--sample`, and `min(--limit, --total-limit)` in that order. Guarded searches retain their finite candidate inspection contract: `source_total_count` is omitted, `source_minimum_count` reports the observed lower bound, `source_total_count_authoritative=false`, `source_fetch_limit` reports the bounded fetch, and `truncated=true` preserves incomplete-population state. Existing `result_count`, `result_limit`, `omitted_count`, and `truncated` fields describe the returned selection accurately; additive `source_total_count`, `returned_count`, `limit_per_query`, `total_limit`, `first_per_file`, and `sample` fields make the applied contract auditable. Replay commands are serialized from normalized parsed options, use POSIX-safe single-quote escaping, and retain raw/exact/prefix modes, path/language/facet/guard filters, selection controls, evidence formatting, duplicate preflight, and issue hints. | -| Recipe SARIF | `search --recipe --format sarif` emits one result per bounded recipe result. Rule IDs use `recipe/query`; standard `fingerprints.cdidx/v1` values are derived from the normalized source location; result properties preserve recipe/query identity, severity, confidence, and per-query truncation; run properties preserve scope, applied result limits, aggregate counts, and conservative omitted-result metadata. Bound SARIF with `--limit` / `--total-limit`; row selectors such as `--sample`, `--first-per-file`, and `--per-file-limit` are rejected instead of being silently ignored. Recipe severity maps `critical` / `high` to `error`, `medium` to `warning`, and `low` / `info` to `note`. | +| Recipe 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. `--max-json-bytes` preflights the complete schema-valid document and exact UTF-8 byte count, including escaping and the final newline, through a counting writer before materializing only the selected prefix. If the full document does not fit, serialization omits only whole trailing results and adds run/query source, emitted, omitted, byte-strategy, and replay metadata; emitted rules and locations remain intact. This truncation exits `11` unless `--allow-partial` is set. A cap below the zero-result document minimum emits no SARIF and reports the required bytes; explicit `--json` may place a versioned error object on stdout when that object fits the cap. Replay metadata removes the byte cap when a complete report exceeds the parser's maximum supported cap. 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; when additive selector-accounting fields prevent the terminal from fitting, the writer omits those optional fields before declaring the terminal impossible. A cap that still 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. | | `outline` / `unused` cursor binding | `outline --json` accepts `--kind `, `--limit` / `--top`, opaque `--cursor `, and `--outline-fields ` for bounded machine output. Controlled outline responses keep the normal envelope and add `total_symbol_count`, `returned_symbol_count`, `cursor_offset`, `next_cursor`, `has_more`, and `result_stable_at`, plus `kind_filter` and `selected_fields` when active. Projection parsing canonicalizes aliases and removes duplicates before validation; unknown field names are reported together as one terminal usage error with valid candidates, while the empty-selection error is reserved for deliberately empty CSV input. `outline` and `unused` cursors bind their offset to the normalized path/scope, filters, ordering, and index generation; reuse after changing those inputs or refreshing the index fails with explicit restart-required guidance. Legacy `outline:` / `unused:` inputs remain accepted for transition, but every newly emitted cursor is opaque and bound. | @@ -5190,7 +5190,7 @@ help はすべてこのレジストリを参照します。field 名は大文字 | search selection の edge case | issue-draft の root は query ごとの `selection_accounting` を独立して保持するため、draft が 0 件の場合や total limit を使い切った query も accounting を失いません。byte 上限付き compact / array envelope は `returned` を実際の出力 row 数へ更新し、論理的な `limit_omitted_count` を保持します。hard cap による省略は `metadata.byte_limit_omitted_count` で別に報告します。 | | ad-hoc search SARIF | `search --format sarif` は completion metadata を SARIF の各 run に格納します。run と単一の `queries[]` summary は `source_result_count`、`source_result_count_authoritative`、出力済み `result_count`、適用された `limit_per_query` / `result_limit`、保守的な `minimum_omitted_result_count`、`truncated` state を返します。source / emitted count は exact search の occurrence 展開を含む最終的な SARIF result / location 単位を使用します。guard 付き search は completion の再計数で失敗せず bounded candidate budget を維持し、source count を明示的に non-authoritative な lower bound として返して truncation state を保守的に保ちます。facet filter 付き exact search は表示用 candidate window ではなく exhaustive な source count を使います。ad-hoc search は継続 cursor を公開しないため、`cursoring_available` は `false`、`next_cursor` は null となり、shell quote 済みの `replay_command` が option のような query と有効な search control を保持します。completion vocabulary は意図的に recipe SARIF と共通化し、空 run も count が 0 の同じ field を保持します。 | | ad-hoc issue-draft selection | `search --format issue-drafts` は filter 済みの ad-hoc 母集団全体を読み、`--first-per-file`、決定的な `--sample`、`min(--limit, --total-limit)` の順に適用します。guard 付き検索は有限の candidate inspection 契約を維持し、`source_total_count` を省略し、観測下限を `source_minimum_count`、非 authoritative 状態を `source_total_count_authoritative=false`、bounded fetch を `source_fetch_limit` で報告し、母集団が未完了であることを `truncated=true` で保持します。既存の `result_count`、`result_limit`、`omitted_count`、`truncated` field は返却 selection を正確に表し、additive な `source_total_count`、`returned_count`、`limit_per_query`、`total_limit`、`first_per_file`、`sample` field により適用済み契約を監査できます。replay command は正規化済み parse option から serialize し、POSIX-safe な単一引用符 escape を使い、raw / exact / prefix mode、path / language / facet / guard filter、selection control、evidence formatting、duplicate preflight、issue hint を維持します。 | -| Recipe SARIF | `search --recipe --format sarif` は、上限付き recipe result ごとに result を1件出力します。rule ID は `recipe/query` を使い、標準の `fingerprints.cdidx/v1` は正規化済み source location から導出します。result properties は recipe/query identity、severity、confidence、query ごとの truncation を保持し、run properties は scope、適用済み result limit、集計 count、保守的な omitted-result metadata を保持します。SARIF の上限には `--limit` / `--total-limit` を使い、`--sample`、`--first-per-file`、`--per-file-limit` のような row selector は黙って無視せず拒否します。recipe severity は `critical` / `high` を `error`、`medium` を `warning`、`low` / `info` を `note` に対応付けます。 | +| Recipe 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 を保持します。`--max-json-bytes` は escape と末尾改行を含む schema-valid な完全 document と正確な UTF-8 byte 数を counting writer で検査し、選択した prefix だけを materialize します。完全な document が収まらない場合、末尾の result だけを1件単位で省略し、run / query に source、emitted、omitted、byte strategy、replay metadata を追加します。出力済み result の rule と location は維持し、この truncation は `--allow-partial` がなければ `11` を返します。result 0件の document の最小値より小さい cap では SARIF を出力せず必要 byte 数を報告し、明示的な `--json` では error object 自体が cap に収まる場合に version 付き error を stdout へ出力することがあります。完全な report が parser の対応可能な最大 cap を超える場合、replay metadata は byte cap を外します。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 全体を対象にし、追加 selector-accounting field が原因で終端が収まらない場合は、終端自体を不可能と判定する前にそれらの任意 field を省略します。それでも終端が収まらない cap は stdout 出力前に失敗します。上限付き出力は `--profile`、`--verbose`、`--json-envelope` を拒否します。byte cap による部分出力は、`--allow-partial` で終了コード `0` を明示許可しない限り `CommandExitCodes.PartialResult`(`11`)を返します。`--results-only` はこれらの NDJSON row stream から終端レコードを明示的に除外するための option であり、array / compact / summary / count 出力との組み合わせは拒否されます。 | | `outline` / `unused` cursor の束縛 | `outline --json` は bounded な機械向け出力として `--kind `、`--limit` / `--top`、opaque な `--cursor `、`--outline-fields ` を受け付けます。制御付き outline 応答は通常の envelope を維持し、`total_symbol_count`、`returned_symbol_count`、`cursor_offset`、`next_cursor`、`has_more`、`result_stable_at` を追加し、該当時は `kind_filter` と `selected_fields` も返します。projection parser は検証前に alias を canonicalize して重複を除きます。未知field名はvalid候補を伴う1つの終端usage errorにまとめ、empty-selection errorは意図的に空のCSV入力にだけ使います。`outline` と `unused` の cursor は offset を正規化済み path/scope、filter、ordering、index generation に束縛するため、条件変更後または index 更新後の再利用は restart-required の明示案内付きで失敗します。移行用に legacy の `outline:` / `unused:` 入力は受理しますが、新しく出力する cursor はすべて opaque かつ束縛済みです。 | diff --git a/README.md b/README.md index 8fc2940d7..ddfa444ca 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ cdidx unused --json --by-bucket --max-json-bytes 16384 cdidx map --compact --max-json-bytes 65536 cdidx map --format issue-drafts --limit 10 cdidx search --recipe risky-code --format compact --max-json-bytes 65536 -cdidx search --recipe risky-code --format sarif --limit 20 +cdidx audit risky-code --format sarif --limit 20 --max-json-bytes 65536 cdidx search --recipe risky-code --format compact --summary-only --json cdidx search --named-query todo=TODO --named-query fixme=FIXME --format compact --limit 10 cdidx search --named-query todo=TODO --named-query fixme=FIXME --format count --summary-only --json @@ -126,6 +126,8 @@ Recipe/audit compact pagination returns an opaque `next_cursor`. Replay it uncha `audit` delegates recipe execution to the search engine but retains its public command name in usage errors, recovery hints, and generated replay commands. With an explicit `--json`, audit usage errors return versioned command-error objects with `command: "audit"` and omit human-readable `usage`; direct `search` diagnostics retain the `search` identity. +Recipe/audit SARIF accepts `--max-json-bytes` as an exact UTF-8 budget for the complete document, including JSON escaping and the final newline. A complete document that fits is unchanged. Otherwise, cdidx emits schema-valid SARIF by omitting only whole trailing results while preserving the rules and locations for emitted results; run and query properties report source/emitted/omitted counts, the byte-budget strategy, and replay guidance. Byte-budget truncation exits with code `11` unless `--allow-partial` is supplied. If even the zero-result bounded document cannot fit, no SARIF document is written and the usage error reports the minimum required byte count. Without an explicit `--json`, stdout stays empty; explicit JSON error mode may instead emit a versioned command-error object to stdout when that object fits the same cap. + 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. @@ -590,7 +592,7 @@ cdidx unused --json --by-bucket --max-json-bytes 16384 cdidx map --compact --max-json-bytes 65536 cdidx map --format issue-drafts --limit 10 cdidx search --recipe risky-code --format compact --max-json-bytes 65536 -cdidx search --recipe risky-code --format sarif --limit 20 +cdidx audit risky-code --format sarif --limit 20 --max-json-bytes 65536 cdidx search --recipe risky-code --format compact --summary-only --json cdidx search --named-query todo=TODO --named-query fixme=FIXME --format compact --limit 10 cdidx search --named-query todo=TODO --named-query fixme=FIXME --format count --summary-only --json @@ -632,6 +634,8 @@ recipe / audit の compact pagination は opaque な `next_cursor` を返しま `audit` は内部で recipe 実行を search engine へ委譲しますが、usage error、復旧 hint、生成する replay command では公開 command 名を維持します。明示的な `--json` では、audit の usage error は `command: "audit"` を持つ version 付き command-error object を返し、人間向けの `usage` を含めません。直接の `search` diagnostic は `search` identity を維持します。 +recipe / audit の SARIF は、JSON escape と末尾改行を含む完全な document の正確な UTF-8 budget として `--max-json-bytes` を受け付けます。完全な document が収まる場合、出力は変わりません。収まらない場合は末尾の result だけを1件単位で省略し、出力した result の rule と location を維持した schema-valid SARIF を返します。run / query properties は source / emitted / omitted count、byte-budget strategy、replay guidance を報告します。byte budget による truncation は `--allow-partial` を指定しない限り終了コード `11` を返します。result 0件の bounded document さえ収まらない場合、SARIF document は出力せず、usage error で必要最小 byte 数を報告します。明示的な `--json` がなければ stdout は空のままですが、明示 JSON error mode では version 付き command-error object 自体が同じ cap に収まる場合に stdout へ出力することがあります。 + 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 5758d5db5..cd2fcc3f5 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -225,7 +225,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Validate JSON, compact, count, and SARIF pagination/severity coverage shares one mixed informational/actionable fixture so authoritative totals, limited rows, SARIF levels, and actionability metadata cannot drift across formats; keep missing-`file_issues` degradation coverage in a separate legacy-schema fixture because availability is a distinct mutable state. Command-specific output format coverage uses a command/format matrix that checks both parser acceptance and the matching usage line; recognized shared formats without a command implementation need a separate usage-error assertion. Ad-hoc search SARIF completion coverage shares one fixture across complete, 1-of-126 limited, facet-filtered occurrence-expanded limited, bounded guarded, empty, and synthetically merged multi-run documents. Assert source/emitted/omitted counts and source-count authority in SARIF result units, applied limits, conservative truncation, null cursor state, raw-FTS and option-like-query replay commands, guard-preserving replay, and unchanged rule/location/severity fields on every run. - Recipe SARIF coverage must assert bounded result counts, `recipe/query` rule identity, source locations, severity mapping, confidence, conservative truncation metadata, and stable `fingerprints.cdidx/v1` values across identical runs. + Recipe SARIF coverage must assert bounded result counts, `recipe/query` rule identity, source locations, severity mapping, confidence, conservative truncation metadata, and stable `fingerprints.cdidx/v1` values across identical runs. Byte-budget coverage must count the complete UTF-8 stdout including JSON escaping and the final newline, exercise exact-fit and one-byte-under boundaries, Unicode, empty and multi-query runs, an individually oversized result, captured/redirected stdout, and replay metadata. Every successful output must parse as complete SARIF, omit only whole results, retain matching rules and locations, and stay within the requested cap. Below-minimum failures must emit no SARIF; non-explicit JSON failures leave stdout empty, while explicit `--json` may emit a bounded versioned error object. Also cover counting-writer measurement and replay recovery when the complete size exceeds the maximum accepted byte cap. MCP schema-origin coverage keeps identical audit phrases in `McpToolCatalog.cs` top-level tool descriptions, concatenated description segments, nested schema-property prose, and executable C# in one indexed fixture; assert explicit `schema_description` search metadata and the recipe's JSON, SARIF, and issue-draft outputs so origin filtering cannot drift across projections. Recipe row-selection coverage reuses one multi-file, multi-chunk fixture across aggregate JSON, compact JSON, NDJSON, and issue-draft source metadata. Assert emitted/matched/omitted counts, `selection_reason` / `selection_omitted_count`, first-per-file path uniqueness, selector-preserving replay commands, suppressed raw cursors when a later limit truncates selected rows, and rejection of incoming cursors with either selector. A separate candidate-window fixture must exceed the default low-limit fetch envelope and prove that `--sample ` observes at least its requested candidate target; validate rejected selectors for non-row recipe shapes without opening a database. Unused default-suppression row, JSON count, summary-only, and text count envelopes, including the `--all` count control, share one unused-symbol fixture. @@ -1177,7 +1177,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" validate の JSON、compact、count、SARIF における pagination / severity coverage は、informational finding と actionable finding が混在する1つの fixture を共有し、authoritative な総件数、limited row、SARIF level、actionability metadata が format 間で drift しないことを検証してください。`file_issues` 欠落時の degradation coverage は availability が独立した mutable state なので、別の legacy-schema fixture に分けてください。 コマンド別の出力形式 coverage は command / format matrix で parser の受理と対応する usage line の両方を検証してください。共通 parser が認識してもコマンド側に実装がない形式には、別途 usage error の assertion が必要です。 ad-hoc search SARIF の completion coverage は complete、1-of-126 の limited、facet filter 付き occurrence 展開後の limited、bounded guard、empty、合成した multi-run document で1つの fixture を共有します。SARIF result 単位の source / emitted / omitted count と source count の確定性、適用済み limit、保守的な truncation、null cursor state、raw FTS と option のような query の replay command、guard を保持する replay、および各 run で rule / location / severity field が不変であることを検証してください。 - Recipe SARIF coverage では、上限付き result count、`recipe/query` rule identity、source location、severity mapping、confidence、保守的な truncation metadata、同一 run 間で安定する `fingerprints.cdidx/v1` を検証してください。 + Recipe SARIF coverage では、上限付き result count、`recipe/query` rule identity、source location、severity mapping、confidence、保守的な truncation metadata、同一 run 間で安定する `fingerprints.cdidx/v1` を検証してください。byte-budget coverage では JSON escape と末尾改行を含む完全な UTF-8 stdout を数え、exact-fit と1 byte不足の境界、Unicode、空 run と複数 query の run、単体で oversized な result、capture / redirect した stdout、replay metadata を扱ってください。成功した出力はすべて完全な SARIF として parse でき、result を1件単位でのみ省略し、対応する rule / location を維持し、要求 cap 以下でなければなりません。最小値未満の失敗では SARIF を出力せず、明示 JSON でない失敗は stdout を空にし、明示的な `--json` では上限内の version 付き error object を出力できることも検証してください。counting writer による計測と、完全な size が受理可能な最大 byte cap を超える場合の replay recovery も扱ってください。 MCP schema-origin coverage では、同一の audit phrase を `McpToolCatalog.cs` の top-level tool description、連結された description segment、nested schema property の prose、実行可能な C# に置いた1つの indexed fixture を共有し、明示的な `schema_description` 検索 metadata と recipe の JSON、SARIF、issue-draft 出力を検証して、projection 間で origin filter が drift しないようにしてください。 recipe row-selection coverage は aggregate JSON、compact JSON、NDJSON、issue-draft の source metadata で1つの multi-file / multi-chunk fixture を共有します。emitted / matched / omitted count、`selection_reason` / `selection_omitted_count`、first-per-file の path uniqueness、selector を保持する replay command、後続 limit が選択済み row を truncate する場合の raw cursor 抑止、両 selector と受け取った cursor の併用拒否を検証してください。別の candidate-window fixture では既定の low-limit fetch envelope を超え、`--sample ` が少なくとも要求 candidate 数を観測することを証明し、row を持たない recipe shape での selector 拒否は database を開かずに確認してください。 unused default-suppressionのrow、JSON count、summary-only、text count envelopeは、`--all` count controlも含めて1つのunused-symbol fixtureを共有してください。 diff --git a/changelog.d/unreleased/4903.fixed.md b/changelog.d/unreleased/4903.fixed.md new file mode 100644 index 000000000..a0ec459c2 --- /dev/null +++ b/changelog.d/unreleased/4903.fixed.md @@ -0,0 +1,22 @@ +--- +category: fixed +issues: + - 4903 +affected: + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs + - tests/CodeIndex.Tests/QueryCommandRunnerAuditSarifIssue4903Tests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Audit and recipe SARIF now honor exact JSON byte budgets (#4903)** — `--max-json-bytes` measures the complete UTF-8 SARIF document, including JSON escaping and the final newline, with a counting writer before materializing the selected prefix. When the full report does not fit, cdidx emits schema-valid SARIF by omitting only whole trailing results and records source/emitted/omitted counts, the byte-budget strategy, and executable replay guidance while preserving emitted rules and locations. A budget below the minimum complete zero-result document emits no partial SARIF; explicit `--json` may return a bounded versioned error object. + +## 日本語 + +- **audit / recipe の SARIF が正確な JSON byte budget を守るようになりました (#4903)** — `--max-json-bytes` は、JSON escape と末尾改行を含む完全な UTF-8 SARIF document を counting writer で計測してから、選択した prefix だけを materialize します。report 全体が収まらない場合は、末尾の result だけを1件単位で省略し、出力済みの rule / location を維持した schema-valid SARIF を返します。source / emitted / omitted count、byte-budget strategy、実行可能な replay guidance も記録し、result 0件の完全な document の最小値にも満たない budget では partial SARIF を出力しません。明示的な `--json` では上限内の version 付き error object を返すことがあります。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index c5f36475a..deb33bdcb 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -578,7 +578,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--env-domain", ValuePlaceholder = "", Description = "Doctor full environment inventory: filter by exact domain", PrimaryCommands = Set("doctor") }, new() { Name = "--env-category", ValuePlaceholder = "", Description = "Doctor full environment inventory: filter by exact category", PrimaryCommands = Set("doctor") }, new() { Name = "--env-sensitivity", ValuePlaceholder = "", Description = "Doctor full environment inventory: filter by exact sensitivity", PrimaryCommands = Set("doctor") }, - new() { Name = "--max-json-bytes", ValuePlaceholder = "", Description = "Bound emitted JSON bytes; bounded high-volume responses truncate projected rows with paging metadata", PrimaryCommands = Set("search", "definition", "find", "status", "references", "callers", "callees", "excerpt", "inspect", "outline", "impact", "recipes", "audit", "map", "files", "symbols", "deps", "hotspots", "languages", "unused", "doctor") }, + new() { Name = "--max-json-bytes", ValuePlaceholder = "", Description = "Bound emitted JSON bytes; bounded responses omit whole rows with recovery metadata, including schema-valid audit SARIF", PrimaryCommands = Set("search", "definition", "find", "status", "references", "callers", "callees", "excerpt", "inspect", "outline", "impact", "recipes", "audit", "map", "files", "symbols", "deps", "hotspots", "languages", "unused", "doctor") }, new() { Name = "--next-steps", Description = "Search: print inspect/excerpt follow-up commands for top hits", PrimaryCommands = Set("search") }, new() { Name = "--exclude-comments", Description = "Search: suppress comment-only matches after origin classification", PrimaryCommands = Set("search") }, new() { Name = "--exclude-strings", Description = "Search: suppress string, regex, and help-text matches after origin classification", PrimaryCommands = Set("search") }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs b/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs index 5ff4d5a6a..eec04e451 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Sarif.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using CodeIndex.Database; @@ -94,12 +95,51 @@ private static void WriteSarif( string level = "warning", JsonObject? runProperties = null) { - var writer = Console.Out; - var itemOptions = GetCompactJsonOptions(jsonOptions); var itemList = items.ToList(); + WriteSarifDocument(Console.Out, itemList, jsonOptions, level, runProperties); + Console.WriteLine(); + } + + private static string BuildSarifDocument( + IReadOnlyList items, + JsonSerializerOptions jsonOptions, + string level = "warning", + JsonObject? runProperties = null, + int? resultCount = null) + { + using var writer = new StringWriter(CultureInfo.InvariantCulture); + WriteSarifDocument(writer, items, jsonOptions, level, runProperties, resultCount); + return writer.ToString(); + } + + private static int GetSarifDocumentUtf8LineByteCount( + IReadOnlyList items, + JsonSerializerOptions jsonOptions, + string level = "warning", + JsonObject? runProperties = null, + int? resultCount = null) + { + using var writer = new Utf8ByteCountingTextWriter(); + WriteSarifDocument(writer, items, jsonOptions, level, runProperties, resultCount); + return checked(writer.ByteCount + Encoding.UTF8.GetByteCount(Environment.NewLine)); + } + + private static void WriteSarifDocument( + TextWriter writer, + IReadOnlyList itemList, + JsonSerializerOptions jsonOptions, + string level, + JsonObject? runProperties, + int? resultCount = null) + { + var itemOptions = GetCompactJsonOptions(jsonOptions); + var visibleItems = resultCount.HasValue + ? itemList.Take(resultCount.Value) + : itemList; writer.Write("{\"version\":\"2.1.0\",\"runs\":[{\"tool\":{\"driver\":{\"name\":\"cdidx\",\"informationUri\":\"https://github.com/Widthdom/CodeIndex\",\"rules\":"); WriteJsonArrayInline( - itemList + writer, + visibleItems .Where(item => !string.IsNullOrWhiteSpace(item.RuleId)) .GroupBy(item => item.RuleId, StringComparer.Ordinal) .Select(group => ( @@ -111,7 +151,8 @@ private static void WriteSarif( separator: ","); writer.Write("}},\"results\":"); WriteJsonArrayInline( - itemList, + writer, + visibleItems, (resultWriter, item) => WriteSarifResult(resultWriter, item, item.Level ?? level, itemOptions), separator: ","); if (runProperties is { Count: > 0 }) @@ -121,7 +162,7 @@ private static void WriteSarif( } writer.Write("}]"); WriteActiveSqliteDiagnosticsProperties(writer, itemOptions); - writer.WriteLine('}'); + writer.Write('}'); } private static string GetHighestSarifLevel(IEnumerable levels) @@ -145,9 +186,12 @@ private static string GetHighestSarifLevel(IEnumerable levels) return highest; } - private static void WriteJsonArrayInline(IEnumerable items, Action writeItem, string separator) + private static void WriteJsonArrayInline( + TextWriter writer, + IEnumerable items, + Action writeItem, + string separator) { - var writer = Console.Out; writer.Write('['); var first = true; foreach (var item in items) @@ -160,6 +204,16 @@ private static void WriteJsonArrayInline(IEnumerable items, Action Tags); + + private sealed class Utf8ByteCountingTextWriter : TextWriter + { + public override Encoding Encoding => Encoding.UTF8; + + public int ByteCount { get; private set; } + + public override void Write(char value) + { + Span buffer = stackalloc char[1]; + buffer[0] = value; + ByteCount = checked(ByteCount + Encoding.UTF8.GetByteCount(buffer)); + } + + public override void Write(string? value) + { + if (value != null) + ByteCount = checked(ByteCount + Encoding.UTF8.GetByteCount(value)); + } + } } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 5fa233fd8..9cbeeaa51 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -706,10 +706,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp var queryResults = CollectSearchRecipeQueryResults(reader, selection.Queries, scope, options, userExact, out var total, out _); if (options.OutputFormat == OutputFormatSarif) - { - WriteSearchRecipeSarif(recipe, scope, queryResults, total, options, jsonOptions); - return CommandExitCodes.Success; - } + return WriteSearchRecipeSarif(recipe, scope, queryResults, total, options, jsonOptions); if (options.Json) { @@ -788,7 +785,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp }); } - private static void WriteSearchRecipeSarif( + private static int WriteSearchRecipeSarif( SearchAuditRecipe recipe, SearchRecipeScopeJsonResult scope, IReadOnlyList queryResults, @@ -796,44 +793,6 @@ private static void WriteSearchRecipeSarif( QueryCommandOptions options, JsonSerializerOptions jsonOptions) { - var summary = BuildSearchRecipeRunSummary( - queryResults, - options.Limit, - options.TotalLimit, - total, - options.InvocationContext); - var querySummaries = new JsonArray(); - foreach (var queryResult in queryResults) - { - querySummaries.Add(new JsonObject - { - ["name"] = queryResult.Name, - ["result_count"] = queryResult.Count, - ["result_limit"] = queryResult.ResultLimit, - ["truncated"] = queryResult.Truncated, - ["minimum_omitted_result_count"] = queryResult.MinimumOmittedResultCount, - ["next_cursor"] = queryResult.NextCursor, - }); - } - var runProperties = new JsonObject - { - ["format"] = "audit-recipe", - ["recipe"] = recipe.Name, - ["scope"] = JsonSerializer.SerializeToNode( - scope, - CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeScopeJsonResult), - ["query_count"] = queryResults.Count, - ["result_count"] = total, - ["limit_per_query"] = options.Limit, - ["total_limit"] = JsonValue.Create(options.TotalLimit), - ["queries"] = querySummaries, - ["truncation"] = new JsonObject - { - ["truncated"] = summary.TruncatedQueryCount > 0, - ["truncated_query_count"] = summary.TruncatedQueryCount, - ["minimum_omitted_result_count"] = summary.MinimumOmittedResultCount, - }, - }; var items = new List(total); foreach (var queryResult in queryResults) { @@ -882,9 +841,323 @@ private static void WriteSearchRecipeSarif( } } - WriteSarif(items, jsonOptions, runProperties: runProperties); + var completeProperties = BuildSearchRecipeSarifRunProperties( + recipe, + scope, + queryResults, + options, + jsonOptions, + items, + items.Count); + if (!options.MaxJsonBytes.HasValue) + { + WriteSarif(items, jsonOptions, runProperties: completeProperties); + return CommandExitCodes.Success; + } + + var completeDocumentBytes = GetSarifDocumentUtf8LineByteCount( + items, + jsonOptions, + runProperties: completeProperties); + var byteLimit = options.MaxJsonBytes.Value; + if (completeDocumentBytes <= byteLimit) + { + WriteSarifDocument(Console.Out, items, jsonOptions, "warning", completeProperties); + Console.WriteLine(); + return CommandExitCodes.Success; + } + + if (items.Count == 0) + { + WriteUsageError( + $"audit SARIF output requires {completeDocumentBytes.ToString(CultureInfo.InvariantCulture)} UTF-8 bytes including the final newline, which exceeds --max-json-bytes {byteLimit.ToString(CultureInfo.InvariantCulture)}.", + options, + "Increase --max-json-bytes to at least the reported minimum; no partial SARIF was written."); + return CommandExitCodes.UsageError; + } + + JsonObject? boundedProperties = null; + var emittedResultCount = -1; + var low = 0; + var high = items.Count - 1; + while (low <= high) + { + var candidate = low + ((high - low) / 2); + var firstOmittedResultBytes = GetSarifResultUtf8ByteCount(items[candidate], jsonOptions); + var candidateProperties = BuildSearchRecipeSarifRunProperties( + recipe, + scope, + queryResults, + options, + jsonOptions, + items, + candidate, + completeDocumentBytes, + firstOmittedResultBytes); + var candidateDocumentBytes = GetSarifDocumentUtf8LineByteCount( + items, + jsonOptions, + runProperties: candidateProperties, + resultCount: candidate); + if (candidateDocumentBytes <= byteLimit) + { + emittedResultCount = candidate; + boundedProperties = candidateProperties; + low = candidate + 1; + } + else + { + high = candidate - 1; + } + } + + if (boundedProperties == null) + { + var minimumBoundedDocumentBytes = GetMinimumBoundedSearchRecipeSarifBytes( + recipe, + scope, + queryResults, + options, + jsonOptions, + items, + completeDocumentBytes, + byteLimit); + WriteUsageError( + $"minimum schema-valid bounded audit SARIF output requires {minimumBoundedDocumentBytes.ToString(CultureInfo.InvariantCulture)} UTF-8 bytes including the final newline, which exceeds --max-json-bytes {byteLimit.ToString(CultureInfo.InvariantCulture)}.", + options, + "Increase --max-json-bytes to at least the reported minimum; no partial SARIF was written."); + return CommandExitCodes.UsageError; + } + + Console.WriteLine( + BuildSarifDocument( + items, + jsonOptions, + runProperties: boundedProperties, + resultCount: emittedResultCount)); + return emittedResultCount < items.Count && !options.AllowPartial + ? CommandExitCodes.PartialResult + : CommandExitCodes.Success; + } + + private static JsonObject BuildSearchRecipeSarifRunProperties( + SearchAuditRecipe recipe, + SearchRecipeScopeJsonResult scope, + IReadOnlyList queryResults, + QueryCommandOptions options, + JsonSerializerOptions jsonOptions, + IReadOnlyList items, + int emittedResultCount, + int? minimumCompleteBytes = null, + int? firstOmittedResultBytes = null, + int? byteLimitOverride = null) + { + var summary = BuildSearchRecipeRunSummary( + queryResults, + options.Limit, + options.TotalLimit, + items.Count, + options.InvocationContext); + var bounded = minimumCompleteBytes.HasValue; + var emittedByRule = items + .Take(emittedResultCount) + .GroupBy(item => item.RuleId, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Count(), StringComparer.Ordinal); + var querySummaries = new JsonArray(); + var cursorEligibleQueryNames = new HashSet(StringComparer.Ordinal); + var truncatedQueryCount = 0; + foreach (var queryResult in queryResults) + { + var ruleId = $"{recipe.Name}/{queryResult.Name}"; + var emittedForQuery = bounded && emittedByRule.TryGetValue(ruleId, out var count) + ? count + : bounded + ? 0 + : queryResult.Count; + var omittedByByteBudget = Math.Max(0, queryResult.Count - emittedForQuery); + var truncated = queryResult.Truncated || omittedByByteBudget > 0; + if (truncated) + truncatedQueryCount++; + if (omittedByByteBudget == 0 && !string.IsNullOrWhiteSpace(queryResult.NextCursor)) + cursorEligibleQueryNames.Add(queryResult.Name); + var querySummary = new JsonObject + { + ["name"] = queryResult.Name, + ["result_count"] = emittedForQuery, + ["result_limit"] = queryResult.ResultLimit, + ["truncated"] = truncated, + ["minimum_omitted_result_count"] = queryResult.MinimumOmittedResultCount + omittedByByteBudget, + ["next_cursor"] = omittedByByteBudget == 0 ? queryResult.NextCursor : null, + }; + if (bounded) + { + querySummary["source_result_count"] = queryResult.SourceTotal; + querySummary["source_result_count_authoritative"] = queryResult.SourceTotalAuthoritative; + querySummary["omitted_by_byte_budget"] = omittedByByteBudget; + querySummary["replay_command"] = BuildSearchRecipeSarifReplayCommand( + ruleId, + options, + minimumCompleteBytes!.Value, + includeRecipeQuerySelectors: false); + } + querySummaries.Add(querySummary); + } + + var omittedByByteBudgetTotal = Math.Max(0, items.Count - emittedResultCount); + var runProperties = new JsonObject + { + ["format"] = "audit-recipe", + ["recipe"] = recipe.Name, + ["scope"] = JsonSerializer.SerializeToNode( + scope, + CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeScopeJsonResult), + ["query_count"] = queryResults.Count, + ["result_count"] = emittedResultCount, + ["limit_per_query"] = options.Limit, + ["total_limit"] = JsonValue.Create(options.TotalLimit), + ["queries"] = querySummaries, + ["truncation"] = new JsonObject + { + ["truncated"] = truncatedQueryCount > 0, + ["truncated_query_count"] = truncatedQueryCount, + ["minimum_omitted_result_count"] = summary.MinimumOmittedResultCount + omittedByByteBudgetTotal, + }, + }; + if (!bounded) + return runProperties; + + runProperties["source_result_count"] = queryResults.Sum(query => query.SourceTotal); + runProperties["source_result_count_authoritative"] = queryResults.All(query => query.SourceTotalAuthoritative); + runProperties["cursoring_available"] = cursorEligibleQueryNames.Count > 0; + var recipeSelector = options.RecipeName ?? recipe.Name; + runProperties["replay_command"] = BuildSearchRecipeSarifReplayCommand( + recipeSelector, + options, + minimumCompleteBytes!.Value, + includeRecipeQuerySelectors: true); + runProperties["next_commands"] = BuildSearchRecipeSarifNextCommands( + recipeSelector, + recipe.Name, + queryResults, + cursorEligibleQueryNames, + options, + minimumCompleteBytes.Value); + runProperties["byte_budget"] = new JsonObject + { + ["max_json_bytes"] = byteLimitOverride ?? options.MaxJsonBytes!.Value, + ["max_supported_json_bytes"] = MaxSearchJsonByteLimit, + ["measurement"] = "utf8_bytes_including_final_newline", + ["strategy"] = "omit_whole_results", + ["minimum_complete_bytes"] = minimumCompleteBytes.Value, + ["complete_output_exceeds_max_json_bytes"] = minimumCompleteBytes.Value > MaxSearchJsonByteLimit, + ["emitted_result_count"] = emittedResultCount, + ["omitted_result_count"] = omittedByByteBudgetTotal, + ["first_omitted_result_bytes"] = firstOmittedResultBytes, + ["truncated"] = omittedByByteBudgetTotal > 0, + }; + return runProperties; } + private static int GetMinimumBoundedSearchRecipeSarifBytes( + SearchAuditRecipe recipe, + SearchRecipeScopeJsonResult scope, + IReadOnlyList queryResults, + QueryCommandOptions options, + JsonSerializerOptions jsonOptions, + IReadOnlyList items, + int minimumCompleteBytes, + int requestedByteLimit) + { + var minimum = requestedByteLimit; + var firstOmittedResultBytes = GetSarifResultUtf8ByteCount(items[0], jsonOptions); + for (var attempt = 0; attempt < 8; attempt++) + { + var properties = BuildSearchRecipeSarifRunProperties( + recipe, + scope, + queryResults, + options, + jsonOptions, + items, + emittedResultCount: 0, + minimumCompleteBytes, + firstOmittedResultBytes, + byteLimitOverride: minimum); + var required = GetSarifDocumentUtf8LineByteCount( + items, + jsonOptions, + runProperties: properties, + resultCount: 0); + if (required <= minimum) + return minimum; + minimum = required; + } + return minimum; + } + + private static JsonArray BuildSearchRecipeSarifNextCommands( + string recipeSelector, + string recipeName, + IReadOnlyList queryResults, + IReadOnlySet cursorEligibleQueryNames, + QueryCommandOptions options, + int minimumCompleteBytes) + { + var commands = new JsonArray + { + BuildSearchRecipeSarifReplayCommand( + recipeSelector, + options, + minimumCompleteBytes, + includeRecipeQuerySelectors: true), + }; + foreach (var query in queryResults + .Where(query => cursorEligibleQueryNames.Contains(query.Name)) + .Take(3)) + { + commands.Add(BuildSearchRecipeCompactReplayCommand( + $"{recipeName}/{query.Name}", + options, + query.NextCursor, + resultsOnly: false, + includeRecipeQuerySelectors: false)); + } + return commands; + } + + private static string BuildSearchRecipeSarifReplayCommand( + string recipeSelector, + QueryCommandOptions options, + int maxJsonBytes, + bool includeRecipeQuerySelectors) + { + var args = new List(); + options.InvocationContext.AddRecipeCommandPrefix(args, recipeSelector); + args.Add("--format"); + args.Add(OutputFormatSarif); + if (!string.IsNullOrWhiteSpace(options.CursorValue)) + AddReplayValueOption(args, "--cursor", options.CursorValue); + AddReplayValueOption(args, "--limit", options.Limit.ToString(CultureInfo.InvariantCulture)); + AddSearchRecipeCompactReplayOptions( + args, + options, + includeRecipeQuerySelectors, + includeMaxJsonBytes: false); + if (maxJsonBytes <= MaxSearchJsonByteLimit) + AddReplayValueOption(args, "--max-json-bytes", maxJsonBytes.ToString(CultureInfo.InvariantCulture)); + return string.Join(" ", args.Select(QuoteReplayShellArg)); + } + + internal static string BuildSearchRecipeSarifReplayCommandForTests( + string recipeSelector, + QueryCommandOptions options, + int maxJsonBytes) + => BuildSearchRecipeSarifReplayCommand( + recipeSelector, + options, + maxJsonBytes, + includeRecipeQuerySelectors: true); + private static (int Line, int Column, int? EndColumn) GetSearchRecipeSarifRegion(CompactSearchResult result) { var facet = result.MatchFacets @@ -1097,7 +1370,11 @@ private static string BuildSearchRecipeCompactReplayCommand( : command; } - private static void AddSearchRecipeCompactReplayOptions(List args, QueryCommandOptions options, bool includeRecipeQuerySelectors) + private static void AddSearchRecipeCompactReplayOptions( + List args, + QueryCommandOptions options, + bool includeRecipeQuerySelectors, + bool includeMaxJsonBytes = true) { if (options.DbPathExplicit) AddReplayValueOption(args, "--db", options.DbPath); @@ -1159,7 +1436,7 @@ private static void AddSearchRecipeCompactReplayOptions(List args, Query if (options.TotalLimit.HasValue) AddReplayValueOption(args, "--total-limit", options.TotalLimit.Value.ToString(CultureInfo.InvariantCulture)); AddSearchRecipeRowSelectionReplayOptions(args, options); - if (options.MaxJsonBytes.HasValue) + if (includeMaxJsonBytes && options.MaxJsonBytes.HasValue) AddReplayValueOption(args, "--max-json-bytes", options.MaxJsonBytes.Value.ToString(CultureInfo.InvariantCulture)); if (options.ShowExcluded) args.Add("--show-excluded"); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index 61b737e28..f2576c2ee 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -886,8 +886,13 @@ private static bool SupportsSearchJsonByteLimit(QueryCommandOptions options) { if (!options.Json) return false; - if (options.OutputFormat is OutputFormatCount or OutputFormatCompact or OutputFormatGrouped or OutputFormatIssueDrafts) + if (options.OutputFormat is OutputFormatCount + or OutputFormatCompact + or OutputFormatGrouped + or OutputFormatIssueDrafts) return true; + if (options.OutputFormat == OutputFormatSarif) + return options.RecipeName != null; if (options.OutputFormat == OutputFormatJson) return options.JsonOutputFormat is JsonOutputFormatNdjson or JsonOutputFormatArray; return false; diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerAuditSarifIssue4903Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerAuditSarifIssue4903Tests.cs new file mode 100644 index 000000000..28c658297 --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerAuditSarifIssue4903Tests.cs @@ -0,0 +1,403 @@ +using System.Text; +using System.Text.Json; +using CodeIndex.Cli; + +namespace CodeIndex.Tests; + +public partial class QueryCommandRunnerTests +{ + [Fact] + public void RunSearch_RecipeSarifMaxJsonBytesUsesExactUtf8BudgetAndWholeResults_Issue4903() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_audit_sarif_bytes_4903"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var index = 0; index < 8; index++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"src/日本語/診断猫{index:D2}.cs", + "csharp", + $$""" + public sealed class 診断猫{{index:D2}} + { + public void Run(Exception ex) + { + JsonDocument.Parse("{}"); + Console.WriteLine(ex.Message); + } + } + """); + } + + string[] args = + [ + "--recipe", "risky-code", + "--include-query", "unbounded-json-parse", + "--include-query", "raw-diagnostic-echo", + "--db", dbPath, + "--format", "sarif", + "--origin", "code", + "--limit", "20", + ]; + var (unboundedExitCode, unboundedStdout, unboundedStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch(args, _jsonOptions)); + var exactBudget = Encoding.UTF8.GetByteCount(unboundedStdout); + + Assert.Equal(CommandExitCodes.Success, unboundedExitCode); + Assert.Equal(string.Empty, unboundedStderr); + Assert.Equal(16, ReadSarifResults(unboundedStdout).Length); + + var exactArgs = args.Concat(["--max-json-bytes", exactBudget.ToString()]).ToArray(); + var (exactExitCode, exactStdout, exactStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch(exactArgs, _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exactExitCode); + Assert.Equal(string.Empty, exactStderr); + Assert.Equal(unboundedStdout, exactStdout); + Assert.Equal(exactBudget, Encoding.UTF8.GetByteCount(exactStdout)); + + var boundedBudget = exactBudget - 1; + var boundedArgs = args.Concat(["--max-json-bytes", boundedBudget.ToString()]).ToArray(); + var (boundedExitCode, boundedStdout, boundedStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch(boundedArgs, _jsonOptions)); + + Assert.Equal(CommandExitCodes.PartialResult, boundedExitCode); + Assert.Equal(string.Empty, boundedStderr); + Assert.InRange(Encoding.UTF8.GetByteCount(boundedStdout), 1, boundedBudget); + using var boundedDocument = JsonDocument.Parse(boundedStdout); + var run = boundedDocument.RootElement.GetProperty("runs")[0]; + var results = run.GetProperty("results").EnumerateArray().ToArray(); + var rules = run.GetProperty("tool").GetProperty("driver").GetProperty("rules").EnumerateArray().ToArray(); + var properties = run.GetProperty("properties"); + var byteBudget = properties.GetProperty("byte_budget"); + var querySummaries = properties.GetProperty("queries").EnumerateArray().ToArray(); + + Assert.InRange(results.Length, 1, 15); + Assert.Equal(2, rules.Length); + Assert.All(results, result => + { + Assert.StartsWith( + "src/日本語/", + result.GetProperty("locations")[0] + .GetProperty("physicalLocation") + .GetProperty("artifactLocation") + .GetProperty("uri") + .GetString(), + StringComparison.Ordinal); + Assert.Contains( + rules, + rule => rule.GetProperty("id").GetString() == result.GetProperty("ruleId").GetString()); + }); + Assert.Equal(2, properties.GetProperty("query_count").GetInt32()); + Assert.Equal(2, querySummaries.Length); + Assert.Equal(results.Length, properties.GetProperty("result_count").GetInt32()); + Assert.Equal(16, properties.GetProperty("source_result_count").GetInt32()); + Assert.False(properties.GetProperty("source_result_count_authoritative").GetBoolean()); + Assert.Equal(boundedBudget, byteBudget.GetProperty("max_json_bytes").GetInt32()); + Assert.Equal("utf8_bytes_including_final_newline", byteBudget.GetProperty("measurement").GetString()); + Assert.Equal("omit_whole_results", byteBudget.GetProperty("strategy").GetString()); + Assert.Equal(exactBudget, byteBudget.GetProperty("minimum_complete_bytes").GetInt32()); + Assert.Equal(16 - results.Length, byteBudget.GetProperty("omitted_result_count").GetInt32()); + Assert.True(byteBudget.GetProperty("truncated").GetBoolean()); + Assert.Contains( + $"--max-json-bytes {exactBudget}", + properties.GetProperty("replay_command").GetString(), + StringComparison.Ordinal); + Assert.All(querySummaries, query => + { + Assert.False(query.GetProperty("source_result_count_authoritative").GetBoolean()); + Assert.True(query.GetProperty("omitted_by_byte_budget").GetInt32() >= 0); + Assert.False(string.IsNullOrWhiteSpace(query.GetProperty("replay_command").GetString())); + }); + + var allowPartialArgs = boundedArgs.Concat(["--allow-partial"]).ToArray(); + var (allowedExitCode, allowedStdout, allowedStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch(allowPartialArgs, _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, allowedExitCode); + Assert.Equal(string.Empty, allowedStderr); + Assert.InRange(Encoding.UTF8.GetByteCount(allowedStdout), 1, boundedBudget); + using var allowedDocument = JsonDocument.Parse(allowedStdout); + Assert.True( + allowedDocument.RootElement.GetProperty("runs")[0] + .GetProperty("properties") + .GetProperty("byte_budget") + .GetProperty("truncated") + .GetBoolean()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_RecipeSarifMaxJsonBytesOmitsOversizedResultAndPreflightsMinimum_Issue4903() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_audit_sarif_oversized_4903"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var oversizedPath = $"src/{new string('猫', 1_500)}.cs"; + TestProjectHelper.InsertIndexedFile( + dbPath, + oversizedPath, + "csharp", + "public sealed class Sample { void Run(Exception ex) { Console.WriteLine(ex.Message); } }"); + string[] args = + [ + "--recipe", "risky-code/raw-diagnostic-echo", + "--db", dbPath, + "--format", "sarif", + "--origin", "code", + "--limit", "10", + "--max-json-bytes", "4000", + ]; + + var (exitCode, stdout, stderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch(args, _jsonOptions)); + + Assert.Equal(CommandExitCodes.PartialResult, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.InRange(Encoding.UTF8.GetByteCount(stdout), 1, 4_000); + using var document = JsonDocument.Parse(stdout); + var run = document.RootElement.GetProperty("runs")[0]; + Assert.Empty(run.GetProperty("results").EnumerateArray()); + Assert.Empty(run.GetProperty("tool").GetProperty("driver").GetProperty("rules").EnumerateArray()); + var properties = run.GetProperty("properties"); + var byteBudget = properties.GetProperty("byte_budget"); + Assert.Equal(0, byteBudget.GetProperty("emitted_result_count").GetInt32()); + Assert.Equal(1, byteBudget.GetProperty("omitted_result_count").GetInt32()); + Assert.True(byteBudget.GetProperty("first_omitted_result_bytes").GetInt32() > 4_000); + Assert.Contains( + "--recipe risky-code/raw-diagnostic-echo", + properties.GetProperty("replay_command").GetString(), + StringComparison.Ordinal); + Assert.Contains( + "--recipe risky-code/raw-diagnostic-echo", + properties.GetProperty("next_commands")[0].GetString(), + StringComparison.Ordinal); + var replayOptions = QueryCommandRunner.ParseArgs(args, jsonDefault: false); + var maximumSupportedReplay = QueryCommandRunner.BuildSearchRecipeSarifReplayCommandForTests( + "risky-code/raw-diagnostic-echo", + replayOptions, + 16 * 1024 * 1024); + var aboveMaximumReplay = QueryCommandRunner.BuildSearchRecipeSarifReplayCommandForTests( + "risky-code/raw-diagnostic-echo", + replayOptions, + (16 * 1024 * 1024) + 1); + Assert.Contains("--max-json-bytes 16777216", maximumSupportedReplay, StringComparison.Ordinal); + Assert.DoesNotContain("--max-json-bytes", aboveMaximumReplay, StringComparison.Ordinal); + + var tooSmallArgs = args + .Take(args.Length - 1) + .Append("1") + .ToArray(); + var (tooSmallExitCode, tooSmallStdout, tooSmallStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch(tooSmallArgs, _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, tooSmallExitCode); + Assert.Equal(string.Empty, tooSmallStdout); + Assert.Contains("minimum schema-valid bounded audit SARIF output requires", tooSmallStderr, StringComparison.Ordinal); + Assert.Contains("no partial SARIF was written", tooSmallStderr, StringComparison.Ordinal); + + var (explicitJsonExitCode, explicitJsonStdout, explicitJsonStderr) = CaptureConsole( + () => ProgramRunner.Run( + [ + "audit", "risky-code/raw-diagnostic-echo", + "--db", dbPath, + "--format", "sarif", + "--json", + "--origin", "code", + "--limit", "10", + "--max-json-bytes", "1000", + ], + appVersion: "1.10.0")); + + Assert.Equal(CommandExitCodes.UsageError, explicitJsonExitCode); + Assert.Equal(string.Empty, explicitJsonStderr); + Assert.InRange(Encoding.UTF8.GetByteCount(explicitJsonStdout), 1, 1_000); + using var explicitJsonDocument = JsonDocument.Parse(explicitJsonStdout); + Assert.Equal("E010_USAGE_ERROR", explicitJsonDocument.RootElement.GetProperty("error_code").GetString()); + Assert.Equal("audit", explicitJsonDocument.RootElement.GetProperty("command").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_RecipeSarifByteBudgetReplayPreservesSelectorAndActiveCursor_Issue4903() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_audit_sarif_cursor_4903"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + for (var index = 0; index < 6; index++) + { + TestProjectHelper.InsertIndexedFile( + dbPath, + $"src/Diagnostic{index:D2}.cs", + "csharp", + $$""" + public sealed class Diagnostic{{index:D2}} + { + public void Run(Exception ex) => Console.WriteLine(ex.Message); + } + """); + } + + string[] firstPageArgs = + [ + "--recipe", "risky-code/raw-diagnostic-echo", + "--db", dbPath, + "--format", "sarif", + "--origin", "code", + "--limit", "2", + ]; + var (firstPageExitCode, firstPageStdout, firstPageStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch(firstPageArgs, _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, firstPageExitCode); + Assert.Equal(string.Empty, firstPageStderr); + using var firstPageDocument = JsonDocument.Parse(firstPageStdout); + var activeCursor = firstPageDocument.RootElement.GetProperty("runs")[0] + .GetProperty("properties") + .GetProperty("queries")[0] + .GetProperty("next_cursor") + .GetString(); + Assert.False(string.IsNullOrWhiteSpace(activeCursor)); + + var secondPageArgs = firstPageArgs.Concat(["--cursor", activeCursor!]).ToArray(); + var (secondPageExitCode, secondPageStdout, secondPageStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch(secondPageArgs, _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, secondPageExitCode); + Assert.Equal(string.Empty, secondPageStderr); + var boundedBudget = Encoding.UTF8.GetByteCount(secondPageStdout) - 1; + + var boundedArgs = secondPageArgs + .Concat(["--max-json-bytes", boundedBudget.ToString()]) + .ToArray(); + var (boundedExitCode, boundedStdout, boundedStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch(boundedArgs, _jsonOptions)); + + Assert.Equal(CommandExitCodes.PartialResult, boundedExitCode); + Assert.Equal(string.Empty, boundedStderr); + Assert.InRange(Encoding.UTF8.GetByteCount(boundedStdout), 1, boundedBudget); + using var boundedDocument = JsonDocument.Parse(boundedStdout); + var properties = boundedDocument.RootElement.GetProperty("runs")[0].GetProperty("properties"); + var query = properties.GetProperty("queries")[0]; + var replayCommand = properties.GetProperty("replay_command").GetString(); + var nextCommands = properties.GetProperty("next_commands").EnumerateArray().ToArray(); + + Assert.False(properties.GetProperty("cursoring_available").GetBoolean()); + Assert.Equal(JsonValueKind.Null, query.GetProperty("next_cursor").ValueKind); + Assert.True(query.GetProperty("omitted_by_byte_budget").GetInt32() > 0); + Assert.Single(nextCommands); + Assert.Contains("--recipe risky-code/raw-diagnostic-echo", replayCommand, StringComparison.Ordinal); + Assert.Contains("--cursor", replayCommand, StringComparison.Ordinal); + Assert.Contains(activeCursor!, replayCommand, StringComparison.Ordinal); + Assert.Equal(replayCommand, nextCommands[0].GetString()); + Assert.Contains(activeCursor!, query.GetProperty("replay_command").GetString(), StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunSearch_EmptyRecipeSarifRequiresExactMinimumWithoutPartialJson_Issue4903() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_audit_sarif_empty_4903"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + string[] args = + [ + "--recipe", "risky-code/raw-diagnostic-echo", + "--db", dbPath, + "--format", "sarif", + "--origin", "code", + "--limit", "10", + ]; + var (unboundedExitCode, unboundedStdout, unboundedStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch(args, _jsonOptions)); + var exactBudget = Encoding.UTF8.GetByteCount(unboundedStdout); + + Assert.Equal(CommandExitCodes.Success, unboundedExitCode); + Assert.Equal(string.Empty, unboundedStderr); + Assert.Empty(ReadSarifResults(unboundedStdout)); + + var (exactExitCode, exactStdout, exactStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch( + args.Concat(["--max-json-bytes", exactBudget.ToString()]).ToArray(), + _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, exactExitCode); + Assert.Equal(string.Empty, exactStderr); + Assert.Equal(unboundedStdout, exactStdout); + + var (underExitCode, underStdout, underStderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch( + args.Concat(["--max-json-bytes", (exactBudget - 1).ToString()]).ToArray(), + _jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, underExitCode); + Assert.Equal(string.Empty, underStdout); + Assert.Contains( + $"requires {exactBudget} UTF-8 bytes including the final newline", + underStderr, + StringComparison.Ordinal); + Assert.Contains("no partial SARIF was written", underStderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void AuditSarifMaxJsonBytesIsExposedByHelpAndCompletionSchema_Issue4903() + { + var flag = Assert.Single( + CliFlagSchema.GetCompletionFlagsForCommand("audit"), + candidate => candidate.Name == "--max-json-bytes"); + Assert.Contains("schema-valid audit SARIF", flag.Description, StringComparison.Ordinal); + + var (printed, stdout, stderr) = CaptureConsole( + () => ConsoleUi.PrintCommandUsage("audit") ? 1 : 0); + Assert.Equal(1, printed); + Assert.Equal(string.Empty, stderr); + Assert.Contains("--max-json-bytes ", stdout, StringComparison.Ordinal); + } + + [Fact] + public void RunSearch_AdHocSarifMaxJsonBytesRemainsRejected_Issue4903() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_adhoc_sarif_bytes_4903"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var (exitCode, stdout, stderr) = CaptureConsole( + () => QueryCommandRunner.RunSearch( + ["Needle", "--db", dbPath, "--format", "sarif", "--max-json-bytes", "4000"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains("--max-json-bytes is only supported with JSON search output", stderr, StringComparison.Ordinal); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + private static JsonElement[] ReadSarifResults(string stdout) + { + using var document = JsonDocument.Parse(stdout); + return document.RootElement.GetProperty("runs")[0].GetProperty("results").EnumerateArray().ToArray(); + } +}