diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 389f38926..133e40152 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -2408,7 +2408,7 @@ catalog and unknown-field errors are generated from that same schema. |---|---| | 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. | +| 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, except that compact results-only replays use the canonical results-only-capable `search --recipe` entry point. 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. | @@ -2426,7 +2426,7 @@ catalog and unknown-field errors are generated from that same schema. | MCP outline pages | MCP `outline` routes `fields`, `sort`, `limit`, and `cursor` through `QueryCommandRunner.BuildOutlinePage`, so its projection aliases, derived sort fields, stable tie-breakers, `page:v1` query fingerprint, and generation validation remain the CLI outline contract rather than a second MCP-specific implementation. The default page is 100 rows and the MCP-wide maximum is 200. `maxBytes` measures the fully enriched serialized `structuredContent`; a binary search rebuilds the page with fewer complete rows and therefore regenerates `next_cursor` from the actual returned count. A budget that cannot hold metadata plus one row fails instead of returning a zero-progress cursor. Default MCP symbol serialization remains backward-compatible, while explicit projection fields use the CLI snake_case names. | | Bounded response edge cases | `impact` applies the cursor offset only to the selected nested collection so definition pages do not repeat or alter caller/fallback mode. Plain `map --compact` preserves its established section arrays and truncation payload; a collection projection is rejected when `--summary-only` or an excluding `--sections` filter would remove it. Explicit definition body fields override compact defaults. Profile and verbose records are moved into `metadata.stream_control_records`. Parser/capture error envelopes use the normal cap when they fit; otherwise a complete `E028_RESPONSE_BUDGET_TOO_SMALL` diagnostic replaces them so machine output never becomes empty or malformed. | | `--count --json` envelope | Count-only JSON for `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `impact`, and `unused` is a single automation-oriented object. It always includes `count`, applied `query_context`, freshness metadata (`indexed_file_count`, `indexed_at`, `freshness_available`), and trust flags `degraded` / `authoritative_count`; commands with matched-file totals also include `files` and the older `file_count` compatibility alias. `file_count` carries the same value as `files`, remains for compatibility, and is not scheduled for removal before the next major release. `unused --count --json` also includes `returned_bucket_counts`, `returned_contract_domain_counts`, and `summary.by_bucket` / `summary.by_confidence` / `summary.by_contract_domain`. `authoritative_count=false` means a readiness or graph/exact trust signal made the count non-authoritative, while the freshness fields describe the indexed snapshot used for the count. | -| Search row selection | Row-producing plain-search and recipe paths share `ApplySearchOutputSelection`: `--first-per-file` and fixed-seed deterministic `--sample` run before the effective per-query / remaining total limit. Sample fetch envelopes are sized from at least the requested sample target. Aggregate/compact query DTOs, plain compact roots, run summaries, issue-draft source DTOs, NDJSON terminals, and bounded array-envelope stream terminals expose `source_total`, `selected_total`, `returned`, `selector_omitted_count`, and `limit_omitted_count`; `source_total_authoritative` / `source_total_lower_bound` distinguish complete populations from bounded observations. Guard filters, origin/facet post-filters, exhausted candidate windows, and recipe file-reject post-filters force lower-bound authority. Their ordered `selectors` entries preserve each stage's input/output/omission counts plus sample size, mode, and seed, while nullable `selection_reason` / `selection_omitted_count` remain compatibility summaries. Bounded plain-search selection is computed once and its selected page is reused by compact/envelope serialization. Search `query_context.row_selectors` records the applied selector configuration. Selection-only omission updates matched/omitted lower bounds without setting `truncated`, `has_more`, or `next_cursor`. When a later limit truncates selected rows, limit truncation remains visible but `next_cursor` is suppressed because raw database cursors cannot preserve selector state; incoming `--cursor` values are rejected with either selector for the same reason. Generated compact and issue-draft replay commands retain the selector. Count, aggregation, named-query, recipe-list, results-only, metadata-free array, unsupported formatted, and summary-only compact shapes reject `--first-per-file` / `--sample`, while every recipe shape rejects grouped-only `--per-file-limit`. | +| Search row selection | Row-producing plain-search and recipe paths share `ApplySearchOutputSelection`: `--first-per-file` and fixed-seed deterministic `--sample` run before the effective per-query / remaining total limit. Sample fetch envelopes are sized from at least the requested sample target. Aggregate/compact query DTOs, plain compact roots, run summaries, issue-draft source DTOs, NDJSON terminals, and bounded array-envelope stream terminals expose `source_total`, `selected_total`, `returned`, `selector_omitted_count`, and `limit_omitted_count`; `source_total_authoritative` / `source_total_lower_bound` distinguish complete populations from bounded observations. Guard filters, origin/facet post-filters, exhausted candidate windows, and recipe file-reject post-filters force lower-bound authority. Their ordered `selectors` entries preserve each stage's input/output/omission counts plus sample size, mode, and seed, while nullable `selection_reason` / `selection_omitted_count` remain compatibility summaries. Bounded plain-search selection is computed once and its selected page is reused by compact/envelope serialization. Search `query_context.row_selectors` records the applied selector configuration. Selection-only omission updates matched/omitted lower bounds without setting `truncated`, `has_more`, or `next_cursor`. When a later limit truncates selected rows, limit truncation remains visible but `next_cursor` is suppressed because raw database cursors cannot preserve selector state; incoming `--cursor` values are rejected with either selector for the same reason. Generated compact and issue-draft replay commands retain the selector. Compact results-only replays always use the results-only-capable canonical `search --recipe` entry point and render structured argv with current-shell-safe quoting. Count, aggregation, named-query, recipe-list, results-only, metadata-free array, unsupported formatted, and summary-only compact shapes reject `--first-per-file` / `--sample`, while every recipe shape rejects grouped-only `--per-file-limit`. | | Search selection edge cases | Issue-draft roots independently retain per-query `selection_accounting`, including zero-draft and exhausted-total-limit queries. Byte-bounded compact and array envelopes rewrite `returned` to the emitted row count while preserving logical `limit_omitted_count`; hard-cap omissions remain separate in `metadata.byte_limit_omitted_count`. | | Ad-hoc search SARIF | `search --format sarif` stores completion metadata on each SARIF run. The run and its single `queries[]` summary report `source_result_count`, `source_result_count_authoritative`, emitted `result_count`, the applied `limit_per_query` / `result_limit`, conservative `minimum_omitted_result_count`, and `truncated` state. Source and emitted counts use the final SARIF result/location unit, including exact-search occurrence expansion. Guarded searches retain their bounded candidate budget instead of failing during a completion recount; their source count is an explicitly non-authoritative lower bound and their truncation state remains conservative. Facet-filtered exact searches use an exhaustive source count rather than the display candidate window. Ad-hoc search does not expose a continuation cursor, so `cursoring_available` is `false` and `next_cursor` is null; a shell-quoted `replay_command` preserves option-like queries and active search controls. The completion vocabulary intentionally matches recipe SARIF, and empty runs carry the same fields with zero counts. | | Ad-hoc issue-draft selection | `search --format issue-drafts` reads the complete filtered ad-hoc population, then applies `--first-per-file`, deterministic `--sample`, and `min(--limit, --total-limit)` in that order. Guarded searches retain their finite candidate inspection contract: `source_total_count` is omitted, `source_minimum_count` reports the observed lower bound, `source_total_count_authoritative=false`, `source_fetch_limit` reports the bounded fetch, and `truncated=true` preserves incomplete-population state. Existing `result_count`, `result_limit`, `omitted_count`, and `truncated` fields describe the returned selection accurately; additive `source_total_count`, `returned_count`, `limit_per_query`, `total_limit`, `first_per_file`, and `sample` fields make the applied contract auditable. Replay commands are serialized from normalized parsed options, use POSIX-safe single-quote escaping, and retain raw/exact/prefix modes, path/language/facet/guard filters, selection controls, evidence formatting, duplicate preflight, and issue hints. | @@ -6573,7 +6573,7 @@ total / cursor / truncation、partial-family metadata、definition body の pagi |---|---| | 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 を維持します。 | +| 委譲された audit command identity | `audit` は内部で recipe 実行を `search` へ委譲しますが、人間向け usage、復旧 hint、生成する replay command では公開された `audit` identity を維持します。ただし compact results-only replay は、results-only に対応する正規の `search --recipe` entry point を使います。明示的な `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` を使用します。 | @@ -6591,7 +6591,7 @@ total / cursor / truncation、partial-family metadata、definition body の pagi | MCP outline page | MCP `outline` は `fields`、`sort`、`limit`、`cursor` を `QueryCommandRunner.BuildOutlinePage` へ渡すため、projection alias、派生 sort field、安定した tie-breaker、`page:v1` query fingerprint、generation validation は第 2 の MCP 固有実装ではなく CLI outline 契約のままです。既定 page は 100 row、MCP 共通の上限は 200 row です。`maxBytes` は enrichment 済みの `structuredContent` 全体を serialize した byte 数で計測し、binary search で完全な row 数を減らして page を再構築するため、`next_cursor` も実際の返却件数から再生成されます。metadata と 1 row が収まらない budget は、進捗しない cursor を返さず失敗します。既定の MCP symbol serialization は後方互換を維持し、明示的な projection field は CLI の snake_case 名を使います。 | | bounded 応答の edge case | `impact` は選択された nested collection だけに cursor offset を適用するため、definition page の重複や caller / fallback mode の変化を防ぎます。通常の `map --compact` は既存の section array と truncation payload を維持し、collection projection が `--summary-only` または除外する `--sections` filter で失われる組み合わせは拒否します。明示的な definition body field は compact default より優先します。profile / verbose record は `metadata.stream_control_records` へ移します。parser / capture failure の error envelope が通常 cap に収まらない場合は完全な `E028_RESPONSE_BUDGET_TOO_SMALL` diagnostic で置き換え、machine output が空または不正 JSON にならないようにします。 | | `--count --json` envelope | `search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`find`、`impact`、`unused` の count-only JSON は単一の自動化向け object です。常に `count`、適用済み `query_context`、freshness metadata(`indexed_file_count`、`indexed_at`、`freshness_available`)、trust flag の `degraded` / `authoritative_count` を含みます。matched-file total を持つ command は `files` と古い互換 alias の `file_count` も含みます。`file_count` は `files` と同じ値を持つ互換用 field として残り、少なくとも次の major release までは削除予定はありません。`unused --count --json` は `returned_bucket_counts`、`returned_contract_domain_counts`、`summary.by_bucket` / `summary.by_confidence` / `summary.by_contract_domain` も含みます。`authoritative_count=false` は readiness または graph/exact trust signal により count が authoritative ではないことを示し、freshness field は count に使った index snapshot を説明します。 | -| search row selection | row を返す plain search / recipe path は `ApplySearchOutputSelection` を共有し、`--first-per-file` と固定 seed の決定的な `--sample` を、有効な query ごとの limit / 残り total limit より先に適用します。sample 用 fetch envelope は少なくとも要求 sample 数を基準に sizing します。aggregate / compact の query DTO、plain compact root、run summary、issue-draft の source DTO、NDJSON terminal、bounded array envelope の stream terminal は `source_total`、`selected_total`、`returned`、`selector_omitted_count`、`limit_omitted_count` を公開し、`source_total_authoritative` / `source_total_lower_bound` で完全な population と bounded な観測を区別します。guard filter、origin / facet の後段 filter、candidate window の枯渇、recipe の file-reject 後段 filter は lower-bound authority にします。適用順の `selectors` entry は各段階の input / output / omission count と sample の size / mode / seed を保持し、nullable な `selection_reason` / `selection_omitted_count` は互換用 summary として維持します。bounded plain-search selection は一度だけ計算し、その selected page を compact / envelope serialize で再利用します。search の `query_context.row_selectors` は適用済み selector 設定を記録します。selection だけによる省略は matched / omitted の lower bound を更新しますが、`truncated`、`has_more`、`next_cursor` は設定しません。後続の limit が選択済み row を truncate する場合、limit truncation は表示しますが raw database cursor は selector state を保持できないため `next_cursor` を抑止し、同じ理由で selector と受け取った `--cursor` の併用も拒否します。compact / issue-draft の生成 replay command は selector を保持します。count、aggregation、named-query、recipe-list、results-only、metadata を持たない array、非対応 formatted、summary-only compact の shape は `--first-per-file` / `--sample` を拒否し、すべての recipe shape は grouped 専用の `--per-file-limit` を拒否します。 | +| search row selection | row を返す plain search / recipe path は `ApplySearchOutputSelection` を共有し、`--first-per-file` と固定 seed の決定的な `--sample` を、有効な query ごとの limit / 残り total limit より先に適用します。sample 用 fetch envelope は少なくとも要求 sample 数を基準に sizing します。aggregate / compact の query DTO、plain compact root、run summary、issue-draft の source DTO、NDJSON terminal、bounded array envelope の stream terminal は `source_total`、`selected_total`、`returned`、`selector_omitted_count`、`limit_omitted_count` を公開し、`source_total_authoritative` / `source_total_lower_bound` で完全な population と bounded な観測を区別します。guard filter、origin / facet の後段 filter、candidate window の枯渇、recipe の file-reject 後段 filter は lower-bound authority にします。適用順の `selectors` entry は各段階の input / output / omission count と sample の size / mode / seed を保持し、nullable な `selection_reason` / `selection_omitted_count` は互換用 summary として維持します。bounded plain-search selection は一度だけ計算し、その selected page を compact / envelope serialize で再利用します。search の `query_context.row_selectors` は適用済み selector 設定を記録します。selection だけによる省略は matched / omitted の lower bound を更新しますが、`truncated`、`has_more`、`next_cursor` は設定しません。後続の limit が選択済み row を truncate する場合、limit truncation は表示しますが raw database cursor は selector state を保持できないため `next_cursor` を抑止し、同じ理由で selector と受け取った `--cursor` の併用も拒否します。compact / issue-draft の生成 replay command は selector を保持します。compact の results-only replay は常に results-only 対応の canonical `search --recipe` entry point を使用し、構造化 argv を current shell 向けに安全に quote します。count、aggregation、named-query、recipe-list、results-only、metadata を持たない array、非対応 formatted、summary-only compact の shape は `--first-per-file` / `--sample` を拒否し、すべての recipe shape は grouped 専用の `--per-file-limit` を拒否します。 | | search selection の edge case | issue-draft の root は query ごとの `selection_accounting` を独立して保持するため、draft が 0 件の場合や total limit を使い切った query も accounting を失いません。byte 上限付き compact / array envelope は `returned` を実際の出力 row 数へ更新し、論理的な `limit_omitted_count` を保持します。hard cap による省略は `metadata.byte_limit_omitted_count` で別に報告します。 | | ad-hoc search SARIF | `search --format sarif` は completion metadata を SARIF の各 run に格納します。run と単一の `queries[]` summary は `source_result_count`、`source_result_count_authoritative`、出力済み `result_count`、適用された `limit_per_query` / `result_limit`、保守的な `minimum_omitted_result_count`、`truncated` state を返します。source / emitted count は exact search の occurrence 展開を含む最終的な SARIF result / location 単位を使用します。guard 付き search は completion の再計数で失敗せず bounded candidate budget を維持し、source count を明示的に non-authoritative な lower bound として返して truncation state を保守的に保ちます。facet filter 付き exact search は表示用 candidate window ではなく exhaustive な source count を使います。ad-hoc search は継続 cursor を公開しないため、`cursoring_available` は `false`、`next_cursor` は null となり、shell quote 済みの `replay_command` が option のような query と有効な search control を保持します。completion vocabulary は意図的に recipe SARIF と共通化し、空 run も count が 0 の同じ field を保持します。 | | ad-hoc issue-draft selection | `search --format issue-drafts` は filter 済みの ad-hoc 母集団全体を読み、`--first-per-file`、決定的な `--sample`、`min(--limit, --total-limit)` の順に適用します。guard 付き検索は有限の candidate inspection 契約を維持し、`source_total_count` を省略し、観測下限を `source_minimum_count`、非 authoritative 状態を `source_total_count_authoritative=false`、bounded fetch を `source_fetch_limit` で報告し、母集団が未完了であることを `truncated=true` で保持します。既存の `result_count`、`result_limit`、`omitted_count`、`truncated` field は返却 selection を正確に表し、additive な `source_total_count`、`returned_count`、`limit_per_query`、`total_limit`、`first_per_file`、`sample` field により適用済み契約を監査できます。replay command は正規化済み parse option から serialize し、POSIX-safe な単一引用符 escape を使い、raw / exact / prefix mode、path / language / facet / guard filter、selection control、evidence formatting、duplicate preflight、issue hint を維持します。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 5bf45e37e..f43f79939 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -28,6 +28,7 @@ Use the full suite by default. Use targeted filters only while iterating locally - Path-containment coverage for #5091 pairs platform-independent `PathCasingTests` seams for mixed parent-namespace case policies and directory identities with `FileIndexerTests` integration through an internal file symlink. Keep both checks when changing internal-symlink policy: a case-insensitive project mount must not make a distinct case-only sibling in its case-sensitive parent namespace appear internal, while genuine same-namespace aliases remain accepted. - `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` 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. +- Issue #5191 compact audit replay coverage lives in `QueryCommandRunnerIssue5191Tests`. Keep shared single- and multi-query fixtures with metacharacters in recipe names, query names, path filters, database paths, and data directories; compare audit/search compact state, assert POSIX sh and PowerShell rendering from the same structured argv, and execute every underlying per-query cursor and results-only replay while preserving selectors, filters, database/data-directory selection, limits, cursors, and byte budgets. - 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 files with 10,000 markerless declaration lines each must produce no references while staying below language-specific allocation ceilings of 20,000,000 / 12,500,000 / 40,000,000 bytes and the 3-second aggregate primary-target runaway guard. `Extract_ReusedContainerResolverDelegates_RemainLineAndExtractionScoped`, the cancellation follow-up, and the sequential Swift-property case pin synchronous delegate lifetime, extraction-local concurrency, token propagation, and per-line container reset. Keep the broad allocation/time guards on `net8.0`; keep the focused semantic and state-preservation tests cross-target. - Framework: xUnit - Target frameworks: `net8.0` and `net9.0` @@ -1167,6 +1168,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - #5091 の path containment coverage は、親 directory namespace の case policy と directory identity が混在する状況を OS 非依存で再現する `PathCasingTests` の seam と、内部 file symlink を通す `FileIndexerTests` の integration を対にします。internal-symlink policy を変更するときは両方を維持し、case-insensitive な project mount の policy で、case-sensitive な親 namespace にある別 identity の case-only sibling を内部 path と誤認しないこと、および同一 namespace の正当な alias は引き続き受理されることを検証してください。 - `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` と `--total-limit` に隠れた positive query を含む)、limit 適用前の evidence-path count と authority / lower-bound metadata の維持、multibyte path と末尾改行を含む UTF-8 document 全体の計測、完全な row 単位の truncation、型付き `E028` の audit identity、完全版 output contract の維持を検証します。 +- Issue #5191 の compact audit replay coverage は `QueryCommandRunnerIssue5191Tests` が担当します。recipe 名、query 名、path filter、database path、data directory にメタ文字を含む単一 query / 複数 query の共有 fixture を維持し、audit / search の compact state を比較し、同じ構造化 argv からの POSIX sh / PowerShell rendering を検証し、selector、filter、database / data-directory 選択、limit、cursor、byte budget を保持したすべての query 別 cursor replay と results-only replay の underlying argv を実行してください。 - 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 それぞれ10,000行の markerless declaration file では reference が0件のまま、言語別 allocation 上限 20,000,000 / 12,500,000 / 40,000,000 byte未満かつ primary target の合計3秒 runaway guard内であることを検証します。`Extract_ReusedContainerResolverDelegates_RemainLineAndExtractionScoped`、cancellation 後の再実行、連続する Swift property case は、delegate の同期 lifetime、extraction-local な並行性、token 伝播、行ごとの container reset を固定します。広い 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 fd362b994..e4265239c 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1435,7 +1435,7 @@ cdidx search --recipe risky-code --show-excluded --json # include recipe sc cdidx search --recipe risky-code/raw-diagnostic-echo --format compact --limit 20 # summary-first compact JSON with next_cursor cdidx search --recipe risky-code/raw-diagnostic-echo --format sarif --limit 20 # bounded SARIF audit findings cdidx search --recipe risky-code/raw-diagnostic-echo --format compact --cursor -cdidx audit risky-code --results-only --search-fields path,line,query_name,recipe --json=ndjson --max-json-bytes 65536 # minimal audit rows +cdidx search --recipe risky-code --results-only --search-fields path,line,query_name,recipe --json=ndjson --max-json-bytes 65536 # minimal audit rows cdidx search --recipe risky-code --format count --summary-only --max-json-bytes 20000 # compact recipe counts cdidx search --named-query pack="dotnet pack" --named-query push="nuget push" --format compact # named ad hoc batch with compact snippets cdidx search "catch (Exception" --group-by file --count --json # rank broad audit hits by file @@ -5142,7 +5142,7 @@ cdidx search --recipe risky-code --show-excluded --json # recipe scope / ex cdidx search --recipe risky-code/raw-diagnostic-echo --format compact --limit 20 # summary-first compact JSON と next_cursor cdidx search --recipe risky-code/raw-diagnostic-echo --format sarif --limit 20 # 上限付き SARIF audit finding cdidx search --recipe risky-code/raw-diagnostic-echo --format compact --cursor -cdidx audit risky-code --results-only --search-fields path,line,query_name,recipe --json=ndjson --max-json-bytes 65536 # 最小限の audit row +cdidx search --recipe risky-code --results-only --search-fields path,line,query_name,recipe --json=ndjson --max-json-bytes 65536 # 最小限の audit row cdidx search --recipe risky-code --format count --summary-only --max-json-bytes 20000 # compact な recipe count cdidx search --named-query pack="dotnet pack" --named-query push="nuget push" --format compact # 名前付き ad hoc batch と compact snippet cdidx search "catch (Exception" --group-by file --count --json # 広い audit hit を file 別にランク付け diff --git a/changelog.d/unreleased/5191.fixed.md b/changelog.d/unreleased/5191.fixed.md new file mode 100644 index 000000000..2cb742b64 --- /dev/null +++ b/changelog.d/unreleased/5191.fixed.md @@ -0,0 +1,24 @@ +--- +category: fixed +issues: + - 5191 +affected: + - src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerIssue5191Tests.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - DEVELOPER_GUIDE.md + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Compact audit output now emits executable results-only replay commands (#5191)** — generated replays use the canonical `search --recipe` entry point, preserve selectors, filters, cursors, data locations, limits, and byte budgets, and safely quote structured arguments for the active shell. + +## 日本語 + +- **compact audit 出力が実行可能な results-only replay command を生成するようになりました (#5191)** — 生成 replay は canonical な `search --recipe` entry point を使用し、selector、filter、cursor、data location、limit、byte budget を保持しながら、構造化された引数を active shell 向けに安全に quote します。 diff --git a/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs b/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs index 79f1f1612..5cd00c73f 100644 --- a/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs +++ b/src/CodeIndex/Cli/ExcerptRecoveryCommandFormatter.cs @@ -1,6 +1,8 @@ using CodeIndex.Database; using CodeIndex.Diagnostics; +using System.Diagnostics; using System.Globalization; +using System.Runtime.InteropServices; namespace CodeIndex.Cli; @@ -79,6 +81,9 @@ internal static string RenderDisplayCommand(IReadOnlyList argv, Recovery : rendered; } + internal static string RenderDisplayCommandForCurrentShell(IReadOnlyList argv) + => RenderDisplayCommand(argv, ResolveCurrentShell()); + private static List BuildArgv( string path, int startLine, @@ -182,7 +187,118 @@ private static bool IsDotnetHost(string processPath) StringComparison.OrdinalIgnoreCase); private static RecoveryCommandShell ResolveCurrentShell() - => OperatingSystem.IsWindows() ? RecoveryCommandShell.PowerShell : RecoveryCommandShell.PosixSh; + => ResolveShell( + TryGetParentProcessName(), + OperatingSystem.IsWindows(), + hasMsysEnvironment: !string.IsNullOrWhiteSpace(CdidxEnvironment.GetEnvironmentVariable("MSYSTEM"))); + + internal static RecoveryCommandShell ResolveShell( + string? parentProcessName, + bool isWindows, + bool hasMsysEnvironment) + { + var normalizedParent = Path.GetFileNameWithoutExtension(parentProcessName?.Replace('\\', '/') ?? string.Empty) + .ToLowerInvariant(); + if (normalizedParent is "pwsh" or "powershell" or "powershell_ise") + return RecoveryCommandShell.PowerShell; + if (normalizedParent is "sh" or "bash" or "zsh" or "dash" or "ksh" or "fish" or "git-bash") + return RecoveryCommandShell.PosixSh; + + if (hasMsysEnvironment) + return RecoveryCommandShell.PosixSh; + return isWindows ? RecoveryCommandShell.PowerShell : RecoveryCommandShell.PosixSh; + } + + private static string? TryGetParentProcessName() + { + try + { + var parentProcessId = OperatingSystem.IsWindows() + ? TryGetWindowsParentProcessId(Environment.ProcessId) + : GetParentProcessId(); + if (!parentProcessId.HasValue || parentProcessId.Value <= 0) + return null; + + using var parent = Process.GetProcessById(parentProcessId.Value); + return parent.ProcessName; + } + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or System.ComponentModel.Win32Exception) + { + return null; + } + } + + private static int? TryGetWindowsParentProcessId(int processId) + { + if (!OperatingSystem.IsWindows()) + return null; + + var snapshot = CreateToolhelp32Snapshot(SnapshotProcesses, 0); + if (snapshot == InvalidHandleValue) + return null; + + try + { + var entry = new ProcessEntry32 + { + Size = (uint)Marshal.SizeOf(), + }; + if (!Process32First(snapshot, ref entry)) + return null; + + do + { + if (entry.ProcessId == processId) + return checked((int)entry.ParentProcessId); + } + while (Process32Next(snapshot, ref entry)); + return null; + } + finally + { + CloseHandle(snapshot); + } + } + + private const uint SnapshotProcesses = 0x00000002; + private static readonly IntPtr InvalidHandleValue = new(-1); + + [DllImport("libc")] + private static extern int getppid(); + + private static int GetParentProcessId() => getppid(); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool Process32First(IntPtr snapshot, ref ProcessEntry32 entry); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool Process32Next(IntPtr snapshot, ref ProcessEntry32 entry); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool CloseHandle(IntPtr handle); + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + private struct ProcessEntry32 + { + internal uint Size; + internal uint Usage; + internal uint ProcessId; + internal IntPtr DefaultHeapId; + internal uint ModuleId; + internal uint Threads; + internal uint ParentProcessId; + internal int BasePriority; + internal uint Flags; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + internal string ExecutableFile; + } private static string FormatShell(RecoveryCommandShell shell) => shell == RecoveryCommandShell.PowerShell ? "powershell" : "posix-sh"; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs index 470925294..c18326ac1 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs @@ -36,7 +36,8 @@ private static bool TryWriteParseError(QueryCommandOptions options, string comma commandName, commandName, RecipeNameIsPositional: false, - StructuredMachineUsageErrors: false), + StructuredMachineUsageErrors: false, + SupportsRecipeResultsOnlyNdjson: false), jsonOptions); private static bool TryWriteParseError( @@ -417,7 +418,8 @@ private static bool TryWriteUnsupportedOptionError( commandName, commandName, RecipeNameIsPositional: false, - StructuredMachineUsageErrors: false), + StructuredMachineUsageErrors: false, + SupportsRecipeResultsOnlyNdjson: false), cmdArgs, supportedOptions, queryLiteral: queryLiteral, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 8dbaf72ec..90b5fc858 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -1539,9 +1539,30 @@ private static string BuildSearchRecipeCompactReplayCommand( string? cursor, bool resultsOnly, bool includeRecipeQuerySelectors) + { + var args = BuildSearchRecipeCompactReplayArguments( + recipeSelector, + options, + cursor, + resultsOnly, + includeRecipeQuerySelectors); + return ExcerptRecoveryCommandFormatter.RenderDisplayCommandForCurrentShell(args); + } + + private static List BuildSearchRecipeCompactReplayArguments( + string recipeSelector, + QueryCommandOptions options, + string? cursor, + bool resultsOnly, + bool includeRecipeQuerySelectors) { var args = new List(); - options.InvocationContext.AddRecipeCommandPrefix(args, recipeSelector); + options.InvocationContext.AddRecipeCommandPrefix( + args, + recipeSelector, + resultsOnly + ? RecipeReplayOutputCapability.ResultsOnlyNdjson + : RecipeReplayOutputCapability.Default); if (resultsOnly) { args.Add("--json=ndjson"); @@ -1556,10 +1577,33 @@ private static string BuildSearchRecipeCompactReplayCommand( AddReplayValueOption(args, "--cursor", cursor); AddReplayValueOption(args, "--limit", options.Limit.ToString(CultureInfo.InvariantCulture)); AddSearchRecipeCompactReplayOptions(args, options, includeRecipeQuerySelectors); - var command = string.Join(" ", args.Select(QuoteReplayShellArg)); - return resultsOnly && !options.MaxJsonBytes.HasValue - ? command + " --max-json-bytes " - : command; + if (resultsOnly && !options.MaxJsonBytes.HasValue) + AddReplayValueOption(args, "--max-json-bytes", ""); + return args; + } + + internal static ( + IReadOnlyList Argv, + string PosixSh, + string PowerShell, + string CurrentShell) BuildSearchRecipeCompactReplayCommandForTests( + string recipeSelector, + QueryCommandOptions options, + string? cursor, + bool resultsOnly, + bool includeRecipeQuerySelectors) + { + var argv = BuildSearchRecipeCompactReplayArguments( + recipeSelector, + options, + cursor, + resultsOnly, + includeRecipeQuerySelectors); + return ( + argv, + ExcerptRecoveryCommandFormatter.RenderDisplayCommand(argv, RecoveryCommandShell.PosixSh), + ExcerptRecoveryCommandFormatter.RenderDisplayCommand(argv, RecoveryCommandShell.PowerShell), + ExcerptRecoveryCommandFormatter.RenderDisplayCommandForCurrentShell(argv)); } private static void AddSearchRecipeCompactReplayOptions( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 74231c430..28eb4e5b5 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -247,21 +247,46 @@ public sealed record SearchNamedQuery(string Name, string Query); public readonly record struct DependencyCycleCursor(int Offset, string Fingerprint); +internal enum RecipeReplayOutputCapability +{ + Default, + ResultsOnlyNdjson, +} + internal sealed record QueryCommandInvocationContext( string CommandName, string UsageCommandName, string ValidationCommandName, bool RecipeNameIsPositional, - bool StructuredMachineUsageErrors) + bool StructuredMachineUsageErrors, + bool SupportsRecipeResultsOnlyNdjson) { internal static QueryCommandInvocationContext Search { get; } = - new("search", "search", "search", RecipeNameIsPositional: false, StructuredMachineUsageErrors: false); + new( + "search", + "search", + "search", + RecipeNameIsPositional: false, + StructuredMachineUsageErrors: false, + SupportsRecipeResultsOnlyNdjson: true); internal static QueryCommandInvocationContext Recipes { get; } = - new("recipes", "recipes", "recipes", RecipeNameIsPositional: false, StructuredMachineUsageErrors: false); + new( + "recipes", + "recipes", + "recipes", + RecipeNameIsPositional: false, + StructuredMachineUsageErrors: false, + SupportsRecipeResultsOnlyNdjson: false); internal static QueryCommandInvocationContext Audit { get; } = - new("audit", "audit", "search", RecipeNameIsPositional: true, StructuredMachineUsageErrors: true); + new( + "audit", + "audit", + "search", + RecipeNameIsPositional: true, + StructuredMachineUsageErrors: true, + SupportsRecipeResultsOnlyNdjson: false); internal string UsageLine => ConsoleUi.GetUsageLine(UsageCommandName) @@ -284,11 +309,18 @@ internal sealed record QueryCommandInvocationContext( internal string RecipeCursorSelectorSyntax => RecipeNameIsPositional ? "cdidx audit /" : "--recipe /"; - internal void AddRecipeCommandPrefix(List args, string recipeSelector) + internal void AddRecipeCommandPrefix( + List args, + string recipeSelector, + RecipeReplayOutputCapability outputCapability = RecipeReplayOutputCapability.Default) { + var replayContext = outputCapability == RecipeReplayOutputCapability.ResultsOnlyNdjson + && !SupportsRecipeResultsOnlyNdjson + ? Search + : this; args.Add("cdidx"); - args.Add(CommandName); - if (!RecipeNameIsPositional) + args.Add(replayContext.CommandName); + if (!replayContext.RecipeNameIsPositional) args.Add("--recipe"); args.Add(recipeSelector); } diff --git a/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs b/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs index 9728b3c8a..f1fdedfc8 100644 --- a/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs +++ b/tests/CodeIndex.Tests/ExcerptRecoveryCommandFormatterTests.cs @@ -190,4 +190,27 @@ public void ResolveInvocationPrefix_PreservesDotnetAssemblyOrNativeApphost_Issue ["cdidx"], ExcerptRecoveryCommandFormatter.ResolveInvocationPrefix(null, assemblyPath)); } + + [Theory] + [InlineData("pwsh", false, true, true)] + [InlineData("PowerShell.EXE", false, false, true)] + [InlineData("bash", true, false, false)] + [InlineData("git-bash.exe", true, false, false)] + [InlineData("testhost", true, true, false)] + [InlineData("testhost", false, true, false)] + [InlineData("testhost", true, false, true)] + [InlineData("testhost", false, false, false)] + public void ResolveShell_PrefersInvokingShellOverOperatingSystem_Issue5191( + string parentProcessName, + bool isWindows, + bool hasMsysEnvironment, + bool expectPowerShell) + { + Assert.Equal( + expectPowerShell ? RecoveryCommandShell.PowerShell : RecoveryCommandShell.PosixSh, + ExcerptRecoveryCommandFormatter.ResolveShell( + parentProcessName, + isWindows, + hasMsysEnvironment)); + } } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerIssue5191Tests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5191Tests.cs new file mode 100644 index 000000000..418445a4f --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerIssue5191Tests.cs @@ -0,0 +1,321 @@ +using System.Text.Json; +using CodeIndex.Cli; + +namespace CodeIndex.Tests; + +[Collection("Console sensitive")] +public sealed class QueryCommandRunnerIssue5191Tests +{ + private const string RecipeName = "replay recipe $5191"; + private const string AlphaQueryName = "alpha query"; + private const string BetaQueryName = "beta '$query&"; + private const string ExcludedQueryName = "excluded; query"; + private const string PathFilter = "src/space $folder/**"; + private const string ExcludePathFilter = "src/space $folder/ignored &/**"; + + [Fact] + public void CompactAuditAndSearchNextCommands_ExecuteWithEquivalentState_Issue5191() + { + using var project = CreateFixture(); + using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); + env.Set(SearchAuditRecipes.RecipePathsEnvironmentVariable, project.RecipePath); + + var commonArgs = new[] + { + "--db", project.DbPath, + "--format", "compact", + "--limit", "1", + "--path", PathFilter, + "--exclude-path", ExcludePathFilter, + "--lang", "csharp", + "--exclude-tests", + "--include-query", AlphaQueryName, + "--include-query", BetaQueryName, + "--exclude-query", ExcludedQueryName, + }; + using var audit = RunCompact(["audit", RecipeName, .. commonArgs]); + using var search = RunCompact(["search", "--recipe", RecipeName, .. commonArgs]); + + Assert.Equal(GetQueryResultPaths(audit.RootElement), GetQueryResultPaths(search.RootElement)); + Assert.Equal(2, audit.RootElement.GetProperty("truncation").GetProperty("truncated_query_count").GetInt32()); + Assert.Equal(2, search.RootElement.GetProperty("truncation").GetProperty("truncated_query_count").GetInt32()); + + AssertEveryNextCommandExecutes( + audit.RootElement, + BuildReplayOptions(["--recipe", RecipeName, .. commonArgs], QueryCommandInvocationContext.Audit), + expectedDataOption: "--db"); + AssertEveryNextCommandExecutes( + search.RootElement, + BuildReplayOptions(["--recipe", RecipeName, .. commonArgs], QueryCommandInvocationContext.Search), + expectedDataOption: "--db"); + } + + [Fact] + public void CompactAuditSingleQueryReplay_PreservesDataDirAndExplicitByteBudget_Issue5191() + { + using var project = CreateFixture(); + using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); + env.Set(SearchAuditRecipes.RecipePathsEnvironmentVariable, project.RecipePath); + var selector = $"{RecipeName}/{AlphaQueryName}"; + var commonArgs = new[] + { + "--data-dir", project.DataDir, + "--format", "compact", + "--limit", "1", + "--path", PathFilter, + "--exclude-path", ExcludePathFilter, + "--max-json-bytes", "1048576", + }; + using var audit = RunCompact(["audit", selector, .. commonArgs]); + + Assert.Equal(1, audit.RootElement.GetProperty("truncation").GetProperty("truncated_query_count").GetInt32()); + AssertEveryNextCommandExecutes( + audit.RootElement, + BuildReplayOptions(["--recipe", selector, .. commonArgs], QueryCommandInvocationContext.Audit), + expectedDataOption: "--data-dir"); + } + + private static JsonDocument RunCompact(string[] args) + { + var (exitCode, stdout, stderr) = QueryCommandTestSupport.CaptureConsole(() => + ProgramRunner.Run(args, QueryCommandTestSupport.JsonOptions, "1.0.0-test")); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + return QueryCommandTestSupport.ParseJsonOutput(stdout); + } + + private static QueryCommandOptions BuildReplayOptions( + string[] args, + QueryCommandInvocationContext invocationContext) + { + var options = QueryCommandRunner.ParseArgs( + args, + jsonDefault: false, + allowNamedQuery: true, + allowIssueDraftsFormat: true, + applySearchSourceDefaults: true); + options.InvocationContext = invocationContext; + Assert.Null(options.ParseError); + return options; + } + + private static void AssertEveryNextCommandExecutes( + JsonElement root, + QueryCommandOptions options, + string expectedDataOption) + { + var queryStates = root.GetProperty("truncation").GetProperty("queries").EnumerateArray() + .Select(query => new ReplayQueryState( + query.GetProperty("name").GetString()!, + query.GetProperty("next_cursor").GetString(), + root.GetProperty("queries").EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == query.GetProperty("name").GetString()) + .GetProperty("results")[0] + .GetProperty("path") + .GetString()!)) + .ToList(); + var emittedCommands = root.GetProperty("next_commands").EnumerateArray() + .Select(command => command.GetString()!) + .ToList(); + var expectedReplays = queryStates + .Where(query => query.Cursor is not null) + .Take(3) + .Select(query => ( + Query: (ReplayQueryState?)query, + Replay: QueryCommandRunner.BuildSearchRecipeCompactReplayCommandForTests( + $"{RecipeName}/{query.Name}", + options, + query.Cursor, + resultsOnly: false, + includeRecipeQuerySelectors: false))) + .ToList(); + var resultsOnlySelector = queryStates.Count == 1 + ? $"{RecipeName}/{queryStates[0].Name}" + : RecipeName; + expectedReplays.Add(( + Query: null, + Replay: QueryCommandRunner.BuildSearchRecipeCompactReplayCommandForTests( + resultsOnlySelector, + options, + cursor: null, + resultsOnly: true, + includeRecipeQuerySelectors: queryStates.Count != 1))); + + Assert.Equal(expectedReplays.Count, emittedCommands.Count); + for (var index = 0; index < expectedReplays.Count; index++) + { + var (query, replay) = expectedReplays[index]; + Assert.Equal(replay.CurrentShell, emittedCommands[index]); + Assert.Equal("cdidx", replay.Argv[0]); + Assert.Contains(expectedDataOption, replay.Argv); + Assert.Contains(PathFilter, replay.Argv); + Assert.Contains(ExcludePathFilter, replay.Argv); + Assert.Contains($"'{PathFilter}'", replay.PosixSh, StringComparison.Ordinal); + Assert.Contains($"'{PathFilter}'", replay.PowerShell, StringComparison.Ordinal); + var dataOptionIndex = replay.Argv.ToList().IndexOf(expectedDataOption); + Assert.True(dataOptionIndex >= 0 && dataOptionIndex + 1 < replay.Argv.Count); + var dataValue = replay.Argv[dataOptionIndex + 1]; + Assert.Contains($"'{dataValue}'", replay.PosixSh, StringComparison.Ordinal); + Assert.Contains($"'{dataValue}'", replay.PowerShell, StringComparison.Ordinal); + + if (query is not null) + { + Assert.Contains(query.Cursor!, replay.Argv); + Assert.Contains($"{RecipeName}/{query.Name}", replay.Argv); + if (query.Name == BetaQueryName) + { + Assert.Contains("beta '\\''$query&", replay.PosixSh, StringComparison.Ordinal); + Assert.Contains("beta ''$query&", replay.PowerShell, StringComparison.Ordinal); + } + AssertReplayReturnsNextPage(replay.Argv, query); + } + else + { + Assert.Equal("search", replay.Argv[1]); + Assert.Equal("--recipe", replay.Argv[2]); + Assert.Contains("--json=ndjson", replay.Argv); + Assert.Contains("--results-only", replay.Argv); + Assert.DoesNotContain("cdidx audit", replay.CurrentShell, StringComparison.OrdinalIgnoreCase); + AssertResultsOnlyReplayReturnsRows(replay.Argv, queryStates); + } + } + } + + private static void AssertReplayReturnsNextPage( + IReadOnlyList argv, + ReplayQueryState query) + { + var (exitCode, stdout, stderr) = RunReplay(argv); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = QueryCommandTestSupport.ParseJsonOutput(stdout); + var replayedQuery = Assert.Single(document.RootElement.GetProperty("queries").EnumerateArray()); + Assert.Equal(query.Name, replayedQuery.GetProperty("name").GetString()); + var replayedResult = Assert.Single(replayedQuery.GetProperty("results").EnumerateArray()); + Assert.NotEqual(query.FirstPagePath, replayedResult.GetProperty("path").GetString()); + } + + private static void AssertResultsOnlyReplayReturnsRows( + IReadOnlyList argv, + IReadOnlyList queryStates) + { + var (exitCode, stdout, stderr) = RunReplay(argv); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + var rows = stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonDocument.Parse(line)) + .ToList(); + try + { + Assert.Equal(queryStates.Count, rows.Count); + Assert.All(rows, row => + { + Assert.False(row.RootElement.TryGetProperty("terminal_record", out _)); + Assert.Equal(RecipeName, row.RootElement.GetProperty("recipe").GetString()); + Assert.Contains( + row.RootElement.GetProperty("query_name").GetString(), + queryStates.Select(query => query.Name)); + }); + } + finally + { + foreach (var row in rows) + row.Dispose(); + } + } + + private static (int ExitCode, string Stdout, string Stderr) RunReplay(IReadOnlyList argv) + { + var executableArgs = argv.Skip(1) + .Select(argument => argument == "" ? "1048576" : argument) + .ToArray(); + return QueryCommandTestSupport.CaptureConsole(() => + ProgramRunner.Run(executableArgs, QueryCommandTestSupport.JsonOptions, "1.0.0-test")); + } + + private static List GetQueryResultPaths(JsonElement root) + => root.GetProperty("queries").EnumerateArray() + .SelectMany(query => query.GetProperty("results").EnumerateArray()) + .Select(result => result.GetProperty("path").GetString()!) + .OrderBy(path => path, StringComparer.Ordinal) + .ToList(); + + private static FixtureScope CreateFixture() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx issue 5191 $meta"); + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var recipePath = Path.Combine(projectRoot, "external recipes $5191.json"); + File.WriteAllText( + recipePath, + $$""" + { + "recipes": [ + { + "name": "{{RecipeName}}", + "description": "Exercise executable compact replay commands.", + "queries": [ + { + "name": "{{AlphaQueryName}}", + "query": "AlphaNeedle5191", + "description": "Find alpha replay fixtures." + }, + { + "name": "beta '$query&", + "query": "BetaNeedle5191", + "description": "Find beta replay fixtures." + }, + { + "name": "{{ExcludedQueryName}}", + "query": "ExcludedNeedle5191", + "description": "Prove excluded selectors remain excluded." + } + ] + } + ] + } + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/space $folder/alpha one.cs", + "csharp", + "public class AlphaOne { string Value = \"AlphaNeedle5191\"; }\n"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/space $folder/alpha & two.cs", + "csharp", + "public class AlphaTwo { string Value = \"AlphaNeedle5191\"; }\n"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/space $folder/beta one.cs", + "csharp", + "public class BetaOne { string Value = \"BetaNeedle5191\"; }\n"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/space $folder/beta ' two.cs", + "csharp", + "public class BetaTwo { string Value = \"BetaNeedle5191\"; }\n"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/space $folder/ignored &/ignored.cs", + "csharp", + "public class Ignored { string Value = \"AlphaNeedle5191 BetaNeedle5191\"; }\n"); + return new FixtureScope(projectRoot, dbPath, Path.GetDirectoryName(dbPath)!, recipePath); + } + + private sealed record ReplayQueryState(string Name, string? Cursor, string FirstPagePath); + + private sealed class FixtureScope( + string ProjectRoot, + string DbPath, + string DataDir, + string RecipePath) : IDisposable + { + internal string ProjectRoot { get; } = ProjectRoot; + internal string DbPath { get; } = DbPath; + internal string DataDir { get; } = DataDir; + internal string RecipePath { get; } = RecipePath; + + public void Dispose() => TestProjectHelper.DeleteDirectory(ProjectRoot); + } +} diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 4a32c6228..149f6ba69 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -12446,7 +12446,7 @@ public void RunAudit_UsageErrorsRetainPublicCommandContext_Issue4875() } [Fact] - public void RunAudit_RecipeReplayCommandsRetainPublicCommandContext_Issue4875() + public void RunAudit_RecipeReplayCommandsUseCapabilityAppropriatePublicCommandContext_Issues4875And5191() { var projectRoot = TestProjectHelper.CreateTempProject("cdidx_audit_command_context"); try @@ -12512,12 +12512,21 @@ public void RunAudit_RecipeReplayCommandsRetainPublicCommandContext_Issue4875() .Select(item => item.GetString()) .ToArray(); Assert.NotEmpty(nextCommands); - Assert.All( + var resultsOnlyReplay = Assert.Single( nextCommands, + command => command?.Contains("--results-only", StringComparison.Ordinal) == true); + Assert.StartsWith( + "cdidx search --recipe risky-code/raw-diagnostic-echo", + resultsOnlyReplay, + StringComparison.Ordinal); + Assert.Contains("--json=ndjson", resultsOnlyReplay, StringComparison.Ordinal); + var commandContextReplays = nextCommands + .Where(command => command != resultsOnlyReplay) + .ToArray(); + Assert.NotEmpty(commandContextReplays); + Assert.All( + commandContextReplays, command => Assert.StartsWith("cdidx audit risky-code/raw-diagnostic-echo", command, StringComparison.Ordinal)); - Assert.DoesNotContain( - nextCommands, - command => command?.Contains("cdidx search --recipe", StringComparison.Ordinal) == true); } finally {