From 32811ec5511f69de9a3bd4bb56c3b7c3641d0a41 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 14:57:52 +0900 Subject: [PATCH 1/3] Preserve audit alias command identity (#4875) --- DEVELOPER_GUIDE.md | 2 + README.md | 4 + TESTING_GUIDE.md | 4 + changelog.d/unreleased/4875.fixed.md | 26 +++ src/CodeIndex/Cli/CommandErrorWriter.cs | 2 + .../Cli/QueryCommandRunner.Ndjson.cs | 6 +- .../QueryCommandRunner.OptionValidation.cs | 71 +++++-- .../Cli/QueryCommandRunner.Search.cs | 190 ++++++++++-------- .../Cli/QueryCommandRunner.SearchAliases.cs | 16 +- .../Cli/QueryCommandRunner.SearchRecipes.cs | 70 +++---- .../Cli/QueryCommandRunner.Validation.cs | 41 ++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 41 ++++ .../QueryCommandRunnerSearchTests.cs | 150 ++++++++++++++ 13 files changed, 484 insertions(+), 139 deletions(-) create mode 100644 changelog.d/unreleased/4875.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 693a24c7f..86d842290 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1676,6 +1676,7 @@ access. |---|---| | Human-readable default | Query commands (`search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `excerpt`, `map`, `inspect`, `outline`, `suggestions`) default to **human-readable output**. | | `--json` | Emits JSON lines output, one JSON object per line, designed for easy parsing by AI agents. | +| Delegated audit command identity | `audit` delegates recipe execution to `search` internally while retaining the public `audit` identity in human usage, recovery hints, and generated replay commands. Explicit `audit --json` usage errors emit stable versioned command-error objects with `command: "audit"` and no human-readable `usage`; direct `search` errors retain `search` identity. | | `definition --json` miss | A default-format definition lookup that finds no matching symbol emits the shared versioned `E018_QUERY_NOT_FOUND` command-error object and exits `2`, with or without `--body`; it never succeeds with empty stdout. Bounded-envelope controls move the object to `metadata.error` and keep `results` empty instead of projecting it as a location row. The object is preflighted against `--max-json-bytes`; an impossible cap returns a usage error without oversized stdout. `--count` still returns its structured zero-count object, and explicit location formats retain their existing format-specific empty-result output. | | Raw discovery JSON shape | `symbols` and `files` build each result row through the same DTO path for array, NDJSON, and envelope output. `symbols --json=array` therefore preserves `exact_index_available` just like NDJSON. Every cardinality and `--max-json-bytes` path keeps the selected flat shape: zero-result NDJSON is an empty stream, `--json=array` is always an array, and byte-capped output omits whole trailing rows without changing the top-level type. Bounded projections keep rows in `results`, pagination facts in `metadata`, and exact-query readiness in `metadata.response_context`; they never reuse a result row as response context. Use `--format compact` or `--json-envelope` when truncation and freshness metadata must accompany the results. | | Generated-code filtering metadata | DB-backed discovery `query_context` always reports `include_generated`, `generated_code_policy`, and `generated_file_filter_available`. The `files --count --json` and every JSON `map` summary (including `issue-drafts`) also report `generated_file_count_excluded` and `generated_file_count_excluded_authoritative`. The excluded count is `0` when generated files are included. For a legacy DB without `files.generated` when filtering is requested, the policy is `unavailable`, the count is `null`, and the authoritative/available flags are `false` rather than claiming that an unavailable filter ran; explicit `--include-generated` remains `include` with an authoritative excluded count of `0`. Byte-capped and uncapped raw discovery arrays retain SQLite trust diagnostics even when the query returns no result rows. | @@ -5043,6 +5044,7 @@ help はすべてこのレジストリを参照します。field 名は大文字 |---|---| | human-readable default | query command(`search`、`definition`、`references`、`callers`、`callees`、`symbols`、`files`、`excerpt`、`map`、`inspect`、`outline`、`suggestions`)は既定で**人間向け出力**です。 | | `--json` | JSON lines output(1 行 1 JSON object)に切り替えます。AI agent が容易に parse できるよう設計されています。 | +| 委譲された audit command identity | `audit` は内部で recipe 実行を `search` へ委譲しますが、人間向け usage、復旧 hint、生成する replay command では公開された `audit` identity を維持します。明示的な `audit --json` の usage error は `command: "audit"` を持つ安定した version 付き command-error object を出力し、人間向けの `usage` を含めません。直接の `search` error は `search` identity を維持します。 | | `definition --json` の未検出 | 既定 format の definition lookup で一致する symbol がない場合、`--body` の有無にかかわらず、共通の versioned `E018_QUERY_NOT_FOUND` command-error object を出力して終了コード `2` を返します。空の stdout のまま成功することはありません。bounded-envelope control の使用時は object を location row として projection せず `metadata.error` に移し、`results` は空のままにします。この object は `--max-json-bytes` に対して事前検査され、収まらない上限では oversized stdout を出さず usage error を返します。`--count` は引き続き構造化された 0 件 object を返し、明示的な location format も既存の format 固有の empty-result output を維持します。 | | raw discovery JSON shape | `symbols` と `files` は、array、NDJSON、envelope の各出力で同じ DTO 経路から result row を構築します。そのため `symbols --json=array` も NDJSON と同様に `exact_index_available` を保持します。結果件数や `--max-json-bytes` の有無にかかわらず選択した flat shape を維持し、0 件の NDJSON は空 stream、`--json=array` は常に array となり、byte cap 到達時は top-level type を変えずに末尾の完全な row を省略します。bounded projection は row を `results`、pagination fact を `metadata`、exact-query readiness を `metadata.response_context` に保持し、result row を response context として再利用しません。truncation / freshness metadata も結果と一緒に必要な場合は `--format compact` または `--json-envelope` を使用します。 | | generated-code filtering metadata | DB-backed discovery の `query_context` は常に `include_generated`、`generated_code_policy`、`generated_file_filter_available` を返します。`files --count --json` と `issue-drafts` を含むすべての JSON `map` summary は、`generated_file_count_excluded` と `generated_file_count_excluded_authoritative` も返します。generated file を含める場合、除外数は `0` です。`files.generated` が無い legacy DB で filter が要求された場合、未実行の filter を実行済みと誤認させないよう、policy は `unavailable`、count は `null`、authoritative / available flag は `false` になります。明示的な `--include-generated` は `include` のままで、authoritative な除外数 `0` を返します。byte cap の有無にかかわらず、raw discovery array は query result row が 0 件でも SQLite trust diagnostics を維持します。 | diff --git a/README.md b/README.md index 1f3923b3e..856c0ccb8 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,8 @@ The default NDJSON output of `search`, `symbols`, and `files` always ends with a Recipe/audit compact pagination returns an opaque `next_cursor`. Replay it unchanged with the same child query and filters as `--cursor `; a search-score cursor can begin with `-`, and that leading minus is part of the cursor value. +`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. + 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. @@ -536,6 +538,8 @@ JSON 形式では `--max-json-bytes` を文書全体の UTF-8 byte cap として recipe / audit の compact pagination は opaque な `next_cursor` を返します。同じ child query と filter を指定し、値を変更せず `--cursor ` として再利用してください。search-score cursor は `-` で始まる場合があり、その先頭のマイナス記号も 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 を維持します。 + 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 74c1f2af2..71a693830 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -522,6 +522,8 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `QueryCommandRunnerTests.RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862`, `RunSearch_RecipeQuerySuggestionHandlesZeroOneAndManyQueries_Issue4862`, and `RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueries_Issue4862` keep human/JSON usage errors, canonical names, current/deprecated aliases, deterministic typo correction, active-recipe isolation, normalized filter preservation, and shell-safe replay quoting in one recipe-selection contract. Suggestions and replay selectors must remain canonical names from the active recipe even when an alias is the nearest match. Include/exclude typo recovery must preserve selector semantics, unresolved repeated selectors, and raw FTS mode. Missing close matches and recipe-name corrections with child selectors must not offer a replay that broadens the selection, while aliases that collide with canonical names or multiple query owners must be removed with bounded diagnostics. +- `QueryCommandRunnerTests.RunAudit_UsageErrorsRetainPublicCommandContext_Issue4875` and `RunAudit_RecipeReplayCommandsRetainPublicCommandContext_Issue4875` + keep audit-alias parse, validation, selection, and conflict errors on `audit` usage; require explicit `--json` failures to expose `command: "audit"` without human-readable usage; preserve direct `search` controls; and keep issue-draft and compact replay commands on the public positional audit syntax. - `QueryCommandRunnerTests.RunReferences_ExactJson_StylesheetAndSqlFixturesShareIndexedWorkspace` keeps SCSS variable/mixin/extend references beside SQL multiline MERGE hints, non-ASCII identifiers, quoted-string masking, and temporary-table body boundaries in one multi-file workspace with one CLI index. Use language-specific queries and unique per-file sentinels so count, kind, and line assertions remain independently diagnostic. - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` @@ -1439,6 +1441,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `QueryCommandRunnerTests.RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862`、`RunSearch_RecipeQuerySuggestionHandlesZeroOneAndManyQueries_Issue4862`、`RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueries_Issue4862` は human / JSON usage error、canonical 名、現行 / deprecated alias、決定的な typo correction、active recipe への限定、正規化済み filter の保持、shell-safe な replay quoting を1つの recipe-selection 契約として維持します。alias が最も近い候補でも、suggestion と replay selector は active recipe の canonical 名でなければなりません。 include / exclude の typo recovery は selector の意味、未解決の繰り返し selector、raw FTS mode を保持します。近い候補がない場合、および child selector を伴う recipe 名訂正では selection を広げる replay を提示せず、canonical 名または複数の query owner と衝突する alias は上限付き diagnostic とともに除去しなければなりません。 +- `QueryCommandRunnerTests.RunAudit_UsageErrorsRetainPublicCommandContext_Issue4875` と `RunAudit_RecipeReplayCommandsRetainPublicCommandContext_Issue4875` + は audit alias の parse、validation、selection、conflict error が `audit` usage を維持すること、明示的な `--json` の失敗が人間向け usage を含めずに `command: "audit"` を公開すること、直接の `search` control を維持すること、issue-draft と compact の replay command が公開されている audit の positional syntax を使うことを検証します。 - `QueryCommandRunnerTests.RunReferences_ExactJson_StylesheetAndSqlFixturesShareIndexedWorkspace` は SCSS の variable / mixin / extend 参照と、SQL の multiline MERGE hint、非 ASCII identifier、quoted-string masking、temporary-table body boundary を、CLI index 1回の multi-file workspace で共有します。言語別 query と file ごとの固有 sentinel を使い、件数・kind・line assertion の診断性を独立に保ってください。 - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` diff --git a/changelog.d/unreleased/4875.fixed.md b/changelog.d/unreleased/4875.fixed.md new file mode 100644 index 000000000..6b22e40b5 --- /dev/null +++ b/changelog.d/unreleased/4875.fixed.md @@ -0,0 +1,26 @@ +--- +category: fixed +issues: + - 4875 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs + - src/CodeIndex/Cli/QueryCommandRunner.Search.cs + - src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs + - src/CodeIndex/Cli/QueryCommandRunner.Validation.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.Ndjson.cs + - src/CodeIndex/Cli/CommandErrorWriter.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Audit alias diagnostics now retain the public command identity (#4875)** — delegated parse, validation, recipe-selection, and conflict errors use `audit` usage and recovery hints, explicit JSON failures expose `command: "audit"` without human usage text, and generated replay commands keep the public positional audit syntax while direct search behavior remains unchanged. + +## 日本語 + +- **Audit alias の診断が公開 command identity を維持するようになりました (#4875)** — 委譲された parse、validation、recipe selection、conflict error は `audit` の usage と復旧 hint を使い、明示的な JSON failure は人間向け usage text を含めずに `command: "audit"` を公開します。生成する replay command は公開された audit の positional syntax を維持し、直接の search の挙動は変わりません。 diff --git a/src/CodeIndex/Cli/CommandErrorWriter.cs b/src/CodeIndex/Cli/CommandErrorWriter.cs index 5b0ef961d..e3a0d9efe 100644 --- a/src/CodeIndex/Cli/CommandErrorWriter.cs +++ b/src/CodeIndex/Cli/CommandErrorWriter.cs @@ -66,6 +66,8 @@ internal static int WriteJsonOrHuman( exitCode, usage), CliJsonSerializerContextFactory.Create(jsonOptions).CommandErrorJsonResult)!.AsObject(); + if (usage == null) + payload.Remove("usage"); if (additionalJsonProperties != null) { foreach (var property in additionalJsonProperties) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Ndjson.cs b/src/CodeIndex/Cli/QueryCommandRunner.Ndjson.cs index 8f54dcaac..4b619ffde 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Ndjson.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Ndjson.cs @@ -24,7 +24,8 @@ private static bool TryWriteCappedJsonDiagnosticsUsageError( WriteUsageError( "--max-json-bytes cannot be combined with --profile or --verbose because those diagnostics add separate stdout records outside the bounded result payload.", - GetUsageLineOrThrow(commandName), + options, + commandName, "Remove --profile/--verbose to keep a hard stdout byte cap, or remove --max-json-bytes when diagnostic records are required."); return true; } @@ -155,7 +156,8 @@ string BuildTerminal( } WriteUsageError( $"{commandName} NDJSON terminal record is {JsonLineBytes(requiredTerminal)} bytes and exceeds --max-json-bytes {options.MaxJsonBytes.Value}.", - GetUsageLineOrThrow(commandName), + options, + commandName, "Increase --max-json-bytes; the hard cap includes both result records and the terminal record."); return new(0, false, null, null, CommandExitCodes.UsageError); } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs index 78b26123c..d520ae20d 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs @@ -28,9 +28,23 @@ private static bool TryWriteParseError(QueryCommandOptions options, string comma => TryWriteParseError(options, commandName, jsonOptions: null); private static bool TryWriteParseError(QueryCommandOptions options, string commandName, JsonSerializerOptions? jsonOptions) + => TryWriteParseError( + options, + new QueryCommandInvocationContext( + commandName, + commandName, + commandName, + RecipeNameIsPositional: false, + StructuredMachineUsageErrors: false), + jsonOptions); + + private static bool TryWriteParseError( + QueryCommandOptions options, + QueryCommandInvocationContext invocationContext, + JsonSerializerOptions? jsonOptions) { var dbPathError = BuildExplicitDbPathParseError(options); - var inspectCursorScopeError = BuildInspectCursorScopeParseError(options, commandName); + var inspectCursorScopeError = BuildInspectCursorScopeParseError(options, invocationContext.CommandName); if (options.ParseError == null && dbPathError == null && inspectCursorScopeError == null) return false; @@ -40,15 +54,19 @@ private static bool TryWriteParseError(QueryCommandOptions options, string comma : primaryError == inspectCursorScopeError && options.ParseError == null && dbPathError == null ? "Pass this cursor back to the unchanged `cdidx inspect` query that returned it." : "fix the invalid or missing option value, then rerun with the command shape below."; - WriteParseError(primaryError, primaryHint, commandName, options, jsonOptions); + var machineErrorOutput = options.Json + && jsonOptions != null + && (!invocationContext.StructuredMachineUsageErrors + || options.InvocationMachineErrorOutputRequested); + WriteParseError(primaryError, primaryHint, invocationContext, options, jsonOptions); if (options.ParseError != null && dbPathError != null - && !(options.Json && jsonOptions != null)) + && !machineErrorOutput) { WriteParseError( dbPathError, "create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun this command.", - commandName, + invocationContext, options, jsonOptions); } @@ -111,11 +129,14 @@ private static bool TryWriteNonPositiveCoordinateJsonError( private static void WriteParseError( string error, string hint, - string commandName, + QueryCommandInvocationContext invocationContext, QueryCommandOptions options, JsonSerializerOptions? jsonOptions) { - if (options.Json && jsonOptions != null) + if (options.Json + && jsonOptions != null + && (!invocationContext.StructuredMachineUsageErrors + || options.InvocationMachineErrorOutputRequested)) { CommandErrorWriter.WriteJsonOrHuman( true, @@ -123,19 +144,21 @@ private static void WriteParseError( StripErrorPrefix(error), CommandExitCodes.UsageError, hint, - GetUsageLineOrThrow(commandName), + invocationContext.StructuredMachineUsageErrors + ? null + : invocationContext.UsageLine, ExtractErrorCode(error), category: "usage", - command: commandName); + command: invocationContext.CommandName); return; } CommandErrorWriter.Write( StripErrorPrefix(error), hint, - GetUsageLineOrThrow(commandName), + invocationContext.UsageLine, ExtractErrorCode(error) - ?? (string.Equals(commandName, "outline", StringComparison.Ordinal) + ?? (string.Equals(invocationContext.CommandName, "outline", StringComparison.Ordinal) ? CommandErrorCodes.UsageError : null)); } @@ -286,11 +309,31 @@ private static bool TryWriteUnsupportedOptionError( IEnumerable supportedOptions, string? queryLiteral = null, JsonSerializerOptions? jsonOptions = null) + => TryWriteUnsupportedOptionError( + new QueryCommandInvocationContext( + commandName, + commandName, + commandName, + RecipeNameIsPositional: false, + StructuredMachineUsageErrors: false), + cmdArgs, + supportedOptions, + queryLiteral, + jsonOptions); + + private static bool TryWriteUnsupportedOptionError( + QueryCommandInvocationContext invocationContext, + string[] cmdArgs, + IEnumerable supportedOptions, + string? queryLiteral = null, + JsonSerializerOptions? jsonOptions = null) { + var commandName = invocationContext.CommandName; + void WriteOptionError(string message, string hint, string? errorCode = null) { if (jsonOptions != null - && cmdArgs.Any(static arg => arg == "--json" || arg.StartsWith("--json=", StringComparison.Ordinal))) + && ProgramRunner.ContainsJsonOutputFlag(cmdArgs)) { CommandErrorWriter.WriteJsonOrHuman( true, @@ -298,7 +341,9 @@ void WriteOptionError(string message, string hint, string? errorCode = null) message, CommandExitCodes.UsageError, hint, - GetUsageLineOrThrow(commandName), + invocationContext.StructuredMachineUsageErrors + ? null + : invocationContext.UsageLine, errorCode ?? CommandErrorCodes.UsageError, command: commandName); return; @@ -307,7 +352,7 @@ void WriteOptionError(string message, string hint, string? errorCode = null) CommandErrorWriter.Write( message, hint, - GetUsageLineOrThrow(commandName), + invocationContext.UsageLine, errorCode ?? (string.Equals(commandName, "outline", StringComparison.Ordinal) ? CommandErrorCodes.UsageError diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs index 32e9ab50c..cfaf765e7 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -10,7 +10,12 @@ public static int RunSearch( string[] cmdArgs, JsonSerializerOptions jsonOptions, CancellationToken cancellationToken = default) => - RunSearchCore(cmdArgs, cmdArgs, "search", jsonOptions, cancellationToken); + RunSearchCore( + cmdArgs, + cmdArgs, + QueryCommandInvocationContext.Search, + jsonOptions, + cancellationToken); internal static int RunRecipeList( string[] cmdArgs, @@ -30,13 +35,18 @@ internal static int RunRecipeList( var searchArgs = new string[cmdArgs.Length + 1]; searchArgs[0] = "--list-recipes"; Array.Copy(cmdArgs, 0, searchArgs, 1, cmdArgs.Length); - return RunSearchCore(searchArgs, cmdArgs, "recipes", jsonOptions, cancellationToken); + return RunSearchCore( + searchArgs, + cmdArgs, + QueryCommandInvocationContext.Recipes, + jsonOptions, + cancellationToken); } private static int RunSearchCore( string[] cmdArgs, string[] validationArgs, - string usageCommandName, + QueryCommandInvocationContext invocationContext, JsonSerializerOptions jsonOptions, CancellationToken cancellationToken) { @@ -52,25 +62,43 @@ private static int RunSearchCore( allowNamedQuery: true, allowIssueDraftsFormat: true, applySearchSourceDefaults: true); + options.InvocationContext = invocationContext; + options.InvocationJsonOptions = jsonOptions; + options.InvocationMachineErrorOutputRequested = ProgramRunner.ContainsJsonOutputFlag(validationArgs); using var exactLanguageScope = DbReader.BeginExactQueryLanguageScope( options.Lang); - if (TryWriteUnsupportedOptionError(usageCommandName, validationArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand(usageCommandName), options.Query)) + if (TryWriteUnsupportedOptionError( + invocationContext, + validationArgs, + CliFlagSchema.GetAcceptedFlagNamesForCommand(invocationContext.ValidationCommandName), + options.Query, + invocationContext.StructuredMachineUsageErrors ? jsonOptions : null)) return CommandExitCodes.UsageError; if (TryWriteParseError( options, - usageCommandName, - options.LanguageValidationError ? jsonOptions : null)) + invocationContext, + options.LanguageValidationError || invocationContext.StructuredMachineUsageErrors ? jsonOptions : null)) return CommandExitCodes.UsageError; if (!TryResolveSearchExactMode(options, out var exact, out var exactError)) { - CommandErrorWriter.WriteStderr(exactError); + if (invocationContext.StructuredMachineUsageErrors) + { + WriteUsageError( + StripErrorPrefix(exactError!), + options, + "Use one compatible exact-search mode, or remove the exact-mode flags."); + } + else + { + CommandErrorWriter.WriteStderr(exactError); + } return CommandExitCodes.UsageError; } if (options.OpenIssuesPath != null && options.OutputFormat != OutputFormatIssueDrafts) { WriteUsageError( "--open-issues can only be used with `cdidx search --format issue-drafts`.", - GetUsageLineOrThrow("search"), + options, "Use an open-issues JSON file from `gh issue list --state open --json number,title,labels,url`."); return CommandExitCodes.UsageError; } @@ -78,20 +106,20 @@ private static int RunSearchCore( { WriteUsageError( "--repo can only be used with `--open-issues github`.", - GetUsageLineOrThrow("search"), + options, "Use `--open-issues github --repo owner/name` to fetch open issues directly from GitHub."); return CommandExitCodes.UsageError; } if (options.IssueState != IssueDuplicatePreflight.DefaultIssueState && !IssueDuplicatePreflight.IsGitHubOpenIssuesSource(options.OpenIssuesPath)) { - WriteUsageError("--issue-state can only be used with `--open-issues github`.", GetUsageLineOrThrow("search"), "Use `--open-issues github --repo owner/name --issue-state all`."); + WriteUsageError("--issue-state can only be used with `--open-issues github`.", options, "Use `--open-issues github --repo owner/name --issue-state all`."); return CommandExitCodes.UsageError; } if (options.DuplicatePreflightTuningExplicit && options.OutputFormat != OutputFormatIssueDrafts) { WriteUsageError( "--duplicate-confidence and --duplicate-threshold can only be used with `cdidx search --format issue-drafts`.", - GetUsageLineOrThrow("search"), + options, "Use these controls when exporting issue draft JSON with duplicate-preflight metadata."); return CommandExitCodes.UsageError; } @@ -99,7 +127,7 @@ private static int RunSearchCore( { WriteUsageError( "--include-query and --exclude-query can only be used with --recipe.", - GetUsageLineOrThrow("search"), + options, "Use `--recipe risky-code --include-query raw-diagnostic-echo` to run a child query subset."); return CommandExitCodes.UsageError; } @@ -107,7 +135,7 @@ private static int RunSearchCore( { WriteUsageError( "--cursor can only be used with --recipe.", - GetUsageLineOrThrow("search"), + options, "Use `--recipe risky-code/raw-diagnostic-echo --format compact --cursor ` to fetch the next page for one child query."); return CommandExitCodes.UsageError; } @@ -115,7 +143,7 @@ private static int RunSearchCore( { WriteUsageError( "--cursor for search must be a search pagination cursor returned by recipe search.", - GetUsageLineOrThrow("search"), + options, "Use `--cursor ` only with `--recipe`; `unused:` cursors are for `cdidx unused`."); return CommandExitCodes.UsageError; } @@ -123,7 +151,7 @@ private static int RunSearchCore( { WriteUsageError( "--cursor for search must be a search pagination cursor returned by recipe search.", - GetUsageLineOrThrow("search"), + options, "`outline:` cursors are for `cdidx outline `."); return CommandExitCodes.UsageError; } @@ -131,7 +159,7 @@ private static int RunSearchCore( { WriteUsageError( "--cursor for search must be a search pagination cursor returned by recipe search.", - GetUsageLineOrThrow("search"), + options, "Dependency-cycle cursors are for `cdidx deps --cycles`."); return CommandExitCodes.UsageError; } @@ -139,7 +167,7 @@ private static int RunSearchCore( { WriteUsageError( "--audit-scope cannot be combined with `cdidx search --list-recipes`.", - GetUsageLineOrThrow("search"), + options, "Use `--query ` with --list-recipes to filter recipe discovery, or run an ad hoc search with `--source-only`."); return CommandExitCodes.UsageError; } @@ -147,7 +175,7 @@ private static int RunSearchCore( { WriteUsageError( "--show-excluded is only supported with `cdidx search --recipe `.", - GetUsageLineOrThrow("search"), + options, "Use it with a recipe run to include the effective scope and exclusion diagnostics in JSON output."); return CommandExitCodes.UsageError; } @@ -155,7 +183,7 @@ private static int RunSearchCore( { WriteUsageError( "--issue-title and --issue-label can only be used with `cdidx search --format issue-drafts`.", - GetUsageLineOrThrow("search"), + options, "Use these hints when exporting issue draft JSON for a plain search."); return CommandExitCodes.UsageError; } @@ -163,7 +191,7 @@ private static int RunSearchCore( { WriteUsageError( "--snippet-lines 0 is only supported with --format issue-drafts.", - GetUsageLineOrThrow("search"), + options, "Use `--format issue-drafts --snippet-lines 0` for path/line-only draft evidence, or pass a positive snippet line count for search output."); return CommandExitCodes.UsageError; } @@ -171,7 +199,7 @@ private static int RunSearchCore( { WriteUsageError( "--issue-title is only supported for ad hoc search issue drafts.", - GetUsageLineOrThrow("search"), + options, "Recipe issue-drafts produce one draft per recipe query, so their titles are derived from the recipe metadata."); return CommandExitCodes.UsageError; } @@ -179,7 +207,7 @@ private static int RunSearchCore( { WriteUsageError( "--count cannot be combined with --format issue-drafts.", - GetUsageLineOrThrow("search"), + options, "Issue-draft export needs result evidence; remove --count."); return CommandExitCodes.UsageError; } @@ -187,7 +215,7 @@ private static int RunSearchCore( { WriteUsageError( "--names is only supported with `cdidx recipes` or `cdidx search --list-recipes`.", - GetUsageLineOrThrow("search"), + options, "Use `cdidx recipes --names --json` for a small deterministic recipe-name list."); return CommandExitCodes.UsageError; } @@ -195,7 +223,7 @@ private static int RunSearchCore( { WriteUsageError( "--names cannot be combined with --summary-only.", - GetUsageLineOrThrow(usageCommandName), + options, "Use one recipe-list shape at a time."); return CommandExitCodes.UsageError; } @@ -210,7 +238,7 @@ private static int RunSearchCore( { WriteUsageError( "--summary-only is only supported with `cdidx recipes` / `cdidx search --list-recipes`, named-query count output, recipe count output, or recipe issue-drafts output.", - GetUsageLineOrThrow("search"), + options, "Use `cdidx recipes --summary-only --json`, `cdidx search --named-query = --summary-only --json`, `cdidx search --recipe --format compact --summary-only --json`, `cdidx search --recipe --format count --summary-only`, or `cdidx search --recipe --format issue-drafts --summary-only`."); return CommandExitCodes.UsageError; } @@ -218,7 +246,7 @@ private static int RunSearchCore( { WriteUsageError( "--json=array is not supported with --format issue-drafts because draft export is a JSON object.", - GetUsageLineOrThrow("search"), + options, "Use plain `--json` or omit --json when exporting issue drafts."); return CommandExitCodes.UsageError; } @@ -226,22 +254,24 @@ private static int RunSearchCore( { WriteUsageError( "--cursor cannot be combined with --format issue-drafts.", - GetUsageLineOrThrow("search"), + options, "Use --cursor with recipe JSON or compact output, then export issue drafts after choosing the desired query page."); return CommandExitCodes.UsageError; } var exactSearch = exact || options.TokenBoundary; if (options.TokenBoundary && options.RawFts) { - WriteValidationError( + WriteUsageError( "--token-boundary cannot be combined with --fts.", + options, "Drop --fts to use exact token-boundary matching, or drop --token-boundary to keep raw FTS5 syntax."); return CommandExitCodes.UsageError; } if (exactSearch && options.Prefix) { - WriteValidationError( + WriteUsageError( "--prefix cannot be combined with --exact / --exact-substring / --token-boundary (exact uses instr(), not FTS5 prefix phrases).", + options, "Drop --prefix to keep the exact substring path, or drop the exact-mode flag to opt into FTS5 prefix matching."); return CommandExitCodes.UsageError; } @@ -252,7 +282,7 @@ private static int RunSearchCore( : "--named-query"; WriteUsageError( $"--group-by is not supported with {mode}.", - GetUsageLineOrThrow("search"), + options, "Use `cdidx search --group-by file --count` or remove --group-by for recipe-list and named-batch output."); return CommandExitCodes.UsageError; } @@ -265,11 +295,13 @@ private static int RunSearchCore( : "--recipe"; WriteUsageError( "--format grouped is only supported for plain search output.", - GetUsageLineOrThrow("search"), - $"Remove {mode}, or run a plain `cdidx search --format grouped`."); + options, + invocationContext.RecipeNameIsPositional && mode == "--recipe" + ? "Run a plain `cdidx search --format grouped`; audit recipe execution does not support grouped output." + : $"Remove {mode}, or run a plain `cdidx search --format grouped`."); return CommandExitCodes.UsageError; } - if (TryWriteCappedJsonDiagnosticsUsageError(usageCommandName, options)) + if (TryWriteCappedJsonDiagnosticsUsageError(invocationContext.CommandName, options)) return CommandExitCodes.UsageError; if (options.ListRecipes) { @@ -277,7 +309,7 @@ private static int RunSearchCore( { WriteUsageError( "row-selection controls are not supported with --list-recipes because recipe discovery does not emit search rows.", - GetUsageLineOrThrow(usageCommandName), + options, "Remove --first-per-file / --sample, or execute a recipe or plain search that returns rows."); return CommandExitCodes.UsageError; } @@ -285,7 +317,7 @@ private static int RunSearchCore( { WriteUsageError( "--list-recipes cannot be combined with --recipe, --named-query, or extra positional arguments.", - GetUsageLineOrThrow(usageCommandName), + options, "Run `cdidx search --list-recipes --query ` to filter built-in audit recipes by recipe, query, label, severity, path, or search text."); return CommandExitCodes.UsageError; } @@ -293,7 +325,7 @@ private static int RunSearchCore( { WriteUsageError( "--format count/csv/tsv/lsp/qf/sarif/issue-drafts is not supported with --list-recipes.", - GetUsageLineOrThrow(usageCommandName), + options, "Use plain text output, `--json` / `--format json` for the full recipe list, or `--format compact` for a compact summary."); return CommandExitCodes.UsageError; } @@ -301,12 +333,12 @@ private static int RunSearchCore( { WriteUsageError( "--json=array is not supported with --list-recipes because recipe-list output is a JSON object.", - GetUsageLineOrThrow(usageCommandName), + options, "Use plain `--json` for the recipe-list object."); return CommandExitCodes.UsageError; } - return WriteSearchRecipeList(options, jsonOptions, usageCommandName); + return WriteSearchRecipeList(options, jsonOptions, invocationContext.CommandName); } if (options.NamedSearchQueries.Count > 0) { @@ -314,7 +346,7 @@ private static int RunSearchCore( { WriteUsageError( "row-selection controls are not supported with --named-query because named batches do not expose selector accounting.", - GetUsageLineOrThrow("search"), + options, "Remove --first-per-file / --sample, or run each query as a plain search or recipe row output."); return CommandExitCodes.UsageError; } @@ -322,7 +354,7 @@ private static int RunSearchCore( { WriteUsageError( "--named-query cannot be combined with a positional query, --query, --recipe, or extra positional arguments.", - GetUsageLineOrThrow("search"), + options, "Pass one or more `--named-query =` values, or run a plain `cdidx search `."); return CommandExitCodes.UsageError; } @@ -330,7 +362,7 @@ private static int RunSearchCore( { WriteUsageError( "--open-issues can only be used with `cdidx search --recipe --format issue-drafts`.", - GetUsageLineOrThrow("search"), + options, "Remove --open-issues for ad hoc named batches."); return CommandExitCodes.UsageError; } @@ -338,7 +370,7 @@ private static int RunSearchCore( { WriteUsageError( "--format csv/tsv/lsp/qf/sarif/issue-drafts is not supported with --named-query.", - GetUsageLineOrThrow("search"), + options, "Use plain text output, `--json`, `--format count`, or `--format compact` for grouped ad hoc results."); return CommandExitCodes.UsageError; } @@ -346,7 +378,7 @@ private static int RunSearchCore( { WriteUsageError( "--json=array is not supported with --named-query because named batch output is grouped by query.", - GetUsageLineOrThrow("search"), + options, "Use plain `--json` for the grouped named-query object."); return CommandExitCodes.UsageError; } @@ -354,7 +386,7 @@ private static int RunSearchCore( { WriteUsageError( "--max-json-bytes is only supported with JSON search output.", - GetUsageLineOrThrow("search"), + options, "Use `--json` or `--format compact` with --named-query when bounding named batch output."); return CommandExitCodes.UsageError; } @@ -370,7 +402,7 @@ private static int RunSearchCore( { WriteUsageError( "--token-boundary is only supported for ad hoc search and --named-query batches, not recipe execution.", - GetUsageLineOrThrow("search"), + options, "Run an individual query without --recipe if token-boundary filtering is required."); return CommandExitCodes.UsageError; } @@ -378,7 +410,7 @@ private static int RunSearchCore( { WriteUsageError( "--recipe expands into its own curated query set and cannot be combined with a search query.", - GetUsageLineOrThrow("search"), + options, "Remove the positional query, or run a plain `cdidx search ` without --recipe."); return CommandExitCodes.UsageError; } @@ -386,7 +418,7 @@ private static int RunSearchCore( { WriteUsageError( "--prefix is not supported with --recipe because each recipe query defines its own match mode.", - GetUsageLineOrThrow("search"), + options, "Remove --prefix, or run the individual query from the recipe list yourself."); return CommandExitCodes.UsageError; } @@ -394,7 +426,7 @@ private static int RunSearchCore( { WriteUsageError( "--format csv/tsv/lsp/qf is not supported with --recipe.", - GetUsageLineOrThrow("search"), + options, "Use `--count` / `--format count` for count-only recipe output, `--json` for grouped recipe results, `--format compact` for summary-first compact JSON, `--format sarif` for audit findings, or `--format issue-drafts` for draft exports."); return CommandExitCodes.UsageError; } @@ -402,7 +434,7 @@ private static int RunSearchCore( { WriteUsageError( "--json=array is not supported with --recipe because recipe output is grouped by query.", - GetUsageLineOrThrow("search"), + options, "Use plain `--json` for the grouped recipe object."); return CommandExitCodes.UsageError; } @@ -421,7 +453,7 @@ private static int RunSearchCore( { WriteUsageError( "--format sarif cannot be combined with recipe count, summary, aggregation, projection, row-selection, or NDJSON controls.", - GetUsageLineOrThrow("search"), + options, "Use `--recipe --format sarif` with result filters and `--limit` / `--total-limit`, or choose the JSON/count output shape instead."); return CommandExitCodes.UsageError; } @@ -429,7 +461,7 @@ private static int RunSearchCore( { WriteUsageError( "--per-file-limit is not supported with --recipe because recipe execution does not produce grouped search output.", - GetUsageLineOrThrow("search"), + options, "Use --first-per-file for one selected recipe row per file, or remove --recipe and use grouped ad hoc search output."); return CommandExitCodes.UsageError; } @@ -438,7 +470,7 @@ private static int RunSearchCore( { WriteUsageError( "recipe row-selection controls cannot be combined with --cursor because raw recipe cursors cannot preserve selector state.", - GetUsageLineOrThrow("search"), + options, "Remove --cursor and rerun selection from the beginning, or remove --first-per-file / --sample to resume from the cursor."); return CommandExitCodes.UsageError; } @@ -452,7 +484,7 @@ private static int RunSearchCore( { WriteUsageError( "recipe row-selection controls cannot be combined with count, aggregation, results-only, or summary-only compact output.", - GetUsageLineOrThrow("search"), + options, "Remove --first-per-file / --sample to keep the non-row output, or choose text, JSON, compact, NDJSON, or issue-drafts row output."); return CommandExitCodes.UsageError; } @@ -460,7 +492,7 @@ private static int RunSearchCore( { WriteUsageError( "--max-json-bytes is only supported with JSON search output.", - GetUsageLineOrThrow("search"), + options, "Use `--json=ndjson`, `--format count`, `--format compact`, grouped/count-by JSON, or `--format issue-drafts` with --max-json-bytes."); return CommandExitCodes.UsageError; } @@ -468,7 +500,7 @@ private static int RunSearchCore( { WriteUsageError( "--results-only is only supported with NDJSON recipe output.", - GetUsageLineOrThrow("search"), + options, "Use `--recipe --results-only --search-fields path,line,query_name`, or remove --results-only."); return CommandExitCodes.UsageError; } @@ -478,7 +510,7 @@ private static int RunSearchCore( { WriteUsageError( "--group-by for recipe search must be one of file, symbol, origin, return-type, or subsystem.", - GetUsageLineOrThrow("search"), + options, "Use `cdidx search --recipe --group-by file --count`, `--group-by symbol --count`, `--group-by return-type --count`, `--group-by subsystem --count`, or `--count-by origin`."); return CommandExitCodes.UsageError; } @@ -486,7 +518,7 @@ private static int RunSearchCore( { WriteUsageError( "search --recipe --group-by requires --count.", - GetUsageLineOrThrow("search"), + options, "Add --count to request grouped recipe result counts, or remove --group-by to print matching snippets."); return CommandExitCodes.UsageError; } @@ -495,7 +527,7 @@ private static int RunSearchCore( { WriteUsageError( "--count-by for recipe search must be one of path, file, symbol, origin, return-type, or subsystem.", - GetUsageLineOrThrow("search"), + options, "Use `--count-by path`, `--count-by symbol`, `--count-by return-type`, `--count-by subsystem`, or `--count-by origin`."); return CommandExitCodes.UsageError; } @@ -503,7 +535,7 @@ private static int RunSearchCore( { WriteUsageError( "--unique for recipe search must be one of path, file, symbol, origin, return-type, or subsystem.", - GetUsageLineOrThrow("search"), + options, "Use `--unique path`, `--unique symbol`, `--unique return-type`, `--unique subsystem`, or `--unique origin`."); return CommandExitCodes.UsageError; } @@ -511,7 +543,7 @@ private static int RunSearchCore( { WriteUsageError( "--count-by cannot be combined with --unique.", - GetUsageLineOrThrow("search"), + options, "Run one recipe aggregation mode at a time."); return CommandExitCodes.UsageError; } @@ -519,7 +551,7 @@ private static int RunSearchCore( { WriteUsageError( "--group-by cannot be combined with --count-by or --unique.", - GetUsageLineOrThrow("search"), + options, "Use either `--group-by --count`, `--count-by `, or `--unique `."); return CommandExitCodes.UsageError; } @@ -527,7 +559,7 @@ private static int RunSearchCore( { WriteUsageError( "recipe aggregation cannot be combined with --results-only or --search-fields.", - GetUsageLineOrThrow("search"), + options, "Run the aggregation separately, or remove --count-by/--group-by to stream projected recipe rows."); return CommandExitCodes.UsageError; } @@ -566,7 +598,7 @@ private static int RunSearchCore( { WriteUsageError( QueryLimits.FormatQueryTooLongError(), - GetUsageLineOrThrow("search"), + options, "Shorten the search text or split generated input into smaller queries before running `cdidx search`."); return CommandExitCodes.UsageError; } @@ -581,7 +613,7 @@ private static int RunSearchCore( { WriteUsageError( "search row-selection controls cannot be combined with count or aggregation output.", - GetUsageLineOrThrow("search"), + options, "Remove --first-per-file / --sample to count the full filtered population, or choose a row output that reports selector accounting."); return CommandExitCodes.UsageError; } @@ -589,7 +621,7 @@ private static int RunSearchCore( { WriteUsageError( "search row-selection controls cannot be combined with --results-only because that stream omits selector accounting.", - GetUsageLineOrThrow("search"), + options, "Remove --results-only to retain the NDJSON terminal record, or remove --first-per-file / --sample."); return CommandExitCodes.UsageError; } @@ -598,7 +630,7 @@ private static int RunSearchCore( { WriteUsageError( "search row-selection controls cannot be combined with metadata-free --json=array output.", - GetUsageLineOrThrow("search"), + options, "Add --json-envelope to retain selector accounting, use --json=ndjson / --format compact, or remove --first-per-file / --sample."); return CommandExitCodes.UsageError; } @@ -610,7 +642,7 @@ and not OutputFormatCompact { WriteUsageError( "search row-selection controls are only supported by text, JSON, compact, and issue-drafts row output.", - GetUsageLineOrThrow("search"), + options, "Choose an output shape that reports selector accounting, or remove --first-per-file / --sample."); return CommandExitCodes.UsageError; } @@ -620,7 +652,7 @@ and not OutputFormatCompact { WriteUsageError( "--group-by for search must be one of file, symbol, origin, return-type, or subsystem.", - GetUsageLineOrThrow("search"), + options, "Use `cdidx search --group-by file --count`, `--group-by symbol --count`, `--group-by return-type --count`, `--group-by subsystem --count`, or `--count-by origin`."); return CommandExitCodes.UsageError; } @@ -628,7 +660,7 @@ and not OutputFormatCompact { WriteUsageError( "search --group-by requires --count.", - GetUsageLineOrThrow("search"), + options, "Add --count to request grouped result counts, or remove --group-by to print matching snippets."); return CommandExitCodes.UsageError; } @@ -636,7 +668,7 @@ and not OutputFormatCompact { WriteUsageError( "--group-by for search only supports plain count output or JSON.", - GetUsageLineOrThrow("search"), + options, "Use `--count`, optionally with `--json`, instead of compact/location formats."); return CommandExitCodes.UsageError; } @@ -644,7 +676,7 @@ and not OutputFormatCompact { WriteUsageError( "--json=array is not supported with search --group-by because grouped count output is a JSON object.", - GetUsageLineOrThrow("search"), + options, "Use plain `--json` for the grouped-count object."); return CommandExitCodes.UsageError; } @@ -653,7 +685,7 @@ and not OutputFormatCompact { WriteUsageError( "--count-by cannot be combined with --unique.", - GetUsageLineOrThrow("search"), + options, "Run one aggregation mode at a time."); return CommandExitCodes.UsageError; } @@ -661,7 +693,7 @@ and not OutputFormatCompact { WriteUsageError( "--group-by cannot be combined with --count-by or --unique.", - GetUsageLineOrThrow("search"), + options, "Use either `--group-by --count`, `--count-by `, or `--unique `."); return CommandExitCodes.UsageError; } @@ -669,7 +701,7 @@ and not OutputFormatCompact { WriteUsageError( "--json=array is not supported with search aggregation because aggregation output is a JSON object.", - GetUsageLineOrThrow("search"), + options, "Use plain `--json` for `--count-by` or `--unique` aggregation output."); return CommandExitCodes.UsageError; } @@ -677,7 +709,7 @@ and not OutputFormatCompact { WriteUsageError( "--json=array is not supported with search --format grouped because grouped output is a JSON object.", - GetUsageLineOrThrow("search"), + options, "Use plain `--json` or omit --json when using `--format grouped`."); return CommandExitCodes.UsageError; } @@ -685,7 +717,7 @@ and not OutputFormatCompact { WriteUsageError( "--results-only is only supported with NDJSON search output.", - GetUsageLineOrThrow("search"), + options, "Use `--results-only --json=ndjson`, or remove --results-only when using --json=array."); return CommandExitCodes.UsageError; } @@ -693,7 +725,7 @@ and not OutputFormatCompact { WriteUsageError( "--max-json-bytes is only supported with JSON search output.", - GetUsageLineOrThrow("search"), + options, "Use `--json=ndjson`, `--json=array`, `--format count`, `--format compact`, grouped/count-by JSON, or `--format issue-drafts` with --max-json-bytes."); return CommandExitCodes.UsageError; } @@ -701,7 +733,7 @@ and not OutputFormatCompact { WriteUsageError( "--count-by for search must be one of path, file, symbol, origin, return-type, or subsystem.", - GetUsageLineOrThrow("search"), + options, "Use `--count-by path`, `--count-by symbol`, `--count-by return-type`, `--count-by subsystem`, or `--count-by origin`."); return CommandExitCodes.UsageError; } @@ -709,7 +741,7 @@ and not OutputFormatCompact { WriteUsageError( "--unique for search must be one of path, file, symbol, origin, return-type, or subsystem.", - GetUsageLineOrThrow("search"), + options, "Use `--unique path`, `--unique symbol`, `--unique return-type`, `--unique subsystem`, or `--unique origin`."); return CommandExitCodes.UsageError; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs index e83816069..7e418b55c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs @@ -22,12 +22,17 @@ internal static int RunAudit( { if (subArgs.Length == 0 || subArgs[0].StartsWith("-", StringComparison.Ordinal)) { + var machineOutputRequested = ProgramRunner.ContainsJsonOutputFlag(subArgs); return CommandErrorWriter.WriteJsonOrHuman( - ProgramRunner.ContainsJsonOutputFlag(subArgs), + machineOutputRequested, jsonOptions, "audit requires a recipe name.", CommandExitCodes.UsageError, - "pass a recipe name after `cdidx audit`, or run `cdidx recipes` to list built-in recipes."); + "pass a recipe name after `cdidx audit`, or run `cdidx recipes` to list built-in recipes.", + machineOutputRequested ? null : ConsoleUi.GetUsageLine("audit"), + CommandErrorCodes.UsageError, + category: "usage", + command: "audit"); } var hasSummaryOnly = false; @@ -63,6 +68,11 @@ internal static int RunAudit( subArgs.Length - insertIndex); } - return RunSearch(searchArgs, jsonOptions, cancellationToken); + return RunSearchCore( + searchArgs, + subArgs, + QueryCommandInvocationContext.Audit, + jsonOptions, + cancellationToken); } } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 6a018837f..a95d75900 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -22,7 +22,7 @@ private static int WriteSearchRecipeList( { WriteUsageError( "--max-json-bytes is only supported with JSON recipe-list output.", - GetUsageLineOrThrow(usageCommandName), + options, "Add `--json` or `--format compact`, or remove --max-json-bytes for text recipe output."); return CommandExitCodes.UsageError; } @@ -133,7 +133,8 @@ private static int WriteJsonObjectWithOptionalByteLimit( { WriteUsageError( $"{outputDescription} JSON output is {byteCount.ToString(CultureInfo.InvariantCulture)} bytes and exceeds --max-json-bytes {options.MaxJsonBytes.Value.ToString(CultureInfo.InvariantCulture)}.", - GetUsageLineOrThrow(commandName), + options, + commandName, hint); return CommandExitCodes.UsageError; } @@ -245,7 +246,7 @@ private static bool TryResolveSearchRecipeSelection( { error = new( "--recipe child selection must use recipe/query form.", - "Use `cdidx search --recipe /`, with exactly one non-empty recipe and query name."); + $"Use `{options.InvocationContext.RecipeSelectorSyntax}`, with exactly one non-empty recipe and query name."); return false; } if (options.IncludeRecipeQueries.Count > 0 || options.ExcludeRecipeQueries.Count > 0) @@ -274,10 +275,10 @@ private static bool TryResolveSearchRecipeSelection( var hint = canReplaySuggestedRecipe ? $"Retry with `{BuildSearchRecipeSelectionReplayCommand(suggestions[0], options)}`." : suggestions.Count > 0 - ? "Correct the recipe name while retaining the requested query selectors, or run `cdidx search --list-recipes` to inspect the available recipes." - : "Run `cdidx search --list-recipes` to choose an available recipe."; + ? $"Correct the recipe name while retaining the requested query selectors, or run `{options.InvocationContext.RecipeDiscoveryCommand}` to inspect the available recipes." + : $"Run `{options.InvocationContext.RecipeDiscoveryCommand}` to choose an available recipe."; error = new( - $"unknown search recipe '{recipeName}'. Available recipes: {available}.{suggestionText}", + $"unknown {options.InvocationContext.CommandName} recipe '{recipeName}'. Available recipes: {available}.{suggestionText}", hint); return false; } @@ -439,7 +440,7 @@ private static SearchRecipeSelectionError BuildUnknownRecipeQueryError( } else { - hint = $"Recipe '{recipe.Name}' has no runnable queries; run `cdidx search --list-recipes` to choose another recipe."; + hint = $"Recipe '{recipe.Name}' has no runnable queries; run `{options.InvocationContext.RecipeDiscoveryCommand}` to choose another recipe."; } return new(message + suggestionText, hint); } @@ -468,15 +469,10 @@ private static string BuildSearchRecipeSelectionReplayCommand( string? invalidSelector = null, string? suggestedQueryName = null) { - var args = new List - { - "cdidx", - "search", - "--recipe", - recipeSelector, - "--format", - OutputFormatCompact, - }; + var args = new List(); + options.InvocationContext.AddRecipeCommandPrefix(args, recipeSelector); + args.Add("--format"); + args.Add(OutputFormatCompact); AddReplayValueOption(args, "--limit", options.Limit.ToString(CultureInfo.InvariantCulture)); AddSearchRecipeCompactReplayOptions(args, options, includeRecipeQuerySelectors: false); if (recipe != null && selectorMode != SearchRecipeQuerySelectorMode.Direct) @@ -649,7 +645,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp { WriteUsageError( selectionError!.Message, - GetUsageLineOrThrow("search"), + options, selectionError.Hint); return CommandExitCodes.UsageError; } @@ -659,7 +655,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp { WriteUsageError( "--cursor requires exactly one selected recipe query.", - GetUsageLineOrThrow("search"), + options, "Use `--recipe recipe/query` or a single `--include-query` value with --cursor."); return CommandExitCodes.UsageError; } @@ -1064,13 +1060,8 @@ private static string BuildSearchRecipeCompactReplayCommand( bool resultsOnly, bool includeRecipeQuerySelectors) { - var args = new List - { - "cdidx", - "search", - "--recipe", - recipeSelector, - }; + var args = new List(); + options.InvocationContext.AddRecipeCommandPrefix(args, recipeSelector); if (resultsOnly) { args.Add("--json=ndjson"); @@ -1172,7 +1163,7 @@ private static int RunSearchRecipeAggregation(QueryCommandOptions options, JsonS { WriteUsageError( selectionError!.Message, - GetUsageLineOrThrow("search"), + options, selectionError.Hint); return CommandExitCodes.UsageError; } @@ -1278,7 +1269,7 @@ private static NdjsonStreamWriteResult WriteRecipeSearchResultRows( options, ndjsonOptions, reader, - "search", + options.InvocationContext.CommandName, limitTruncated, "Increase --limit or --total-limit, select one recipe query, or narrow the recipe scope.", totalCountAuthoritative: false, @@ -1333,7 +1324,7 @@ private static int RunSearchRecipeIssueDrafts( { WriteUsageError( selectionError!.Message, - GetUsageLineOrThrow("search"), + options, selectionError.Hint); return CommandExitCodes.UsageError; } @@ -1350,7 +1341,7 @@ private static int RunSearchRecipeIssueDrafts( { WriteUsageError( preflightResult.Error!, - GetUsageLineOrThrow("search"), + options, "Pass a readable JSON array from `gh issue list --state open --json number,title,labels,url`, or use `--open-issues github --repo owner/name`."); return CommandExitCodes.UsageError; } @@ -1399,7 +1390,7 @@ private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSeriali { WriteUsageError( selectionError!.Message, - GetUsageLineOrThrow("search"), + options, selectionError.Hint); return CommandExitCodes.UsageError; } @@ -1488,7 +1479,7 @@ private static int RunSearchIssueDrafts( { WriteUsageError( preflightResult.Error!, - GetUsageLineOrThrow("search"), + options, "Pass a readable JSON array from `gh issue list --state open --json number,title,labels,url`, or use `--open-issues github --repo owner/name`."); return CommandExitCodes.UsageError; } @@ -3053,17 +3044,12 @@ private static string BuildSearchRecipeReplayCommand(SearchAuditRecipe recipe, Q var recipeSelector = string.IsNullOrWhiteSpace(queryName) ? recipe.Name : $"{recipe.Name}/{queryName}"; - var args = new List - { - "cdidx", - "search", - "--recipe", - recipeSelector, - "--format", - OutputFormatIssueDrafts, - "--limit", - options.Limit.ToString(CultureInfo.InvariantCulture), - }; + var args = new List(); + options.InvocationContext.AddRecipeCommandPrefix(args, recipeSelector); + args.Add("--format"); + args.Add(OutputFormatIssueDrafts); + args.Add("--limit"); + args.Add(options.Limit.ToString(CultureInfo.InvariantCulture)); if (options.DbPathExplicit) AddReplayValueOption(args, "--db", options.DbPath); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs b/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs index baaa5af29..0590e1623 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs @@ -138,6 +138,47 @@ private static string GetUsageLineOrThrow(string commandName) => private static void WriteUsageError(string message, string usage, string hint) => CommandErrorWriter.Write(message, hint, usage); + private static void WriteUsageError(string message, QueryCommandOptions options, string hint) + { + var invocationContext = options.InvocationContext; + if (options.InvocationMachineErrorOutputRequested + && invocationContext.StructuredMachineUsageErrors + && options.InvocationJsonOptions != null) + { + CommandErrorWriter.WriteJsonOrHuman( + true, + options.InvocationJsonOptions, + message, + CommandExitCodes.UsageError, + hint, + usage: null, + errorCode: CommandErrorCodes.UsageError, + category: "usage", + command: invocationContext.CommandName); + return; + } + + CommandErrorWriter.Write( + message, + hint, + invocationContext.UsageLine); + } + + private static void WriteUsageError( + string message, + QueryCommandOptions options, + string fallbackCommandName, + string hint) + { + if (options.InvocationContext.StructuredMachineUsageErrors) + { + WriteUsageError(message, options, hint); + return; + } + + WriteUsageError(message, GetUsageLineOrThrow(fallbackCommandName), hint); + } + private static bool TryWriteUnsupportedOutputFormat(string commandName, QueryCommandOptions options, IReadOnlySet supportedFormats, string hint) { if (supportedFormats.Contains(options.OutputFormat)) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index fbabea01a..dae29084c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -68,6 +68,9 @@ public static partial class QueryCommandRunner public sealed class QueryCommandOptions { + internal QueryCommandInvocationContext InvocationContext { get; set; } = QueryCommandInvocationContext.Search; + internal JsonSerializerOptions? InvocationJsonOptions { get; set; } + internal bool InvocationMachineErrorOutputRequested { get; set; } public string DbPath { get; init; } = Path.Combine(".cdidx", "codeindex.db"); public bool DbPathExplicit { get; init; } public bool ReadOnly { get; init; } @@ -224,3 +227,41 @@ public sealed class QueryCommandOptions public sealed record SearchNamedQuery(string Name, string Query); public readonly record struct DependencyCycleCursor(int Offset, string Fingerprint); + +internal sealed record QueryCommandInvocationContext( + string CommandName, + string UsageCommandName, + string ValidationCommandName, + bool RecipeNameIsPositional, + bool StructuredMachineUsageErrors) +{ + internal static QueryCommandInvocationContext Search { get; } = + new("search", "search", "search", RecipeNameIsPositional: false, StructuredMachineUsageErrors: false); + + internal static QueryCommandInvocationContext Recipes { get; } = + new("recipes", "recipes", "recipes", RecipeNameIsPositional: false, StructuredMachineUsageErrors: false); + + internal static QueryCommandInvocationContext Audit { get; } = + new("audit", "audit", "search", RecipeNameIsPositional: true, StructuredMachineUsageErrors: true); + + internal string UsageLine => + ConsoleUi.GetUsageLine(UsageCommandName) + ?? throw new InvalidOperationException($"Missing usage line for command '{UsageCommandName}'."); + + internal string RecipeDiscoveryCommand => + RecipeNameIsPositional ? "cdidx recipes" : "cdidx search --list-recipes"; + + internal string RecipeSelectorSyntax => + RecipeNameIsPositional + ? "cdidx audit /" + : "cdidx search --recipe /"; + + internal void AddRecipeCommandPrefix(List args, string recipeSelector) + { + args.Add("cdidx"); + args.Add(CommandName); + if (!RecipeNameIsPositional) + args.Add("--recipe"); + args.Add(recipeSelector); + } +} diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index ac78dbe27..b9d072aed 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -8467,6 +8467,156 @@ public void RunSearch_RecipeRejectsRowSelectionForNonRowOutputs_Issue4759() } } + [Fact] + public void RunAudit_UsageErrorsRetainPublicCommandContext_Issue4875() + { + var humanCases = new[] + { + (Args: Array.Empty(), Expected: "audit requires a recipe name"), + (Args: new[] { "risky-code", "--limit", "not-a-number" }, Expected: "--limit requires an integer"), + (Args: new[] { "risky-code", "--format", "grouped" }, Expected: "--format grouped is only supported for plain search output"), + (Args: new[] { "not-a-recipe" }, Expected: "unknown audit recipe 'not-a-recipe'"), + (Args: new[] { "xml-parser-security/xml-readr-settings" }, Expected: "unknown recipe query 'xml-readr-settings'"), + (Args: new[] { "risky-code", "--count-by", "path", "--unique", "file" }, Expected: "--count-by cannot be combined with --unique"), + }; + + foreach (var testCase in humanCases) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["audit", .. testCase.Args], + _jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains(testCase.Expected, stderr, StringComparison.Ordinal); + Assert.Contains("Usage: cdidx audit ", stderr, StringComparison.Ordinal); + Assert.DoesNotContain("Usage: cdidx search ", stderr, StringComparison.Ordinal); + } + + var (_, _, unknownRecipeStderr) = CaptureConsole(() => ProgramRunner.Run( + ["audit", "not-a-recipe"], + _jsonOptions, + "test")); + Assert.Contains("Run `cdidx recipes`", unknownRecipeStderr, StringComparison.Ordinal); + + var (_, _, unknownQueryStderr) = CaptureConsole(() => ProgramRunner.Run( + ["audit", "xml-parser-security/xml-readr-settings"], + _jsonOptions, + "test")); + Assert.Contains( + "Retry with `cdidx audit xml-parser-security/xml-reader-settings", + unknownQueryStderr, + StringComparison.Ordinal); + Assert.DoesNotContain("cdidx search --recipe", unknownQueryStderr, StringComparison.Ordinal); + + foreach (var jsonArgs in new[] + { + new[] { "--json" }, + new[] { "risky-code", "--limit", "not-a-number", "--json" }, + new[] { "not-a-recipe", "--json" }, + new[] { "xml-parser-security/xml-readr-settings", "--json" }, + new[] { "risky-code", "--format", "grouped", "--json" }, + }) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["audit", .. jsonArgs], + _jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + Assert.Equal("error", document.RootElement.GetProperty("status").GetString()); + Assert.Equal("audit", document.RootElement.GetProperty("command").GetString()); + Assert.Equal(CommandErrorCodes.UsageError, document.RootElement.GetProperty("error_code").GetString()); + Assert.False(document.RootElement.TryGetProperty("usage", out _)); + } + + var (searchExitCode, searchStdout, searchStderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", "--recipe", "risky-code", "--limit", "not-a-number"], + _jsonOptions, + "test")); + Assert.Equal(CommandExitCodes.UsageError, searchExitCode); + Assert.Equal(string.Empty, searchStdout); + Assert.Contains("Usage: cdidx search ", searchStderr, StringComparison.Ordinal); + Assert.DoesNotContain("Usage: cdidx audit ", searchStderr, StringComparison.Ordinal); + } + + [Fact] + public void RunAudit_RecipeReplayCommandsRetainPublicCommandContext_Issue4875() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_audit_command_context"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/first.cs", + "csharp", + "Console.WriteLine(ex.Message);\n"); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/second.cs", + "csharp", + "Console.Error.WriteLine(ex.Message);\n"); + + var (draftExitCode, draftStdout, draftStderr) = CaptureConsole(() => ProgramRunner.Run( + [ + "audit", + "risky-code/raw-diagnostic-echo", + "--db", dbPath, + "--format", "issue-drafts", + "--limit", "1", + "--snippet-lines", "1", + ], + _jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.Success, draftExitCode); + Assert.Equal(string.Empty, draftStderr); + using var draftDocument = ParseJsonOutput(draftStdout); + var draft = Assert.Single(draftDocument.RootElement.GetProperty("drafts").EnumerateArray()); + var draftBody = draft.GetProperty("body").GetString(); + Assert.Contains( + "cdidx audit risky-code/raw-diagnostic-echo --format issue-drafts", + draftBody, + StringComparison.Ordinal); + Assert.DoesNotContain("cdidx search --recipe", draftBody, StringComparison.Ordinal); + + var (compactExitCode, compactStdout, compactStderr) = CaptureConsole(() => ProgramRunner.Run( + [ + "audit", + "risky-code/raw-diagnostic-echo", + "--db", dbPath, + "--format", "compact", + "--limit", "1", + ], + _jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.Success, compactExitCode); + Assert.Equal(string.Empty, compactStderr); + using var compactDocument = ParseJsonOutput(compactStdout); + var nextCommands = compactDocument.RootElement + .GetProperty("next_commands") + .EnumerateArray() + .Select(item => item.GetString()) + .ToArray(); + Assert.NotEmpty(nextCommands); + Assert.All( + nextCommands, + 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 + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunAudit_CountRejectsRowSelectorsBeforeExecution_Issue4843() { From cefbda2dce72d812155b42aff6315ea8c8d3a277 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 15:34:54 +0900 Subject: [PATCH 2/3] Address adversarial audit review findings (#4875) --- src/CodeIndex/Cli/CommandErrorWriter.cs | 78 +++++++++++++------ .../QueryCommandRunner.OptionValidation.cs | 33 ++++++-- .../Cli/QueryCommandRunner.Search.cs | 5 +- .../Cli/QueryCommandRunner.SearchAliases.cs | 23 +++--- .../Cli/QueryCommandRunner.Validation.cs | 34 +++++++- .../QueryCommandRunnerSearchTests.cs | 32 ++++++++ 6 files changed, 157 insertions(+), 48 deletions(-) diff --git a/src/CodeIndex/Cli/CommandErrorWriter.cs b/src/CodeIndex/Cli/CommandErrorWriter.cs index e3a0d9efe..8c7f85f1d 100644 --- a/src/CodeIndex/Cli/CommandErrorWriter.cs +++ b/src/CodeIndex/Cli/CommandErrorWriter.cs @@ -49,33 +49,23 @@ internal static int WriteJsonOrHuman( string? category = null, string? command = null, string? path = null, - JsonObject? additionalJsonProperties = null) + JsonObject? additionalJsonProperties = null, + bool omitNullUsage = false) { if (json) { - var (resolvedErrorCode, resolvedCategory) = ResolveMachineContract(exitCode, errorCode, category); - var payload = JsonSerializer.SerializeToNode( - new CommandErrorJsonResult( - "error", - message, - hint ?? DefaultHint, - resolvedErrorCode, - path, - resolvedCategory, - command, - exitCode, - usage), - CliJsonSerializerContextFactory.Create(jsonOptions).CommandErrorJsonResult)!.AsObject(); - if (usage == null) - payload.Remove("usage"); - if (additionalJsonProperties != null) - { - foreach (var property in additionalJsonProperties) - { - if (!payload.ContainsKey(property.Key)) - payload[property.Key] = property.Value?.DeepClone(); - } - } + var payload = BuildJsonPayload( + jsonOptions, + message, + exitCode, + hint, + usage, + errorCode, + category, + command, + path, + additionalJsonProperties, + omitNullUsage); WriteStdout(payload.ToJsonString(jsonOptions)); return exitCode; @@ -85,6 +75,46 @@ internal static int WriteJsonOrHuman( return exitCode; } + internal static JsonObject BuildJsonPayload( + JsonSerializerOptions jsonOptions, + string message, + int exitCode, + string? hint = null, + string? usage = null, + string? errorCode = null, + string? category = null, + string? command = null, + string? path = null, + JsonObject? additionalJsonProperties = null, + bool omitNullUsage = false) + { + var (resolvedErrorCode, resolvedCategory) = ResolveMachineContract(exitCode, errorCode, category); + var payload = JsonSerializer.SerializeToNode( + new CommandErrorJsonResult( + "error", + message, + hint ?? DefaultHint, + resolvedErrorCode, + path, + resolvedCategory, + command, + exitCode, + usage), + CliJsonSerializerContextFactory.Create(jsonOptions).CommandErrorJsonResult)!.AsObject(); + if (omitNullUsage && usage == null) + payload.Remove("usage"); + if (additionalJsonProperties != null) + { + foreach (var property in additionalJsonProperties) + { + if (!payload.ContainsKey(property.Key)) + payload[property.Key] = property.Value?.DeepClone(); + } + } + + return payload; + } + internal static (string ErrorCode, string Category) ResolveMachineContract( int exitCode, string? errorCode = null, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs index d520ae20d..b0a5d2e27 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs @@ -138,15 +138,23 @@ private static void WriteParseError( && (!invocationContext.StructuredMachineUsageErrors || options.InvocationMachineErrorOutputRequested)) { + if (invocationContext.StructuredMachineUsageErrors) + { + WriteInvocationUsageError( + StripErrorPrefix(error), + options, + hint, + ExtractErrorCode(error)); + return; + } + CommandErrorWriter.WriteJsonOrHuman( true, jsonOptions, StripErrorPrefix(error), CommandExitCodes.UsageError, hint, - invocationContext.StructuredMachineUsageErrors - ? null - : invocationContext.UsageLine, + invocationContext.UsageLine, ExtractErrorCode(error), category: "usage", command: invocationContext.CommandName); @@ -318,13 +326,14 @@ private static bool TryWriteUnsupportedOptionError( StructuredMachineUsageErrors: false), cmdArgs, supportedOptions, - queryLiteral, - jsonOptions); + queryLiteral: queryLiteral, + jsonOptions: jsonOptions); private static bool TryWriteUnsupportedOptionError( QueryCommandInvocationContext invocationContext, string[] cmdArgs, IEnumerable supportedOptions, + QueryCommandOptions? options = null, string? queryLiteral = null, JsonSerializerOptions? jsonOptions = null) { @@ -335,15 +344,23 @@ void WriteOptionError(string message, string hint, string? errorCode = null) if (jsonOptions != null && ProgramRunner.ContainsJsonOutputFlag(cmdArgs)) { + if (invocationContext.StructuredMachineUsageErrors && options != null) + { + WriteInvocationUsageError( + message, + options, + hint, + errorCode); + return; + } + CommandErrorWriter.WriteJsonOrHuman( true, jsonOptions, message, CommandExitCodes.UsageError, hint, - invocationContext.StructuredMachineUsageErrors - ? null - : invocationContext.UsageLine, + invocationContext.UsageLine, errorCode ?? CommandErrorCodes.UsageError, command: commandName); return; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs index cfaf765e7..84cfe857c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -71,6 +71,7 @@ private static int RunSearchCore( invocationContext, validationArgs, CliFlagSchema.GetAcceptedFlagNamesForCommand(invocationContext.ValidationCommandName), + options, options.Query, invocationContext.StructuredMachineUsageErrors ? jsonOptions : null)) return CommandExitCodes.UsageError; @@ -261,7 +262,7 @@ private static int RunSearchCore( var exactSearch = exact || options.TokenBoundary; if (options.TokenBoundary && options.RawFts) { - WriteUsageError( + WriteSearchValidationError( "--token-boundary cannot be combined with --fts.", options, "Drop --fts to use exact token-boundary matching, or drop --token-boundary to keep raw FTS5 syntax."); @@ -269,7 +270,7 @@ private static int RunSearchCore( } if (exactSearch && options.Prefix) { - WriteUsageError( + WriteSearchValidationError( "--prefix cannot be combined with --exact / --exact-substring / --token-boundary (exact uses instr(), not FTS5 prefix phrases).", options, "Drop --prefix to keep the exact substring path, or drop the exact-mode flag to opt into FTS5 prefix matching."); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs index 7e418b55c..0f3bbe565 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchAliases.cs @@ -22,17 +22,20 @@ internal static int RunAudit( { if (subArgs.Length == 0 || subArgs[0].StartsWith("-", StringComparison.Ordinal)) { - var machineOutputRequested = ProgramRunner.ContainsJsonOutputFlag(subArgs); - return CommandErrorWriter.WriteJsonOrHuman( - machineOutputRequested, - jsonOptions, + var options = ParseArgs( + subArgs, + jsonDefault: false, + allowNamedQuery: true, + allowIssueDraftsFormat: true, + applySearchSourceDefaults: true); + options.InvocationContext = QueryCommandInvocationContext.Audit; + options.InvocationJsonOptions = jsonOptions; + options.InvocationMachineErrorOutputRequested = ProgramRunner.ContainsJsonOutputFlag(subArgs); + WriteUsageError( "audit requires a recipe name.", - CommandExitCodes.UsageError, - "pass a recipe name after `cdidx audit`, or run `cdidx recipes` to list built-in recipes.", - machineOutputRequested ? null : ConsoleUi.GetUsageLine("audit"), - CommandErrorCodes.UsageError, - category: "usage", - command: "audit"); + options, + "pass a recipe name after `cdidx audit`, or run `cdidx recipes` to list built-in recipes."); + return CommandExitCodes.UsageError; } var hasSummaryOnly = false; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs b/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs index 0590e1623..fe4f6dc65 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace CodeIndex.Cli; public static partial class QueryCommandRunner @@ -139,22 +141,35 @@ private static void WriteUsageError(string message, string usage, string hint) => CommandErrorWriter.Write(message, hint, usage); private static void WriteUsageError(string message, QueryCommandOptions options, string hint) + => WriteInvocationUsageError(message, options, hint); + + private static void WriteInvocationUsageError( + string message, + QueryCommandOptions options, + string hint, + string? errorCode = null) { var invocationContext = options.InvocationContext; if (options.InvocationMachineErrorOutputRequested && invocationContext.StructuredMachineUsageErrors && options.InvocationJsonOptions != null) { - CommandErrorWriter.WriteJsonOrHuman( - true, + var payload = CommandErrorWriter.BuildJsonPayload( options.InvocationJsonOptions, message, CommandExitCodes.UsageError, hint, usage: null, - errorCode: CommandErrorCodes.UsageError, + errorCode: errorCode ?? CommandErrorCodes.UsageError, category: "usage", - command: invocationContext.CommandName); + command: invocationContext.CommandName, + omitNullUsage: true); + var json = payload.ToJsonString(options.InvocationJsonOptions); + if (options.MaxJsonBytes.HasValue + && Encoding.UTF8.GetByteCount(json) + Encoding.UTF8.GetByteCount(Environment.NewLine) > options.MaxJsonBytes.Value) + return; + + CommandErrorWriter.WriteStdout(json); return; } @@ -179,6 +194,17 @@ private static void WriteUsageError( WriteUsageError(message, GetUsageLineOrThrow(fallbackCommandName), hint); } + private static void WriteSearchValidationError(string message, QueryCommandOptions options, string hint) + { + if (options.InvocationContext.StructuredMachineUsageErrors) + { + WriteUsageError(message, options, hint); + return; + } + + WriteValidationError(message, hint); + } + private static bool TryWriteUnsupportedOutputFormat(string commandName, QueryCommandOptions options, IReadOnlySet supportedFormats, string hint) { if (supportedFormats.Contains(options.OutputFormat)) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index b9d072aed..84ab651a6 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -8533,6 +8533,22 @@ public void RunAudit_UsageErrorsRetainPublicCommandContext_Issue4875() Assert.False(document.RootElement.TryGetProperty("usage", out _)); } + foreach (var cappedArgs in new[] + { + new[] { "--json", "--max-json-bytes", "1" }, + new[] { "risky-code", "--json", "--max-json-bytes", "1", "--profile" }, + }) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["audit", .. cappedArgs], + _jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Equal(string.Empty, stderr); + } + var (searchExitCode, searchStdout, searchStderr) = CaptureConsole(() => ProgramRunner.Run( ["search", "--recipe", "risky-code", "--limit", "not-a-number"], _jsonOptions, @@ -8541,6 +8557,22 @@ public void RunAudit_UsageErrorsRetainPublicCommandContext_Issue4875() Assert.Equal(string.Empty, searchStdout); Assert.Contains("Usage: cdidx search ", searchStderr, StringComparison.Ordinal); Assert.DoesNotContain("Usage: cdidx audit ", searchStderr, StringComparison.Ordinal); + + foreach (var directSearchArgs in new[] + { + new[] { "x", "--token-boundary", "--fts" }, + new[] { "x", "--exact", "--prefix" }, + }) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => ProgramRunner.Run( + ["search", .. directSearchArgs], + _jsonOptions, + "test")); + + Assert.Equal(CommandExitCodes.UsageError, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.DoesNotContain("Usage:", stderr, StringComparison.Ordinal); + } } [Fact] From 378959ce8f071fc7719e415fa7e812ed91000f12 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 29 Jul 2026 16:07:07 +0900 Subject: [PATCH 3/3] Complete audit recovery command context (#4875) --- .../Cli/QueryCommandRunner.Search.cs | 4 +- .../Cli/QueryCommandRunner.SearchRecipes.cs | 42 ++++++++++++++----- src/CodeIndex/Cli/QueryCommandRunner.cs | 9 ++++ .../QueryCommandRunnerSearchTests.cs | 15 +++++++ 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs index 84cfe857c..ad77dc54a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Search.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Search.cs @@ -512,13 +512,13 @@ private static int RunSearchCore( WriteUsageError( "--group-by for recipe search must be one of file, symbol, origin, return-type, or subsystem.", options, - "Use `cdidx search --recipe --group-by file --count`, `--group-by symbol --count`, `--group-by return-type --count`, `--group-by subsystem --count`, or `--count-by origin`."); + $"Use `{invocationContext.RecipeCommandPrefix} --group-by file --count`, `--group-by symbol --count`, `--group-by return-type --count`, `--group-by subsystem --count`, or `--count-by origin`."); return CommandExitCodes.UsageError; } if (!options.CountOnly) { WriteUsageError( - "search --recipe --group-by requires --count.", + $"{invocationContext.RecipeExecutionName} --group-by requires --count.", options, "Add --count to request grouped recipe result counts, or remove --group-by to print matching snippets."); return CommandExitCodes.UsageError; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index a95d75900..5fa233fd8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -700,7 +700,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp compactJson, options, "recipe compact", - "Reduce --limit or --total-limit, select one child query with --recipe /, stream rows with --json=ndjson, or increase --max-json-bytes."); + $"Reduce --limit or --total-limit, select one child query with {options.InvocationContext.RecipeCursorSelectorSyntax}, stream rows with --json=ndjson, or increase --max-json-bytes."); } var queryResults = CollectSearchRecipeQueryResults(reader, selection.Queries, scope, options, userExact, out var total, out _); @@ -720,7 +720,12 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp scope, selection.Queries.Count, total, - BuildSearchRecipeRunSummary(queryResults, options.Limit, options.TotalLimit, total), + BuildSearchRecipeRunSummary( + queryResults, + options.Limit, + options.TotalLimit, + total, + options.InvocationContext), queryResults), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeRunJsonResult); return WriteJsonObjectWithOptionalByteLimit( @@ -791,7 +796,12 @@ private static void WriteSearchRecipeSarif( QueryCommandOptions options, JsonSerializerOptions jsonOptions) { - var summary = BuildSearchRecipeRunSummary(queryResults, options.Limit, options.TotalLimit, total); + var summary = BuildSearchRecipeRunSummary( + queryResults, + options.Limit, + options.TotalLimit, + total, + options.InvocationContext); var querySummaries = new JsonArray(); foreach (var queryResult in queryResults) { @@ -959,7 +969,12 @@ private static JsonObject BuildSearchRecipeCompactRunPayload( scope, selectedQueries.Count, compactTotal, - BuildSearchRecipeRunSummary(compactQueryResults, options.Limit, options.TotalLimit, compactTotal), + BuildSearchRecipeRunSummary( + compactQueryResults, + options.Limit, + options.TotalLimit, + compactTotal, + options.InvocationContext), compactQueryResults); var payload = JsonSerializer.SerializeToNode( run, @@ -2209,7 +2224,8 @@ private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( IReadOnlyList queryResults, int limitPerQuery, int? totalLimit, - int emittedResultCount) + int emittedResultCount, + QueryCommandInvocationContext invocationContext) => new( limitPerQuery, totalLimit, @@ -2220,7 +2236,8 @@ private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor)), BuildSearchRecipeCursoringHint( queryResults.Any(query => query.Truncated), - queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor))), + queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor)), + invocationContext), queryResults.Sum(query => query.SourceTotal), queryResults.All(query => query.SourceTotalAuthoritative), queryResults.All(query => query.SourceTotalAuthoritative) @@ -2235,7 +2252,8 @@ private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( IReadOnlyList queryResults, int limitPerQuery, int? totalLimit, - int emittedResultCount) + int emittedResultCount, + QueryCommandInvocationContext invocationContext) => new( limitPerQuery, totalLimit, @@ -2246,7 +2264,8 @@ private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor)), BuildSearchRecipeCursoringHint( queryResults.Any(query => query.Truncated), - queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor))), + queryResults.Any(query => query.Truncated && !string.IsNullOrWhiteSpace(query.NextCursor)), + invocationContext), queryResults.Sum(query => query.SourceTotal), queryResults.All(query => query.SourceTotalAuthoritative), queryResults.All(query => query.SourceTotalAuthoritative) @@ -2257,9 +2276,12 @@ private static SearchRecipeRunSummaryJsonResult BuildSearchRecipeRunSummary( queryResults.Sum(query => query.SelectorOmittedCount), queryResults.Sum(query => query.LimitOmittedCount)); - private static string BuildSearchRecipeCursoringHint(bool hasTruncatedQuery, bool cursoringAvailable) + private static string BuildSearchRecipeCursoringHint( + bool hasTruncatedQuery, + bool cursoringAvailable, + QueryCommandInvocationContext invocationContext) => cursoringAvailable - ? "When a query is truncated, rerun a single child query with --recipe / --cursor to page the next result set." + ? $"When a query is truncated, rerun a single child query with {invocationContext.RecipeCursorSelectorSyntax} --cursor to page the next result set." : hasTruncatedQuery ? "Continuation cursors are unavailable for the selected rows; increase --limit or --total-limit and rerun." : "No query is truncated, so no continuation cursor is needed."; diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index dae29084c..85b467fb0 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -256,6 +256,15 @@ internal sealed record QueryCommandInvocationContext( ? "cdidx audit /" : "cdidx search --recipe /"; + internal string RecipeCommandPrefix => + RecipeNameIsPositional ? "cdidx audit" : "cdidx search --recipe"; + + internal string RecipeExecutionName => + RecipeNameIsPositional ? "audit" : "search --recipe"; + + internal string RecipeCursorSelectorSyntax => + RecipeNameIsPositional ? "cdidx audit /" : "--recipe /"; + internal void AddRecipeCommandPrefix(List args, string recipeSelector) { args.Add("cdidx"); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 84ab651a6..5990a0aca 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -8478,6 +8478,8 @@ public void RunAudit_UsageErrorsRetainPublicCommandContext_Issue4875() (Args: new[] { "not-a-recipe" }, Expected: "unknown audit recipe 'not-a-recipe'"), (Args: new[] { "xml-parser-security/xml-readr-settings" }, Expected: "unknown recipe query 'xml-readr-settings'"), (Args: new[] { "risky-code", "--count-by", "path", "--unique", "file" }, Expected: "--count-by cannot be combined with --unique"), + (Args: new[] { "risky-code", "--group-by", "bogus", "--count" }, Expected: "--group-by for recipe search"), + (Args: new[] { "risky-code", "--group-by", "file" }, Expected: "audit --group-by requires --count"), }; foreach (var testCase in humanCases) @@ -8510,6 +8512,13 @@ public void RunAudit_UsageErrorsRetainPublicCommandContext_Issue4875() StringComparison.Ordinal); Assert.DoesNotContain("cdidx search --recipe", unknownQueryStderr, StringComparison.Ordinal); + var (_, _, groupByStderr) = CaptureConsole(() => ProgramRunner.Run( + ["audit", "risky-code", "--group-by", "bogus", "--count"], + _jsonOptions, + "test")); + Assert.Contains("cdidx audit --group-by file --count", groupByStderr, StringComparison.Ordinal); + Assert.DoesNotContain("cdidx search --recipe", groupByStderr, StringComparison.Ordinal); + foreach (var jsonArgs in new[] { new[] { "--json" }, @@ -8630,6 +8639,12 @@ public void RunAudit_RecipeReplayCommandsRetainPublicCommandContext_Issue4875() Assert.Equal(CommandExitCodes.Success, compactExitCode); Assert.Equal(string.Empty, compactStderr); using var compactDocument = ParseJsonOutput(compactStdout); + var cursoringHint = compactDocument.RootElement + .GetProperty("summary") + .GetProperty("cursoring_hint") + .GetString(); + Assert.Contains("cdidx audit / --cursor", cursoringHint, StringComparison.Ordinal); + Assert.DoesNotContain("--recipe", cursoringHint, StringComparison.Ordinal); var nextCommands = compactDocument.RootElement .GetProperty("next_commands") .EnumerateArray()