diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 2d416f878..ec31c90c8 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -342,6 +342,23 @@ Usage: When an error code is available, the first line is `Error []: `. Use `CommandErrorWriter` for new CLI parse, validation, and filesystem preflight errors so `ProgramRunner`, `IndexCommandRunner`, and query runners keep the same format. JSON error payloads continue to use `CommandErrorJsonResult`. +For JSON mode, recoverable non-database failures use the same versioned +`CommandErrorJsonResult` envelope. It requires `api_version`, `status`, +`message`, `hint`, `error_code`, `category`, `command`, `exit_code`, and +`usage`; command-specific fields may be merged only after paths, warnings, and +previews are sanitized and bounded. JSON failures write the envelope to stdout +and leave stderr empty. Human failures write the matching coded `Error`, +`Hint`, and `Usage` lines to stderr and leave stdout empty. + +| Failure class | Exit code | Error code | Category | +|---|---:|---|---| +| Usage / invalid arguments | 1 or 7 | `E010_USAGE_ERROR` | `usage` | +| Missing outline path | 2 | `E019_FILE_NOT_FOUND` | `not_found` | +| Invalid configuration | 1 | `E024_CONFIG_INVALID` | `configuration` | +| Hook platform or filesystem failure | 9 | `E025_HOOK_OPERATION_FAILED` | `platform` | +| Hooks outside a Git repository | 2 | `E026_NOT_GIT_REPOSITORY` | `not_found` | +| Other recoverable command failure | command-specific | `E023_COMMAND_FAILED` | stable writer classification | + ### Process launch policy All production subprocess launch sites must use `ProcessStartInfo.ArgumentList` and must leave `UseShellExecute` disabled. Start-info construction belongs in `ProcessLaunchPolicy` or a nearby purpose-specific helper that calls it, so git, isolated workers, hook callbacks, installer dispatch, and other subprocesses share the same argument, encoding, and no-shell defaults. @@ -3557,6 +3574,23 @@ validation、filesystem preflight error は `CommandErrorWriter` を使い、`Pr `IndexCommandRunner`、query runner の形式を揃えてください。JSON error payload は `CommandErrorJsonResult` を使い続けます。 +JSON mode の回復可能な非データベース系失敗も、共通のバージョン付き +`CommandErrorJsonResult` envelope を使います。`api_version`、`status`、 +`message`、`hint`、`error_code`、`category`、`command`、`exit_code`、 +`usage` は必須です。command 固有 field を加える場合は、path、warning、preview を +sanitization し、上限を適用してから merge します。JSON の失敗は envelope を stdout に +出し、stderr を空に保ちます。human の失敗は対応する code 付き `Error`、`Hint`、 +`Usage` を stderr に出し、stdout を空に保ちます。 + +| failure class | exit code | error code | category | +|---|---:|---|---| +| usage / 不正な引数 | 1 または 7 | `E010_USAGE_ERROR` | `usage` | +| outline path が見つからない | 2 | `E019_FILE_NOT_FOUND` | `not_found` | +| 不正な設定 | 1 | `E024_CONFIG_INVALID` | `configuration` | +| hook の platform / filesystem failure | 9 | `E025_HOOK_OPERATION_FAILED` | `platform` | +| Git repository 外での hooks 実行 | 2 | `E026_NOT_GIT_REPOSITORY` | `not_found` | +| その他の回復可能な command failure | command ごと | `E023_COMMAND_FAILED` | writer による安定した分類 | + ### プロセス起動ポリシー 本番の subprocess 起動箇所はすべて `ProcessStartInfo.ArgumentList` を使い、`UseShellExecute` を無効のままにしてください。start-info の構築は `ProcessLaunchPolicy` またはそれを呼ぶ用途別 helper に置き、git、isolated worker、hook callback、installer dispatch、その他の subprocess が同じ argument、encoding、no-shell default を共有できるようにします。 diff --git a/README.md b/README.md index 8fb848a92..629380a32 100644 --- a/README.md +++ b/README.md @@ -199,6 +199,17 @@ versioned surfaces are the `cdidx` CLI, CLI JSON output, and `cdidx mcp` JSON-RPC interface. There is no public library / SDK API. See [INTEGRATION_POLICY.md](INTEGRATION_POLICY.md#api-surface-and-library-use). +## CLI JSON Error Contract + +Recoverable non-database failures from commands such as `outline`, `hooks`, +`doctor`, and `validate-config` use one versioned JSON envelope. The envelope +includes `api_version`, `status`, `message`, `hint`, `error_code`, `category`, +`command`, `exit_code`, and `usage`, plus only sanitized optional context such +as `path`. JSON mode writes that envelope to stdout without human prose on +stderr. Human mode keeps the corresponding `Error`, `Hint`, and `Usage` lines. +See the [Developer Guide](DEVELOPER_GUIDE.md#cli-recoverable-error-format) for +the stable code/category mapping. + ## Status JSON Contract `cdidx status --json` exposes trust, freshness, compatibility, and remediation @@ -612,6 +623,18 @@ commit し、構造化 `file_errors` を返して partial-result 終了コード JSON-RPC interface です。公開 library / SDK API は提供していません。詳細は [INTEGRATION_POLICY.md](INTEGRATION_POLICY.md#api-surface-and-library-use) を参照してください。 +## CLI JSON エラー契約 + +`outline`、`hooks`、`doctor`、`validate-config` などの回復可能な +非データベース系失敗は、共通のバージョン付き JSON envelope を使います。 +envelope には `api_version`、`status`、`message`、`hint`、`error_code`、 +`category`、`command`、`exit_code`、`usage` を含め、`path` などの任意の +context は sanitization 済みの値だけを追加します。JSON mode は human 向け +prose を stderr に混ぜず、envelope を stdout に出力します。human mode は +対応する `Error`、`Hint`、`Usage` の各行を維持します。安定した code / +category 対応は +[開発者ガイド](DEVELOPER_GUIDE.md#cli-の回復可能エラー形式) を参照してください。 + ## Status JSON 契約 `cdidx status --json` は script、MCP client、release check 向けに trust、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 1a9218e46..3fdeec1ac 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -566,6 +566,8 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Request-lifetime coverage keeps body-idle and correlated total-timeout configuration in that matrix. Use gated body streams for idle deadlines and bounded real TCP disconnects for queued and executing requests; assert queue removal, tool/DB cancellation, response closure, semaphore/body-budget cleanup, and health counters without fixed sleeps. Probe coverage must also keep chunked leading whitespace JSON-parseable after session establishment, prove the initial headerless `initialize` does not probe or commit headers before returning `Mcp-Session-Id`, and prove notifications retain `204 No Content` without starting a probe. Signal-gated non-cooperative output tests must prove probe shutdown and caller-cancelled SSE delivery keep their serialization gate until the started write settles, while a probe write timeout terminally cancels and cleans the queued request. Retained-resource coverage keeps a completed HTTP response's byte reservation charged until its supplied drain task finishes and proves a saturated replacement request is rejected in the interim. - `McpServer*Tests.cs` request-lifetime cancellation coverage Keep `maxConcurrency: 1` and signal-gated cancellation-ignoring isolated actions in both the sequential transport and concurrent stdio loops. The paired cancellation response may complete promptly, but the next normal request must not enter its delay hook until the detached action exits; the sequential probe transport must also observe the same incomplete completion task through `RetainCurrentRequestResourcesUntil` before the paired write. +- `CliNonDatabaseErrorContractTests.cs` + Table-driven empty, not-found, invalid-argument, invalid-config, and hook-platform failures. Keep JSON assertions focused on the stable versioned envelope and sanitized optional context, pair every scenario with its coded human `Error` / `Hint` / `Usage` form, and restore current-directory and hook filesystem seams after each case. - `GitHelperTests.cs`, `GitProcessRunnerTests.cs`, `HookCommandRunnerTests.cs` Git-specific behavior, including worktrees, commit-based updates, direct git process runner diagnostics, cancellation of git subprocesses, portable absolute executable overrides, and fail-closed metadata-write boundaries. Hook install coverage keeps `installed`, `updated`, and `already_installed` distinct, proves an exact executable UTF-8/no-BOM rerun preserves the hook write time, repairs non-executable and differently encoded managed hooks, and exercises dry-run create, managed replacement, custom-hook chain, blocked, and no-op plans without creating the missing hooks directory or changing either hook file. Keep executable-override fixtures owner-only-writable, owned by the current test user, under trusted ancestors (including an explicit root-owned sticky `/tmp` case), and able to return `git version` from `--version` unless the test is asserting a rejection reason. Symlinked `.git` directory/file, intermediate metadata components, hard-linked metadata files, worktree target, `commondir`, `info`, `exclude`, `hooks`, and hook-file coverage must prove resolution is rejected before metadata is written outside the project boundary. Tests that create real repositories or launch real/fake git subprocesses use `ExternalProcessFactAttribute` / `ExternalProcessTheoryAttribute` and run only on the `net8.0` test target; keep pure `.git` metadata parsing, trust diagnostics, and trusted-candidate enumeration cross-target. Timeout and cancellation wall-clock assertions should stay below the fake git scripts' natural completion while leaving room for macOS CI scheduling and process-cleanup overhead. Fake git scripts that run after commit-ref validation should echo the verified commit argument for `rev-parse --verify ^{commit}` so timeout tests reach the intended git command. - `WorkspaceMetadataEnricherTests.cs` @@ -1459,6 +1461,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" request-lifetime coverage では body-idle と相関する total-timeout 設定を同じ matrix に含めてください。idle deadline には gated body stream、queued / executing request には bounded な実 TCP disconnect を使い、固定 sleep に依存せず queue removal、tool / DB cancellation、response close、semaphore / body-budget cleanup、health counter を検証してください。probe coverage では session 確立後の chunked response が先頭空白を含めて JSON parse 可能なこと、最初の headerless `initialize` が probe や早期 header commit を行わず `Mcp-Session-Id` を返すこと、および notification が probe を開始せず `204 No Content` を維持することも確認してください。signal-gated な non-cooperative output test では、probe shutdown と caller-cancel 済み SSE delivery が開始済み write の終了まで serialization gate を保持し、probe write timeout が queued request を terminal に cancel して cleanup することを証明してください。retained-resource coverage では、完了済み HTTP response の byte reservation を supplied drain task の完了まで計上し続け、その間の replacement request が budget 飽和として拒否されることを確認してください。 - `McpServer*Tests.cs` の request-lifetime cancellation coverage sequential transport と concurrent stdio loop の双方で `maxConcurrency: 1` と signal-gated な cancellation-ignoring isolated action を維持してください。対応する cancellation response は速やかに完了しても、detached action が終了するまで次の normal request が delay hook へ入ってはなりません。sequential probe transport は、対応 write より前に同じ未完了 completion task を `RetainCurrentRequestResourcesUntil` で観測することも検証してください。 +- `CliNonDatabaseErrorContractTests.cs` + empty、not-found、不正な引数、不正な設定、hook platform failure を表駆動で検証します。JSON assertion は安定したバージョン付き envelope と sanitization 済みの任意 context に集中させ、各 scenario で code 付き human `Error` / `Hint` / `Usage` も対にして検証し、current directory と hook filesystem seam を各 case の後に復元してください。 - `GitHelperTests.cs`、`GitProcessRunnerTests.cs`、`HookCommandRunnerTests.cs` worktree、commit ベース更新、direct git process runner diagnostics、git subprocess cancellation、portable な absolute executable override、fail-closed な metadata-write boundary を含む Git まわりのテスト。hook install coverage では `installed`、`updated`、`already_installed` を区別し、同一かつ実行可能な UTF-8/no-BOM の内容での再実行が hook の write time を維持すること、実行不可または異なる encoding の managed hook を修復すること、および dry-run の create、managed replacement、custom-hook chain、blocked、no-op plan が存在しない hooks directory を作成せず、どちらの hook file も変更しないことを検証します。executable override fixture は rejection reason 自体を検証する場合を除き owner-only-writable、test user 所有、trusted ancestor 配下(root 所有の sticky `/tmp` case を明示的に含む)とし、`--version` で `git version` を返せるようにします。symlinked `.git` directory / file、中間 metadata component、hard-linked metadata file、worktree target、`commondir`、`info`、`exclude`、`hooks`、hook file の coverage は、project boundary 外へ metadata を書く前に解決が拒否されることを証明してください。実 repo を作る、または real/fake git subprocess を起動するテストは `ExternalProcessFactAttribute` / `ExternalProcessTheoryAttribute` を使い、`net8.0` test target だけで実行します。純粋な `.git` metadata parsing、trust diagnostics、trusted-candidate enumeration は cross-target のままにしてください。Timeout と cancellation の wall-clock assertion は fake git script の自然完了より短く保ちつつ、macOS CI の scheduling や process cleanup の遅れを許容する余裕を持たせます。commit-ref validation 後に使う fake git script は `rev-parse --verify ^{commit}` の検証対象 commit 引数を返し、timeout テストが意図した git command まで到達するようにします。 - `WorkspaceMetadataEnricherTests.cs` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 70a3d6027..60171715d 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2070,7 +2070,7 @@ If a query itself begins with `-`, pass it as `--query ` or `-- `. ### Error codes -For scripts and AI agents that need to classify failures without substring-matching the human prose, every CLI error carries a stable machine-readable code. Human stderr prefixes the code in brackets (`Error [E001_DB_NOT_FOUND]: database not found at …`) and CLI `--json` envelopes add an optional `error_code` field (omitted when not applicable, so existing JSON consumers see no schema break). In JSON mode, missing-query validation for `search` / `find`, incompatible `status --config` modes, `definition` / `goto` misses, and missing or out-of-range `excerpt` coordinates are emitted as one versioned `{ "status": "error", ... }` object on stdout instead of plain text or an empty stream. A `definition` miss uses `E018_QUERY_NOT_FOUND` and exit code `2`; bounded-envelope controls retain the error under `metadata.error` with an empty `results` array, while an explicitly impossible `--max-json-bytes` cap instead produces a usage error on stderr before any oversized stdout is written. MCP tool errors usually surface as `isError: true` text content, while newer failure modes can also expose stable fields under `structuredContent`; the bracketed CLI constant is not guaranteed to appear in MCP message text. See [Troubleshooting](#troubleshooting) for the MCP message text and structured fields each failure mode expects clients to match. Codes never get renamed or reused once published — retired codes simply stop being emitted. +For scripts and AI agents that need to classify failures without substring-matching the human prose, every CLI error carries a stable machine-readable code. Human stderr prefixes the code in brackets (`Error [E001_DB_NOT_FOUND]: database not found at …`) and CLI `--json` envelopes add an optional `error_code` field (omitted when not applicable, so existing JSON consumers see no schema break). Recoverable non-database failures from `outline`, `hooks`, `doctor`, and `validate-config` always use the versioned error envelope with `error_code`, `category`, `command`, `exit_code`, `hint`, and `usage`, plus sanitized optional context. In JSON mode, missing-query validation for `search` / `find`, incompatible `status --config` modes, `definition` / `goto` misses, and missing or out-of-range `excerpt` coordinates are emitted as one versioned `{ "status": "error", ... }` object on stdout instead of plain text or an empty stream. A `definition` miss uses `E018_QUERY_NOT_FOUND` and exit code `2`; bounded-envelope controls retain the error under `metadata.error` with an empty `results` array, while an explicitly impossible `--max-json-bytes` cap instead produces a usage error on stderr before any oversized stdout is written. MCP tool errors usually surface as `isError: true` text content, while newer failure modes can also expose stable fields under `structuredContent`; the bracketed CLI constant is not guaranteed to appear in MCP message text. See [Troubleshooting](#troubleshooting) for the MCP message text and structured fields each failure mode expects clients to match. Codes never get renamed or reused once published — retired codes simply stop being emitted. | Code | When emitted | |---|---| @@ -2096,6 +2096,10 @@ For scripts and AI agents that need to classify failures without substring-match | `E020_LINE_OUT_OF_RANGE` | A requested source line falls outside the indexed file's 1-based line range | | `E021_SUGGESTION_STORE_UNAVAILABLE` | Suggestion JSON/archive/lock storage could not be resolved, created, read, or written safely | | `E022_INDEX_PARTIAL` | Indexing committed successful files but one or more files failed; inspect `file_errors` and rerun after fixing that file | +| `E023_COMMAND_FAILED` | A recoverable command failed without a more specific published error code | +| `E024_CONFIG_INVALID` | `validate-config` discovered a configuration file that failed validation | +| `E025_HOOK_OPERATION_FAILED` | A Git hook operation failed at a platform or filesystem boundary | +| `E026_NOT_GIT_REPOSITORY` | `hooks` was run outside a Git worktree and no valid `--project` was supplied | ### Debugging reader errors @@ -5210,7 +5214,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit ### エラーコード -スクリプトや AI エージェントが人間向け文言の部分一致なしで失敗を分類できるよう、CLI のエラーには安定した機械可読コードが付与されます。人間向け stderr ではコードを角括弧で前置し(`Error [E001_DB_NOT_FOUND]: database not found at …`)、CLI `--json` エンベロープには任意フィールド `error_code` を追加します(該当しない場合は省略されるので、既存 JSON 利用者にスキーマ破壊なし)。JSON モードでは、`search` / `find` の query 欠落、`status --config` の mode 競合、`definition` / `goto` の未検出、`excerpt` の file 未検出・行範囲外を plain text や空ストリームではなく、version 付きの `{ "status": "error", ... }` オブジェクト 1 件として stdout に出力します。`definition` の未検出は `E018_QUERY_NOT_FOUND` と終了コード `2` を使い、bounded-envelope control の使用時も空の `results` array と `metadata.error` に error を維持します。明示した `--max-json-bytes` が object を格納できない場合は、上限超過の stdout を書く前に stderr の usage error で終了します。MCP ツールエラーは通常 `isError: true` のテキストコンテンツとして返りますが、新しい失敗モードでは `structuredContent` に安定フィールドを持つこともあります。本文に CLI 側の角括弧付き定数が必ず含まれる保証はありません。MCP クライアントが照合すべき各失敗モードの MCP メッセージ本文と構造化フィールドは [トラブルシューティング](#トラブルシューティング) を参照してください。一度公開したコードは renaming / 使い回しをせず、廃止する場合も新規 emission を止めるだけです。 +スクリプトや AI エージェントが人間向け文言の部分一致なしで失敗を分類できるよう、CLI のエラーには安定した機械可読コードが付与されます。人間向け stderr ではコードを角括弧で前置し(`Error [E001_DB_NOT_FOUND]: database not found at …`)、CLI `--json` エンベロープには任意フィールド `error_code` を追加します(該当しない場合は省略されるので、既存 JSON 利用者にスキーマ破壊なし)。`outline`、`hooks`、`doctor`、`validate-config` の回復可能な非データベース系失敗は、`error_code`、`category`、`command`、`exit_code`、`hint`、`usage` と sanitization 済みの任意 context を持つバージョン付き error envelope を必ず使います。JSON モードでは、`search` / `find` の query 欠落、`status --config` の mode 競合、`definition` / `goto` の未検出、`excerpt` の file 未検出・行範囲外を plain text や空ストリームではなく、version 付きの `{ "status": "error", ... }` オブジェクト 1 件として stdout に出力します。`definition` の未検出は `E018_QUERY_NOT_FOUND` と終了コード `2` を使い、bounded-envelope control の使用時も空の `results` array と `metadata.error` に error を維持します。明示した `--max-json-bytes` が object を格納できない場合は、上限超過の stdout を書く前に stderr の usage error で終了します。MCP ツールエラーは通常 `isError: true` のテキストコンテンツとして返りますが、新しい失敗モードでは `structuredContent` に安定フィールドを持つこともあります。本文に CLI 側の角括弧付き定数が必ず含まれる保証はありません。MCP クライアントが照合すべき各失敗モードの MCP メッセージ本文と構造化フィールドは [トラブルシューティング](#トラブルシューティング) を参照してください。一度公開したコードは renaming / 使い回しをせず、廃止する場合も新規 emission を止めるだけです。 | コード | 発行条件 | |---|---| @@ -5236,6 +5240,10 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `E020_LINE_OUT_OF_RANGE` | 要求した source line が indexed file の 1-based 行範囲外だった | | `E021_SUGGESTION_STORE_UNAVAILABLE` | suggestion JSON / archive / lock の保存先を安全に解決・作成・読み書きできなかった | | `E022_INDEX_PARTIAL` | 成功ファイルを commit した一方で1件以上の file が失敗した。`file_errors` を確認して該当 file の修正後に再実行する | +| `E023_COMMAND_FAILED` | より具体的な公開 error code がない回復可能な command が失敗した | +| `E024_CONFIG_INVALID` | `validate-config` が検出した設定 file の validation に失敗した | +| `E025_HOOK_OPERATION_FAILED` | Git hook 操作が platform または filesystem boundary で失敗した | +| `E026_NOT_GIT_REPOSITORY` | Git worktree 外で `hooks` を実行し、有効な `--project` も指定されていなかった | ### reader エラーのデバッグ diff --git a/changelog.d/unreleased/4855.fixed.md b/changelog.d/unreleased/4855.fixed.md new file mode 100644 index 000000000..8e66aa686 --- /dev/null +++ b/changelog.d/unreleased/4855.fixed.md @@ -0,0 +1,26 @@ +--- +category: fixed +issues: + - 4855 +affected: + - CLI JSON error output + - outline + - hooks + - doctor + - validate-config + - documentation +--- + +## English + +- Standardized recoverable non-database failures from `outline`, `hooks`, + `doctor`, and `validate-config` on the stable versioned JSON error envelope, + including safe command context, hints, exit codes, and the residual error + taxonomy from #1526 while keeping machine output isolated on stdout. + +## 日本語 + +- `outline`、`hooks`、`doctor`、`validate-config` の回復可能な + 非データベース系失敗を安定したバージョン付き JSON error envelope に統一し、 + 安全な command context、hint、exit code、#1526 で残っていた error taxonomy を + 含めつつ、機械処理用出力を stdout だけに分離しました。 diff --git a/src/CodeIndex/Cli/CdidxConfigFile.cs b/src/CodeIndex/Cli/CdidxConfigFile.cs index 0470cf440..c9c20b14d 100644 --- a/src/CodeIndex/Cli/CdidxConfigFile.cs +++ b/src/CodeIndex/Cli/CdidxConfigFile.cs @@ -588,6 +588,7 @@ private static string ResolveConfigWorkspaceRoot(string configPath) internal static int RunValidate(string[] args, JsonSerializerOptions jsonOptions) { + const string usage = "cdidx validate-config [--json]"; var wantsJson = args.Any(static arg => arg == "--json" || arg.StartsWith("--json=", StringComparison.Ordinal)); var json = false; var remaining = new List(); @@ -606,7 +607,10 @@ internal static int RunValidate(string[] args, JsonSerializerOptions jsonOptions jsonOptions, "validate-config supports --json only; --json= is not supported.", CommandExitCodes.InvalidArgument, - "use `cdidx validate-config --json`."); + "use `cdidx validate-config --json`.", + usage, + CommandErrorCodes.UsageError, + command: "validate-config"); } remaining.Add(arg); @@ -618,7 +622,10 @@ internal static int RunValidate(string[] args, JsonSerializerOptions jsonOptions jsonOptions, "validate-config does not accept positional arguments.", CommandExitCodes.UsageError, - "run `cdidx validate-config` from the workspace whose config should be validated."); + "run `cdidx validate-config` from the workspace whose config should be validated.", + usage, + CommandErrorCodes.UsageError, + command: "validate-config"); var result = Load(Environment.CurrentDirectory, name => name == DisableEnvVar ? null : CdidxEnvironment.GetProcessEnvironmentVariable(name)); if (result.Failed) @@ -628,7 +635,12 @@ internal static int RunValidate(string[] args, JsonSerializerOptions jsonOptions jsonOptions, result.Error ?? "configuration file validation failed.", CommandExitCodes.UsageError, - "fix or remove the discovered config file."); + "fix or remove the discovered config file.", + usage, + CommandErrorCodes.ConfigInvalid, + "configuration", + "validate-config", + result.ConfigPath == null ? null : FormatConfigDiagnosticPath(result.ConfigPath)); } var configFileFound = result.Loaded; diff --git a/src/CodeIndex/Cli/CliContractManifest.cs b/src/CodeIndex/Cli/CliContractManifest.cs index f68a1a68d..79799490f 100644 --- a/src/CodeIndex/Cli/CliContractManifest.cs +++ b/src/CodeIndex/Cli/CliContractManifest.cs @@ -59,6 +59,10 @@ internal static class CliContractManifest new(nameof(CommandErrorCodes.LineOutOfRange), CommandErrorCodes.LineOutOfRange, null), new(nameof(CommandErrorCodes.SuggestionStoreUnavailable), CommandErrorCodes.SuggestionStoreUnavailable, null), new(nameof(CommandErrorCodes.IndexPartial), CommandErrorCodes.IndexPartial, null), + new(nameof(CommandErrorCodes.CommandFailed), CommandErrorCodes.CommandFailed, null), + new(nameof(CommandErrorCodes.ConfigInvalid), CommandErrorCodes.ConfigInvalid, null), + new(nameof(CommandErrorCodes.HookOperationFailed), CommandErrorCodes.HookOperationFailed, null), + new(nameof(CommandErrorCodes.NotGitRepository), CommandErrorCodes.NotGitRepository, null), ]; public static IReadOnlyList CliJsonRootTypes { get; } = LoadCliJsonRootTypes(); diff --git a/src/CodeIndex/Cli/CommandErrorCodes.cs b/src/CodeIndex/Cli/CommandErrorCodes.cs index 985b0c87f..9ef0ce15b 100644 --- a/src/CodeIndex/Cli/CommandErrorCodes.cs +++ b/src/CodeIndex/Cli/CommandErrorCodes.cs @@ -80,4 +80,16 @@ internal static class CommandErrorCodes /// Indexing committed a usable but incomplete generation because one or more files failed. public const string IndexPartial = "E022_INDEX_PARTIAL"; + + /// A command failed without a more specific published error code. + public const string CommandFailed = "E023_COMMAND_FAILED"; + + /// A discovered cdidx configuration file failed validation. + public const string ConfigInvalid = "E024_CONFIG_INVALID"; + + /// A Git hook operation failed because its platform or filesystem contract was not satisfied. + public const string HookOperationFailed = "E025_HOOK_OPERATION_FAILED"; + + /// The requested Git hook operation did not target a Git repository. + public const string NotGitRepository = "E026_NOT_GIT_REPOSITORY"; } diff --git a/src/CodeIndex/Cli/CommandErrorWriter.cs b/src/CodeIndex/Cli/CommandErrorWriter.cs index c443130ad..5b0ef961d 100644 --- a/src/CodeIndex/Cli/CommandErrorWriter.cs +++ b/src/CodeIndex/Cli/CommandErrorWriter.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.Json.Nodes; using CodeIndex.Diagnostics; namespace CodeIndex.Cli; @@ -45,13 +46,36 @@ internal static int WriteJsonOrHuman( string? hint = null, string? usage = null, string? errorCode = null, - string? category = null) + string? category = null, + string? command = null, + string? path = null, + JsonObject? additionalJsonProperties = null) { if (json) { - WriteStdout(JsonSerializer.Serialize( - new CommandErrorJsonResult("error", message, hint, errorCode, Category: category), - CliJsonSerializerContextFactory.Create(jsonOptions).CommandErrorJsonResult)); + 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 (additionalJsonProperties != null) + { + foreach (var property in additionalJsonProperties) + { + if (!payload.ContainsKey(property.Key)) + payload[property.Key] = property.Value?.DeepClone(); + } + } + + WriteStdout(payload.ToJsonString(jsonOptions)); return exitCode; } @@ -59,6 +83,34 @@ internal static int WriteJsonOrHuman( return exitCode; } + internal static (string ErrorCode, string Category) ResolveMachineContract( + int exitCode, + string? errorCode = null, + string? category = null) + { + var defaults = exitCode switch + { + CommandExitCodes.UsageError or CommandExitCodes.InvalidArgument or CommandExitCodes.ExUsage + => (CommandErrorCodes.UsageError, "usage"), + CommandExitCodes.NotFound + => (CommandErrorCodes.CommandFailed, "not_found"), + CommandExitCodes.DatabaseError or CommandExitCodes.TransientDatabaseError + => (CommandErrorCodes.DbError, "database_error"), + CommandExitCodes.FeatureUnavailable + => (CommandErrorCodes.FeatureUnavailable, "feature_unavailable"), + CommandExitCodes.StaleIndex + => (CommandErrorCodes.CommandFailed, "stale_index"), + CommandExitCodes.CancelledBySignal or CommandExitCodes.LegacyInterrupted + => (CommandErrorCodes.Interrupted, "interrupted"), + CommandExitCodes.InstallError + => (CommandErrorCodes.CommandFailed, "install_error"), + CommandExitCodes.PartialResult + => (CommandErrorCodes.IndexPartial, "partial_result"), + _ => (CommandErrorCodes.CommandFailed, "runtime_error"), + }; + return (errorCode ?? defaults.Item1, category ?? defaults.Item2); + } + internal static string FormatSanitizedException(Exception ex) { ArgumentNullException.ThrowIfNull(ex); diff --git a/src/CodeIndex/Cli/HookCommandRunner.cs b/src/CodeIndex/Cli/HookCommandRunner.cs index 9a4ee9519..de6692eee 100644 --- a/src/CodeIndex/Cli/HookCommandRunner.cs +++ b/src/CodeIndex/Cli/HookCommandRunner.cs @@ -1,5 +1,6 @@ using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using CodeIndex.Database; using CodeIndex.Diagnostics; @@ -20,61 +21,108 @@ public static class HookCommandRunner public static int Run(string[] args, JsonSerializerOptions jsonOptions) { var options = ParseArgs(args); - if (options.ShowHelp || (options.Command == null && options.ParseError == null)) + var wantsJson = args.Any(static arg => arg == "--json" || arg.StartsWith("--json=", StringComparison.Ordinal)); + if (wantsJson && !options.Json) + options = options with { Json = true }; + if (options.ShowHelp) { PrintUsage(); - return options.ShowHelp ? CommandExitCodes.Success : CommandExitCodes.UsageError; + return CommandExitCodes.Success; } - if (options.ParseError != null) + string projectPath; + try { - var errorProjectPath = Path.GetFullPath(options.ProjectPath ?? Environment.CurrentDirectory); - if (!options.Json) - PrintUsage(); - return WriteResult(options.Json, jsonOptions, "error", options.ParseError, errorProjectPath, null, null, CommandExitCodes.UsageError); + projectPath = Path.GetFullPath(options.ProjectPath ?? Environment.CurrentDirectory); + } + catch (Exception ex) when (IsHookFileOperationException(ex)) + { + return WriteResult( + options.Json, + jsonOptions, + "error", + $"invalid hooks project path ({CommandErrorWriter.FormatSanitizedException(ex)})", + DiagnosticSanitizer.ForPath(options.ProjectPath ?? Environment.CurrentDirectory), + null, + null, + CommandExitCodes.InvalidArgument); } - var projectPath = Path.GetFullPath(options.ProjectPath ?? Environment.CurrentDirectory); - var gitDir = GitHelper.ResolveGitCommonDir(projectPath); - if (gitDir == null) - return WriteResult(options.Json, jsonOptions, "error", "not a git repository", projectPath, null, null, CommandExitCodes.NotFound); - - if (!GitHelper.TryResolveGitMetadataChildPath( - gitDir, - "hooks", - expectDirectory: true, - allowMissing: true, - out var hooksDir)) + if (options.Command == null && options.ParseError == null) { - return WriteResult(options.Json, jsonOptions, "error", "unsafe Git hooks metadata path", projectPath, null, null, CommandExitCodes.InstallError); + return WriteResult( + options.Json, + jsonOptions, + "error", + "hooks requires an install, uninstall, or status command", + projectPath, + null, + null, + CommandExitCodes.UsageError); } - var hookPath = Path.Combine(hooksDir, HookName); - var chainedHookPath = Path.Combine(hooksDir, ChainedHookName); - if (Directory.Exists(LongPath.EnsureWindowsPrefix(hooksDir)) - && (!GitHelper.TryResolveGitMetadataChildPath( - hooksDir, - HookName, - expectDirectory: false, - allowMissing: true, - out hookPath) - || !GitHelper.TryResolveGitMetadataChildPath( - hooksDir, - ChainedHookName, - expectDirectory: false, - allowMissing: true, - out chainedHookPath))) + if (options.ParseError != null) { - return WriteResult(options.Json, jsonOptions, "error", "unsafe Git hook file path", projectPath, null, null, CommandExitCodes.InstallError); + if (!options.Json) + PrintUsage(); + return WriteResult(options.Json, jsonOptions, "error", options.ParseError, projectPath, null, null, CommandExitCodes.UsageError); } - return options.Command switch + try + { + var gitDir = GitHelper.ResolveGitCommonDir(projectPath); + if (gitDir == null) + return WriteResult(options.Json, jsonOptions, "error", "not a git repository", projectPath, null, null, CommandExitCodes.NotFound); + + if (!GitHelper.TryResolveGitMetadataChildPath( + gitDir, + "hooks", + expectDirectory: true, + allowMissing: true, + out var hooksDir)) + { + return WriteResult(options.Json, jsonOptions, "error", "unsafe Git hooks metadata path", projectPath, null, null, CommandExitCodes.InstallError); + } + + var hookPath = Path.Combine(hooksDir, HookName); + var chainedHookPath = Path.Combine(hooksDir, ChainedHookName); + if (Directory.Exists(LongPath.EnsureWindowsPrefix(hooksDir)) + && (!GitHelper.TryResolveGitMetadataChildPath( + hooksDir, + HookName, + expectDirectory: false, + allowMissing: true, + out hookPath) + || !GitHelper.TryResolveGitMetadataChildPath( + hooksDir, + ChainedHookName, + expectDirectory: false, + allowMissing: true, + out chainedHookPath))) + { + return WriteResult(options.Json, jsonOptions, "error", "unsafe Git hook file path", projectPath, null, null, CommandExitCodes.InstallError); + } + + return options.Command switch + { + "install" => Install(options, jsonOptions, projectPath, gitDir, hooksDir, hookPath, chainedHookPath), + "uninstall" => Uninstall(options, jsonOptions, projectPath, gitDir, hooksDir, hookPath, chainedHookPath), + "status" => Status(options, jsonOptions, projectPath, hookPath, chainedHookPath), + _ => UnknownCommand(options, jsonOptions, projectPath) + }; + } + catch (Exception ex) when (IsHookFileOperationException(ex)) { - "install" => Install(options, jsonOptions, projectPath, gitDir, hooksDir, hookPath, chainedHookPath), - "uninstall" => Uninstall(options, jsonOptions, projectPath, gitDir, hooksDir, hookPath, chainedHookPath), - "status" => Status(options, jsonOptions, projectPath, hookPath, chainedHookPath), - _ => UnknownCommand(options, jsonOptions, projectPath) - }; + return WriteResult( + options.Json, + jsonOptions, + "error", + $"hook operation failed ({CommandErrorWriter.FormatSanitizedException(ex)})", + projectPath, + null, + null, + CommandExitCodes.InstallError); + } } internal static HookCommandOptions ParseArgs(string[] args) @@ -557,6 +605,71 @@ private static int WriteResult( string? managedHookPreview = null) { var hasWarnings = warnings is { Count: > 0 }; + if (exitCode != CommandExitCodes.Success) + { + if (!json && hasWarnings) + { + foreach (var warning in warnings!) + CommandErrorWriter.WriteWarning(warning.Message); + } + + JsonObject? additionalJsonProperties = null; + if (json) + { + var diagnosticProjectPath = DiagnosticSanitizer.ForPath(projectPath); + var diagnosticHookPath = hookPath == null ? null : DiagnosticSanitizer.ForPath(hookPath); + var diagnosticChainedHookPath = chainedHookPath == null ? null : DiagnosticSanitizer.ForPath(chainedHookPath); + var safeWarnings = hasWarnings + ? warnings! + .Select(static warning => new HookCommandWarningJsonResult( + warning.Category, + warning.DiagnosticPath, + warning.DiagnosticPath, + DiagnosticSanitizer.ForMessage(warning.Message))) + .ToArray() + : null; + additionalJsonProperties = JsonSerializer.SerializeToNode( + new HookCommandJsonResult( + status, + message, + diagnosticProjectPath, + diagnosticHookPath, + diagnosticChainedHookPath, + diagnosticProjectPath, + diagnosticHookPath, + diagnosticChainedHookPath, + safeWarnings, + dryRun, + plannedAction, + managedHookPreview == null + ? null + : DiagnosticRedactor.RedactSensitiveText(managedHookPreview, "[redacted]", redactPaths: true)), + CliJsonSerializerContextFactory.Create(jsonOptions).HookCommandJsonResult)!.AsObject(); + } + + var (errorCode, category, hint) = exitCode switch + { + CommandExitCodes.UsageError or CommandExitCodes.InvalidArgument + => (CommandErrorCodes.UsageError, "usage", "Use `cdidx hooks --help` and correct the command arguments."), + CommandExitCodes.NotFound + => (CommandErrorCodes.NotGitRepository, "not_found", "Run from a Git worktree or pass `--project ` for one."), + _ => (CommandErrorCodes.HookOperationFailed, "platform", "Inspect the Git metadata path and filesystem permissions, then retry the hook operation."), + }; + var result = CommandErrorWriter.WriteJsonOrHuman( + json, + jsonOptions, + json ? DiagnosticSanitizer.ForMessage(message) : message, + exitCode, + hint, + GetUsage(), + errorCode, + category, + "hooks", + DiagnosticSanitizer.ForPath(projectPath), + additionalJsonProperties); + return result; + } + if (json) { Console.WriteLine(JsonSerializer.Serialize( @@ -607,8 +720,11 @@ private static int WriteResult( return exitCode; } + private static string GetUsage() + => "cdidx hooks [--project ] [--force] [--dry-run] [--json]"; + private static void PrintUsage() - => CommandErrorWriter.WriteStderr("Usage: cdidx hooks [--project ] [--force] [--dry-run] [--json]"); + => CommandErrorWriter.WriteStderr($"Usage: {GetUsage()}"); } public sealed record HookCommandOptions(string? Command, string? ProjectPath, bool Json, bool Force, bool DryRun, bool ShowHelp, string? ParseError); diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 70ab39551..577ab877a 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -87,6 +87,9 @@ internal sealed record CommandErrorJsonResult( [property: JsonPropertyName("error_code")] string? ErrorCode = null, [property: JsonPropertyName("path")] string? Path = null, [property: JsonPropertyName("category")] string? Category = null, + [property: JsonPropertyName("command")] string? Command = null, + [property: JsonPropertyName("exit_code")] int? ExitCode = null, + [property: JsonPropertyName("usage")] string? Usage = null, [property: JsonPropertyName("api_version")] string ApiVersion = JsonOutputContract.ApiVersion) : IVersionedJsonResult; internal sealed record DoctorJsonResult( diff --git a/src/CodeIndex/Cli/ProgramRunner.Doctor.cs b/src/CodeIndex/Cli/ProgramRunner.Doctor.cs index 711e97865..72a29871c 100644 --- a/src/CodeIndex/Cli/ProgramRunner.Doctor.cs +++ b/src/CodeIndex/Cli/ProgramRunner.Doctor.cs @@ -68,7 +68,9 @@ private static int RunDoctor(string[] args, string appVersion, JsonSerializerOpt "--max-json-bytes requires a positive integer.", CommandExitCodes.InvalidArgument, "pass a positive UTF-8 byte limit, for example `--max-json-bytes 16384`.", - usage: GetDoctorUsage()); + usage: GetDoctorUsage(), + errorCode: CommandErrorCodes.UsageError, + command: "doctor"); } maxJsonBytes = parsed; continue; @@ -100,7 +102,10 @@ private static int RunDoctor(string[] args, string appVersion, JsonSerializerOpt ? "doctor supports --json only; --json= is not supported." : $"Unknown doctor argument: {arg}", CommandExitCodes.InvalidArgument, - $"use `{GetDoctorUsage()}`."); + $"use `{GetDoctorUsage()}`.", + usage: GetDoctorUsage(), + errorCode: CommandErrorCodes.UsageError, + command: "doctor"); } } @@ -113,7 +118,9 @@ private static int RunDoctor(string[] args, string appVersion, JsonSerializerOpt "doctor environment inventory filters require --env-inventory=full.", CommandExitCodes.InvalidArgument, "add `--env-inventory=full` before filtering by domain, category, or sensitivity.", - usage: GetDoctorUsage()); + usage: GetDoctorUsage(), + errorCode: CommandErrorCodes.UsageError, + command: "doctor"); } if (maxJsonBytes.HasValue && (!json || envInventory != DoctorEnvironmentInventoryMode.Full)) { @@ -123,7 +130,9 @@ private static int RunDoctor(string[] args, string appVersion, JsonSerializerOpt "doctor --max-json-bytes requires --json and --env-inventory=full.", CommandExitCodes.InvalidArgument, "use `cdidx doctor --json --env-inventory=full --max-json-bytes `.", - usage: GetDoctorUsage()); + usage: GetDoctorUsage(), + errorCode: CommandErrorCodes.UsageError, + command: "doctor"); } if (!TryFilterDoctorEnvironmentInventory( @@ -248,7 +257,9 @@ private static bool TryReadDoctorValueOption( $"{flag} requires a non-empty value.", CommandExitCodes.InvalidArgument, $"pass one value reported by `cdidx doctor --env-inventory` for {flag}.", - usage: GetDoctorUsage()); + usage: GetDoctorUsage(), + errorCode: CommandErrorCodes.UsageError, + command: "doctor"); return true; } @@ -291,7 +302,9 @@ private static bool TryFilterDoctorEnvironmentInventory( $"Unknown {flag} value: {value}", CommandExitCodes.InvalidArgument, $"choose one of: {allowed}.", - usage: GetDoctorUsage()); + usage: GetDoctorUsage(), + errorCode: CommandErrorCodes.UsageError, + command: "doctor"); return false; } @@ -361,7 +374,9 @@ private static int WriteDoctorJson( $"doctor JSON output is {byteCount.ToString(CultureInfo.InvariantCulture)} bytes and exceeds --max-json-bytes {maxJsonBytes.Value.ToString(CultureInfo.InvariantCulture)}.", CommandExitCodes.UsageError, "increase --max-json-bytes or narrow the full environment inventory with --env-domain, --env-category, or --env-sensitivity.", - usage: GetDoctorUsage()); + usage: GetDoctorUsage(), + errorCode: CommandErrorCodes.UsageError, + command: "doctor"); } Console.WriteLine(json); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs index daa4489f5..78b26123c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.OptionValidation.cs @@ -41,13 +41,17 @@ private static bool TryWriteParseError(QueryCommandOptions options, string comma ? "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); - if (options.ParseError != null && dbPathError != null) + if (options.ParseError != null + && dbPathError != null + && !(options.Json && jsonOptions != null)) + { WriteParseError( dbPathError, "create or refresh the index with `cdidx index ` (or `cdidx .`) and then rerun this command.", commandName, options, jsonOptions); + } return true; } @@ -121,7 +125,8 @@ private static void WriteParseError( hint, GetUsageLineOrThrow(commandName), ExtractErrorCode(error), - category: "usage"); + category: "usage", + command: commandName); return; } @@ -129,7 +134,10 @@ private static void WriteParseError( StripErrorPrefix(error), hint, GetUsageLineOrThrow(commandName), - ExtractErrorCode(error)); + ExtractErrorCode(error) + ?? (string.Equals(commandName, "outline", StringComparison.Ordinal) + ? CommandErrorCodes.UsageError + : null)); } private static string? BuildExplicitDbPathParseError(QueryCommandOptions options) @@ -201,7 +209,9 @@ private static bool TryWriteInvalidKindFilterError(QueryCommandOptions options, return false; } - private static bool TryWriteInvalidOutlineKindFilterError(QueryCommandOptions options) + private static bool TryWriteInvalidOutlineKindFilterError( + QueryCommandOptions options, + JsonSerializerOptions jsonOptions) { if (options.Kind == null) return false; @@ -209,10 +219,15 @@ private static bool TryWriteInvalidOutlineKindFilterError(QueryCommandOptions op var kinds = BuildOutlineKindFilters(options.Kind); if (kinds.Count == 0) { - CommandErrorWriter.Write( + CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, $"invalid --kind value `{ConsoleUi.FormatBoundedValue(options.Kind)}`.", + CommandExitCodes.InvalidArgument, $"use one or more of: {string.Join(", ", KnownSymbolKindFilters)}.", - GetUsageLineOrThrow("outline")); + GetUsageLineOrThrow("outline"), + CommandErrorCodes.UsageError, + command: "outline"); return true; } @@ -221,10 +236,15 @@ private static bool TryWriteInvalidOutlineKindFilterError(QueryCommandOptions op if (KnownSymbolKindFilters.Contains(kind)) continue; - CommandErrorWriter.Write( + CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, $"invalid --kind value `{ConsoleUi.FormatBoundedValue(kind)}`.", + CommandExitCodes.InvalidArgument, $"use one or more of: {string.Join(", ", KnownSymbolKindFilters)}.", - GetUsageLineOrThrow("outline")); + GetUsageLineOrThrow("outline"), + CommandErrorCodes.UsageError, + command: "outline"); return true; } @@ -260,8 +280,40 @@ private static bool TryWriteInvalidUnusedFilterError(QueryCommandOptions options return false; } - private static bool TryWriteUnsupportedOptionError(string commandName, string[] cmdArgs, IEnumerable supportedOptions, string? queryLiteral = null) + private static bool TryWriteUnsupportedOptionError( + string commandName, + string[] cmdArgs, + IEnumerable supportedOptions, + string? queryLiteral = null, + JsonSerializerOptions? jsonOptions = null) { + void WriteOptionError(string message, string hint, string? errorCode = null) + { + if (jsonOptions != null + && cmdArgs.Any(static arg => arg == "--json" || arg.StartsWith("--json=", StringComparison.Ordinal))) + { + CommandErrorWriter.WriteJsonOrHuman( + true, + jsonOptions, + message, + CommandExitCodes.UsageError, + hint, + GetUsageLineOrThrow(commandName), + errorCode ?? CommandErrorCodes.UsageError, + command: commandName); + return; + } + + CommandErrorWriter.Write( + message, + hint, + GetUsageLineOrThrow(commandName), + errorCode + ?? (string.Equals(commandName, "outline", StringComparison.Ordinal) + ? CommandErrorCodes.UsageError + : null)); + } + var supported = supportedOptions.ToHashSet(StringComparer.Ordinal); var skippedQueryLiteral = false; for (var i = 0; i < cmdArgs.Length; i++) @@ -290,10 +342,9 @@ private static bool TryWriteUnsupportedOptionError(string commandName, string[] { if (commandName == "outline") { - CommandErrorWriter.Write( + WriteOptionError( "--json= is not supported by outline because outline emits one JSON object.", - "use plain `--json`; add `--limit ` to cap symbols and read the paging metadata (`returned_symbol_count`, `total_symbol_count`, `next_cursor`).", - GetUsageLineOrThrow(commandName)); + "use plain `--json`; add `--limit ` to cap symbols and read the paging metadata (`returned_symbol_count`, `total_symbol_count`, `next_cursor`)."); return true; } if (commandName == "validate" && string.Equals(inlineValue, JsonOutputFormatArray, StringComparison.OrdinalIgnoreCase)) @@ -301,14 +352,13 @@ private static bool TryWriteUnsupportedOptionError(string commandName, string[] continue; } - CommandErrorWriter.Write( + WriteOptionError( commandName == "validate" ? "--json= for validate only supports 'array'." : "--json= is only supported by 'search', 'files', 'symbols', and validate's array output.", commandName == "validate" ? "use plain `--json` or `--json=array`." - : "use plain `--json` here, rerun search/files/symbols with `--json=array`, or rerun validate with `--json=array`.", - GetUsageLineOrThrow(commandName)); + : "use plain `--json` here, rerun search/files/symbols with `--json=array`, or rerun validate with `--json=array`."); return true; } @@ -333,19 +383,17 @@ private static bool TryWriteUnsupportedOptionError(string commandName, string[] if (normalizedArg == "--group-by-name") { - CommandErrorWriter.Write( + WriteOptionError( "--group-by-name is only supported by 'hotspots'.", - "remove `--group-by-name` here, or rerun with `cdidx hotspots --group-by-name ...`.", - GetUsageLineOrThrow(commandName)); + "remove `--group-by-name` here, or rerun with `cdidx hotspots --group-by-name ...`."); return true; } if (normalizedArg == "--group-by") { - CommandErrorWriter.Write( + WriteOptionError( "--group-by is only supported by 'hotspots'.", - "remove `--group-by` here, or rerun with `cdidx hotspots --group-by ...`.", - GetUsageLineOrThrow(commandName)); + "remove `--group-by` here, or rerun with `cdidx hotspots --group-by ...`."); return true; } @@ -374,10 +422,9 @@ private static bool TryWriteUnsupportedOptionError(string commandName, string[] var hint = suggestion == null ? $"remove `{displayArg}` and rerun, or use only the options shown in `{commandName} --help`." : $"Did you mean: {suggestion}? Remove `{displayArg}` and rerun, or use `{suggestion}` if that is what you meant."; - CommandErrorWriter.Write( + WriteOptionError( $"{displayArg} is not supported for {commandName}.", hint, - GetUsageLineOrThrow(commandName), CommandErrorCodes.UsageError); return true; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs b/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs index 0be0fc201..b731d23a7 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Outline.cs @@ -1,6 +1,7 @@ using System.Text.Json; using System.Text.Json.Nodes; using CodeIndex.Database; +using CodeIndex.Diagnostics; namespace CodeIndex.Cli; @@ -8,20 +9,36 @@ public static partial class QueryCommandRunner { public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions) { + var wantsJson = cmdArgs.Any(static arg => + arg == "--compact" + || arg == "--json" + || arg.StartsWith("--json=", StringComparison.Ordinal)); + var usage = GetUsageLineOrThrow("outline"); if (cmdArgs.Length == 0 || cmdArgs[0].StartsWith('-')) { - WriteUsageError( + return CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, "outline requires a file path.", - GetUsageLineOrThrow("outline"), - "Pass the indexed file path, for example: `cdidx outline src/CodeIndex/Program.cs`."); - return CommandExitCodes.UsageError; + CommandExitCodes.UsageError, + "Pass the indexed file path, for example: `cdidx outline src/CodeIndex/Program.cs`.", + usage, + CommandErrorCodes.UsageError, + command: "outline"); } var previewOptionError = ValidatePreviewOptions("outline", cmdArgs[1..], allowMaxLineWidth: false, allowFocusOptions: false); if (previewOptionError != null) { - CommandErrorWriter.WriteStderr(previewOptionError); - return CommandExitCodes.UsageError; + return CommandErrorWriter.WriteJsonOrHuman( + wantsJson, + jsonOptions, + previewOptionError, + CommandExitCodes.UsageError, + "Remove the unsupported preview option and rerun outline.", + usage, + CommandErrorCodes.UsageError, + command: "outline"); } var options = ParseArgs( cmdArgs[1..], @@ -30,23 +47,31 @@ public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions validateDefaultSnippetLines: false, validateDefaultMaxLineWidth: false, allowOutlineSort: true); - if (TryWriteUnsupportedOptionError("outline", cmdArgs[1..], CliFlagSchema.GetAcceptedFlagNamesForCommand("outline"))) + if (TryWriteUnsupportedOptionError( + "outline", + cmdArgs[1..], + CliFlagSchema.GetAcceptedFlagNamesForCommand("outline"), + jsonOptions: jsonOptions)) return CommandExitCodes.UsageError; - if (TryWriteParseError(options, "outline")) + if (TryWriteParseError(options, "outline", jsonOptions)) return CommandExitCodes.UsageError; - if (TryWriteInvalidOutlineKindFilterError(options)) + if (TryWriteInvalidOutlineKindFilterError(options, jsonOptions)) return CommandExitCodes.InvalidArgument; - if (TryWriteUnexpectedPositionals("outline", options)) + if (TryWriteUnexpectedPositionals("outline", options, jsonOptions)) return CommandExitCodes.UsageError; if (options.SearchCursor.HasValue || options.UnusedCursorOffset.HasValue || options.DependencyCycleCursor.HasValue) { - WriteUsageError( + return CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, "outline --cursor must use an outline pagination cursor.", - GetUsageLineOrThrow("outline"), - "Use the `next_cursor` value returned by `cdidx outline --json --limit `."); - return CommandExitCodes.UsageError; + CommandExitCodes.UsageError, + "Use the `next_cursor` value returned by `cdidx outline --json --limit `.", + usage, + CommandErrorCodes.UsageError, + command: "outline"); } var filePath = DbPathResolver.ResolveQueryFilePath(options.DbPath, cmdArgs[0], options.DbPathExplicit); @@ -68,21 +93,34 @@ public static int RunOutline(string[] cmdArgs, JsonSerializerOptions jsonOptions var cursorValidationError = ValidateScopedOffsetCursor(options, "outline", cursorContext); if (cursorValidationError != null) { - WriteUsageError( + return CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, cursorValidationError, - GetUsageLineOrThrow("outline"), - "Restart outline pagination without --cursor and use the new next_cursor value."); - return CommandExitCodes.UsageError; + CommandExitCodes.UsageError, + "Restart outline pagination without --cursor and use the new next_cursor value.", + usage, + CommandErrorCodes.UsageError, + command: "outline"); } var outline = reader.GetOutline(filePath, includeReferenceCounts: includeReferenceCounts); if (outline == null) { - if (options.Json) - Console.WriteLine(JsonSerializer.Serialize(new QueryPathErrorJsonResult(filePath, "file not found in index"), CliJsonSerializerContextFactory.Create(jsonOptions).QueryPathErrorJsonResult)); - else - CommandErrorWriter.WriteStderr($"Error: '{filePath}' not found in index."); - return CommandExitCodes.NotFound; + var diagnosticFilePath = Path.IsPathRooted(filePath) + ? DiagnosticSanitizer.ForPath(filePath) + : ConsoleUi.FormatBoundedValue(filePath); + return CommandErrorWriter.WriteJsonOrHuman( + options.Json, + jsonOptions, + $"'{(options.Json ? diagnosticFilePath : filePath)}' was not found in the active index.", + CommandExitCodes.NotFound, + "Check the indexed path spelling or refresh the index with `cdidx index `.", + usage, + CommandErrorCodes.FileNotFound, + "not_found", + "outline", + diagnosticFilePath); } var filteredSymbols = ApplyOutlineKindFilters(outline.Symbols, kindFilters); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs b/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs index 33c12c817..baaa5af29 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.Validation.cs @@ -29,7 +29,10 @@ private static string BuildUnexpectedExtraPositionalsHint(string commandName, Qu private static bool IsPathLikeArgument(string value) => value.Contains('/') || value.Contains('\\'); - private static bool TryWriteUnexpectedPositionals(string commandName, QueryCommandOptions options) + private static bool TryWriteUnexpectedPositionals( + string commandName, + QueryCommandOptions options, + System.Text.Json.JsonSerializerOptions? jsonOptions = null) { var unexpected = new List(); if (!string.IsNullOrWhiteSpace(options.Query)) @@ -38,10 +41,30 @@ private static bool TryWriteUnexpectedPositionals(string commandName, QueryComma if (unexpected.Count == 0) return false; - CommandErrorWriter.Write( - $"{commandName} does not accept positional arguments: {string.Join(", ", unexpected)}.", - "remove the extra positional arguments and use the documented flags only.", - GetUsageLineOrThrow(commandName)); + var message = $"{commandName} does not accept positional arguments: {string.Join(", ", unexpected)}."; + const string hint = "remove the extra positional arguments and use the documented flags only."; + if (options.Json && jsonOptions != null) + { + CommandErrorWriter.WriteJsonOrHuman( + true, + jsonOptions, + message, + CommandExitCodes.UsageError, + hint, + GetUsageLineOrThrow(commandName), + CommandErrorCodes.UsageError, + command: commandName); + } + else + { + CommandErrorWriter.Write( + message, + hint, + GetUsageLineOrThrow(commandName), + string.Equals(commandName, "outline", StringComparison.Ordinal) + ? CommandErrorCodes.UsageError + : null); + } return true; } diff --git a/tests/CodeIndex.Tests/CliNonDatabaseErrorContractTests.cs b/tests/CodeIndex.Tests/CliNonDatabaseErrorContractTests.cs new file mode 100644 index 000000000..2b6e34ed3 --- /dev/null +++ b/tests/CodeIndex.Tests/CliNonDatabaseErrorContractTests.cs @@ -0,0 +1,215 @@ +using System.Text.Json; +using CodeIndex.Cli; +using Microsoft.Data.Sqlite; + +namespace CodeIndex.Tests; + +[Collection("SQLite pool sensitive")] +public sealed class CliNonDatabaseErrorContractTests +{ + [Fact] + public void OutlineCompactWithoutPath_UsesSingleJsonEnvelope_Issue4855() + { + var result = ConsoleCapture.Capture(() => QueryCommandRunner.RunOutline( + ["--compact"], + ProgramRunner.CreateDefaultJsonOptions())); + + Assert.Equal(CommandExitCodes.UsageError, result.ExitCode); + Assert.Equal(string.Empty, result.Stderr); + using var document = JsonDocument.Parse(result.Stdout); + AssertStableEnvelope( + document.RootElement, + CommandExitCodes.UsageError, + CommandErrorCodes.UsageError, + "usage", + "outline"); + } + + [Fact] + public void HooksUnsupportedInlineJsonFormat_UsesSingleJsonEnvelope_Issue4855() + { + var result = ConsoleCapture.Capture(() => ProgramRunner.Run( + ["hooks", "status", "--json=array"], + appVersion: "test")); + + Assert.Equal(CommandExitCodes.UsageError, result.ExitCode); + Assert.Equal(string.Empty, result.Stderr); + using var document = JsonDocument.Parse(result.Stdout); + AssertStableEnvelope( + document.RootElement, + CommandExitCodes.UsageError, + CommandErrorCodes.UsageError, + "usage", + "hooks"); + } + + [Fact] + public void OutlineCombinedParseAndDatabasePathErrors_EmitsOneJsonDocument_Issue4855() + { + var missingDbPath = Path.Combine( + Path.GetTempPath(), + $"cdidx_missing_outline_{Guid.NewGuid():N}.db"); + var result = ConsoleCapture.Capture(() => QueryCommandRunner.RunOutline( + ["missing.cs", "--db", missingDbPath, "--limit", "nope", "--json"], + ProgramRunner.CreateDefaultJsonOptions())); + + Assert.Equal(CommandExitCodes.UsageError, result.ExitCode); + Assert.Equal(string.Empty, result.Stderr); + using var document = JsonDocument.Parse(result.Stdout); + AssertStableEnvelope( + document.RootElement, + CommandExitCodes.UsageError, + CommandErrorCodes.UsageError, + "usage", + "outline"); + Assert.DoesNotContain("does not point to an existing database", result.Stdout, StringComparison.Ordinal); + } + + [Fact] + public void OutlineAbsoluteMissingPath_RedactsJsonEnvelopeContext_Issue4855() + { + var projectRoot = TestProjectHelper.CreateTempProject("outline_absolute_path_error"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var missingPath = Path.Combine(projectRoot, "private", "missing.cs"); + var result = ConsoleCapture.Capture(() => QueryCommandRunner.RunOutline( + [missingPath, "--db", dbPath, "--json"], + ProgramRunner.CreateDefaultJsonOptions())); + + Assert.Equal(CommandExitCodes.NotFound, result.ExitCode); + Assert.Equal(string.Empty, result.Stderr); + Assert.DoesNotContain(projectRoot, result.Stdout, StringComparison.Ordinal); + using var document = JsonDocument.Parse(result.Stdout); + Assert.Equal("private/missing.cs", document.RootElement.GetProperty("path").GetString()); + Assert.Contains("missing.cs", document.RootElement.GetProperty("message").GetString(), StringComparison.Ordinal); + } + finally + { + SqliteConnection.ClearAllPools(); + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Theory] + [InlineData("empty", CommandExitCodes.UsageError, CommandErrorCodes.UsageError, "usage", "hooks")] + [InlineData("not_found", CommandExitCodes.NotFound, CommandErrorCodes.FileNotFound, "not_found", "outline")] + [InlineData("invalid", CommandExitCodes.InvalidArgument, CommandErrorCodes.UsageError, "usage", "doctor")] + [InlineData("config", CommandExitCodes.UsageError, CommandErrorCodes.ConfigInvalid, "configuration", "validate-config")] + [InlineData("platform", CommandExitCodes.InstallError, CommandErrorCodes.HookOperationFailed, "platform", "hooks")] + public void Failures_UseStableEnvelopeInJsonAndCanonicalHumanOutput_Issue4855( + string scenario, + int expectedExitCode, + string expectedErrorCode, + string expectedCategory, + string expectedCommand) + { + using var scope = new FailureScenarioScope(scenario); + + var jsonResult = scope.Run(json: true); + + Assert.Equal(expectedExitCode, jsonResult.ExitCode); + Assert.Equal(string.Empty, jsonResult.Stderr); + using (var document = JsonDocument.Parse(jsonResult.Stdout)) + AssertStableEnvelope( + document.RootElement, + expectedExitCode, + expectedErrorCode, + expectedCategory, + expectedCommand); + if (scenario == "platform") + Assert.DoesNotContain(scope.ProjectRoot, jsonResult.Stdout, StringComparison.Ordinal); + + var humanResult = scope.Run(json: false); + + Assert.Equal(expectedExitCode, humanResult.ExitCode); + Assert.Equal(string.Empty, humanResult.Stdout); + Assert.Contains($"Error [{expectedErrorCode}]:", humanResult.Stderr, StringComparison.Ordinal); + Assert.Contains("Hint:", humanResult.Stderr, StringComparison.Ordinal); + Assert.Contains("Usage:", humanResult.Stderr, StringComparison.Ordinal); + } + + private static void AssertStableEnvelope( + JsonElement root, + int expectedExitCode, + string expectedErrorCode, + string expectedCategory, + string expectedCommand) + { + Assert.Equal("1", root.GetProperty("api_version").GetString()); + Assert.Equal("error", root.GetProperty("status").GetString()); + Assert.Equal(expectedErrorCode, root.GetProperty("error_code").GetString()); + Assert.Equal(expectedCategory, root.GetProperty("category").GetString()); + Assert.Equal(expectedCommand, root.GetProperty("command").GetString()); + Assert.Equal(expectedExitCode, root.GetProperty("exit_code").GetInt32()); + Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("message").GetString())); + Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("hint").GetString())); + Assert.False(string.IsNullOrWhiteSpace(root.GetProperty("usage").GetString())); + } + + private sealed class FailureScenarioScope : IDisposable + { + private readonly string _scenario; + private readonly string _previousDirectory; + private readonly string? _dbPath; + + internal FailureScenarioScope(string scenario) + { + _scenario = scenario; + _previousDirectory = Environment.CurrentDirectory; + ProjectRoot = TestProjectHelper.CreateTempProject($"non_db_error_{scenario}"); + switch (scenario) + { + case "not_found": + _dbPath = TestProjectHelper.CreateProjectDb(ProjectRoot); + break; + case "config": + File.WriteAllText(Path.Combine(ProjectRoot, CdidxConfigFile.FileName), "{ invalid json"); + Environment.CurrentDirectory = ProjectRoot; + break; + case "platform": + TestProjectHelper.InitializeGitRepo(ProjectRoot); + var hooksDir = Path.Combine(ProjectRoot, ".git", "hooks"); + Directory.CreateDirectory(hooksDir); + File.WriteAllText(Path.Combine(hooksDir, "pre-commit"), "#!/bin/sh\necho existing\n"); + HookCommandRunner.ReplaceFileForTesting = (_, _, _) => throw new IOException("replace denied"); + break; + } + } + + internal string ProjectRoot { get; } + + internal (int ExitCode, string Stdout, string Stderr) Run(bool json) + => ConsoleCapture.Capture(() => _scenario switch + { + "empty" => ProgramRunner.Run( + json ? ["hooks", "--json"] : ["hooks"], + appVersion: "test"), + "not_found" => QueryCommandRunner.RunOutline( + json + ? ["missing.cs", "--db", _dbPath!, "--json"] + : ["missing.cs", "--db", _dbPath!], + ProgramRunner.CreateDefaultJsonOptions()), + "invalid" => ProgramRunner.Run( + json ? ["doctor", "--json", "--bogus"] : ["doctor", "--bogus"], + appVersion: "test"), + "config" => ProgramRunner.Run( + json ? ["validate-config", "--json"] : ["validate-config"], + appVersion: "test"), + "platform" => HookCommandRunner.Run( + json + ? ["install", "--project", ProjectRoot, "--json"] + : ["install", "--project", ProjectRoot], + ProgramRunner.CreateDefaultJsonOptions()), + _ => throw new ArgumentOutOfRangeException(nameof(_scenario), _scenario, null), + }); + + public void Dispose() + { + Environment.CurrentDirectory = _previousDirectory; + HookCommandRunner.ReplaceFileForTesting = null; + SqliteConnection.ClearAllPools(); + TestProjectHelper.DeleteDirectory(ProjectRoot); + } + } +} diff --git a/tests/CodeIndex.Tests/CommandErrorWriterTests.cs b/tests/CodeIndex.Tests/CommandErrorWriterTests.cs index 756930a91..44d5c541f 100644 --- a/tests/CodeIndex.Tests/CommandErrorWriterTests.cs +++ b/tests/CodeIndex.Tests/CommandErrorWriterTests.cs @@ -5,6 +5,15 @@ namespace CodeIndex.Tests; [Collection("Console sensitive")] public class CommandErrorWriterTests { + [Fact] + public void ResolveMachineContract_NotFoundUsesGenericCodeWithoutDomainContext_Issue4855() + { + var (errorCode, category) = CommandErrorWriter.ResolveMachineContract(CommandExitCodes.NotFound); + + Assert.Equal(CommandErrorCodes.CommandFailed, errorCode); + Assert.Equal("not_found", category); + } + [Fact] public void Write_DoesNotDuplicateExistingUsagePrefix_Issue4244() { diff --git a/tests/CodeIndex.Tests/HookCommandRunnerTests.cs b/tests/CodeIndex.Tests/HookCommandRunnerTests.cs index aec207577..487589108 100644 --- a/tests/CodeIndex.Tests/HookCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/HookCommandRunnerTests.cs @@ -238,6 +238,7 @@ public void Hooks_InstallDryRun_PreviewsCreateChainReplaceAndNoOpWithoutWriting_ ["install", "--project", projectRoot, "--dry-run", "--json"]); Assert.Equal(CommandExitCodes.UsageError, blockedPreview.ExitCode); + Assert.DoesNotContain(projectRoot, blockedPreview.StdOut, StringComparison.Ordinal); using (var document = JsonDocument.Parse(blockedPreview.StdOut)) { Assert.Equal("error", document.RootElement.GetProperty("status").GetString()); @@ -255,10 +256,10 @@ public void Hooks_InstallDryRun_PreviewsCreateChainReplaceAndNoOpWithoutWriting_ ["install", "--project", projectRoot, "--dry-run"]); Assert.Equal(CommandExitCodes.UsageError, blockedHumanPreview.ExitCode); + Assert.Equal(string.Empty, blockedHumanPreview.StdOut); Assert.Contains("chained hook already exists", blockedHumanPreview.StdErr, StringComparison.Ordinal); - Assert.Contains("Planned action: blocked", blockedHumanPreview.StdOut, StringComparison.Ordinal); - Assert.Contains("Managed hook preview:", blockedHumanPreview.StdOut, StringComparison.Ordinal); - Assert.Contains("BEGIN CDIDX MANAGED PRE-COMMIT", blockedHumanPreview.StdOut, StringComparison.Ordinal); + Assert.Contains("Hint:", blockedHumanPreview.StdErr, StringComparison.Ordinal); + Assert.Contains("Usage:", blockedHumanPreview.StdErr, StringComparison.Ordinal); Assert.Equal(customHook, File.ReadAllText(hookPath)); Assert.Equal(existingChain, File.ReadAllText(chainedHookPath)); TestProjectHelper.DeleteFile(chainedHookPath); @@ -609,7 +610,7 @@ public void Hooks_InstallJson_ReportsStagedHookCleanupFailure() Assert.Contains("IOException", warning.GetProperty("message").GetString(), StringComparison.Ordinal); var backupWarning = warnings[1]; Assert.Equal("chained_hook_backup", backupWarning.GetProperty("category").GetString()); - Assert.Contains("pre-commit.cdidx-chain", backupWarning.GetProperty("path").GetString(), StringComparison.Ordinal); + Assert.Equal("pre-commit.cdidx-chain", backupWarning.GetProperty("path").GetString()); Assert.Equal("pre-commit.cdidx-chain", backupWarning.GetProperty("diagnostic_path").GetString()); Assert.Contains("failed to back up existing hook", backupWarning.GetProperty("message").GetString(), StringComparison.Ordinal); } @@ -643,7 +644,7 @@ public void Hooks_UninstallJson_ReportsManagedHookDeleteFailure() Assert.Equal("error", document.RootElement.GetProperty("status").GetString()); var warning = document.RootElement.GetProperty("warnings")[0]; Assert.Equal("managed_hook", warning.GetProperty("category").GetString()); - Assert.Equal(hookPath, warning.GetProperty("path").GetString()); + Assert.Equal("pre-commit", warning.GetProperty("path").GetString()); Assert.Equal("pre-commit", warning.GetProperty("diagnostic_path").GetString()); Assert.Contains("failed to delete managed_hook", warning.GetProperty("message").GetString(), StringComparison.Ordinal); Assert.Contains("IOException", warning.GetProperty("message").GetString(), StringComparison.Ordinal); @@ -655,6 +656,34 @@ public void Hooks_UninstallJson_ReportsManagedHookDeleteFailure() } } + [Fact] + public void Hooks_UninstallHuman_ReportsManagedHookDeleteWarning_Issue4855() + { + var projectRoot = TestProjectHelper.CreateTempProject("hook_uninstall_human_warning"); + try + { + TestProjectHelper.InitializeGitRepo(projectRoot); + var installExit = RunHooksAndCaptureStreams(["install", "--project", projectRoot]).ExitCode; + Assert.Equal(CommandExitCodes.Success, installExit); + HookCommandRunner.DeleteFileForTesting = _ => throw new IOException("delete denied"); + + var result = RunHooksAndCaptureStreams(["uninstall", "--project", projectRoot]); + + Assert.Equal(CommandExitCodes.InstallError, result.ExitCode); + Assert.Equal(string.Empty, result.StdOut); + Assert.Contains("Warning:", result.StdErr, StringComparison.Ordinal); + Assert.Contains("failed to delete managed_hook", result.StdErr, StringComparison.Ordinal); + Assert.Contains("pre-commit", result.StdErr, StringComparison.Ordinal); + Assert.Contains(nameof(IOException), result.StdErr, StringComparison.Ordinal); + Assert.Contains($"Error [{CommandErrorCodes.HookOperationFailed}]:", result.StdErr, StringComparison.Ordinal); + } + finally + { + HookCommandRunner.DeleteFileForTesting = null; + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void Hooks_UninstallJson_ReportsChainedHookBackupFailure() { @@ -679,7 +708,7 @@ public void Hooks_UninstallJson_ReportsChainedHookBackupFailure() Assert.Equal("error", document.RootElement.GetProperty("status").GetString()); var warning = document.RootElement.GetProperty("warnings")[0]; Assert.Equal("chained_hook_backup", warning.GetProperty("category").GetString()); - Assert.Equal(chainedHookPath, warning.GetProperty("path").GetString()); + Assert.Equal("pre-commit.cdidx-chain", warning.GetProperty("path").GetString()); Assert.Equal("pre-commit.cdidx-chain", warning.GetProperty("diagnostic_path").GetString()); Assert.Contains("failed to restore chained hook backup", warning.GetProperty("message").GetString(), StringComparison.Ordinal); Assert.Contains("IOException", warning.GetProperty("message").GetString(), StringComparison.Ordinal); diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs index 40ddafbd5..5311219d1 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerInspectTests.cs @@ -1673,12 +1673,18 @@ public void RunOutline_JsonArray_WritesOutlineSpecificError_Issue3947() _jsonOptions)); Assert.Equal(CommandExitCodes.UsageError, exitCode); - Assert.Equal(string.Empty, stdout); - Assert.Contains("--json= is not supported by outline", stderr, StringComparison.Ordinal); - Assert.Contains("outline emits one JSON object", stderr, StringComparison.Ordinal); - Assert.Contains("use plain `--json`", stderr, StringComparison.Ordinal); - Assert.Contains("paging metadata", stderr, StringComparison.Ordinal); - Assert.Contains("next_cursor", stderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var error = document.RootElement; + Assert.Equal("error", error.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.UsageError, error.GetProperty("error_code").GetString()); + Assert.Equal("usage", error.GetProperty("category").GetString()); + Assert.Equal("outline", error.GetProperty("command").GetString()); + Assert.Contains("--json= is not supported by outline", error.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.Contains("outline emits one JSON object", error.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.Contains("use plain `--json`", error.GetProperty("hint").GetString(), StringComparison.Ordinal); + Assert.Contains("paging metadata", error.GetProperty("hint").GetString(), StringComparison.Ordinal); + Assert.Contains("next_cursor", error.GetProperty("hint").GetString(), StringComparison.Ordinal); } [Fact] @@ -2157,9 +2163,16 @@ public void M2() { } _jsonOptions)); Assert.Equal(CommandExitCodes.UsageError, mismatchedExitCode); - Assert.Equal(string.Empty, mismatchedStdout); - Assert.Contains("query scope, filters, or ordering", mismatchedStderr, StringComparison.Ordinal); - Assert.Contains("restart required", mismatchedStderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, mismatchedStderr); + using (var mismatchedDocument = ParseJsonOutput(mismatchedStdout)) + { + var error = mismatchedDocument.RootElement; + Assert.Equal(CommandErrorCodes.UsageError, error.GetProperty("error_code").GetString()); + Assert.Equal("usage", error.GetProperty("category").GetString()); + Assert.Equal("outline", error.GetProperty("command").GetString()); + Assert.Contains("query scope, filters, or ordering", error.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.Contains("restart required", error.GetProperty("message").GetString(), StringComparison.Ordinal); + } var (secondExitCode, secondStdout, secondStderr) = CaptureConsole(() => QueryCommandRunner.RunOutline( ["src/many.cs", "--db", dbPath, "--json", "--kind", "function", "--limit", "2", "--cursor", nextCursor!, "--outline-fields", "name,line,kind"], @@ -2190,9 +2203,16 @@ public void M2() { } _jsonOptions)); Assert.Equal(CommandExitCodes.UsageError, staleExitCode); - Assert.Equal(string.Empty, staleStdout); - Assert.Contains("index generation changed", staleStderr, StringComparison.Ordinal); - Assert.Contains("restart required", staleStderr, StringComparison.Ordinal); + Assert.Equal(string.Empty, staleStderr); + using (var staleDocument = ParseJsonOutput(staleStdout)) + { + var error = staleDocument.RootElement; + Assert.Equal(CommandErrorCodes.UsageError, error.GetProperty("error_code").GetString()); + Assert.Equal("usage", error.GetProperty("category").GetString()); + Assert.Equal("outline", error.GetProperty("command").GetString()); + Assert.Contains("index generation changed", error.GetProperty("message").GetString(), StringComparison.Ordinal); + Assert.Contains("restart required", error.GetProperty("message").GetString(), StringComparison.Ordinal); + } } finally { @@ -2213,8 +2233,13 @@ public void RunOutline_RejectsInvalidKindFilter_Issue3986() _jsonOptions)); Assert.Equal(CommandExitCodes.InvalidArgument, exitCode); - Assert.Equal(string.Empty, stdout); - Assert.Contains("invalid --kind value `missing`", stderr); + Assert.Equal(string.Empty, stderr); + using var document = ParseJsonOutput(stdout); + var error = document.RootElement; + Assert.Equal(CommandErrorCodes.UsageError, error.GetProperty("error_code").GetString()); + Assert.Equal("usage", error.GetProperty("category").GetString()); + Assert.Equal("outline", error.GetProperty("command").GetString()); + Assert.Contains("invalid --kind value `missing`", error.GetProperty("message").GetString(), StringComparison.Ordinal); } finally { diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index b7d722979..dfccf2cd7 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -1971,8 +1971,14 @@ public void RunPublishedTrimmedCli_SerializesQueryJsonAndSupportsRazorAliases() Assert.Equal(string.Empty, outlineStdErr); using (var outlineJson = JsonDocument.Parse(outlineStdOut)) { - Assert.Equal("src/missing.cs", outlineJson.RootElement.GetProperty("path").GetString()); - Assert.Equal("file not found in index", outlineJson.RootElement.GetProperty("error").GetString()); + var error = outlineJson.RootElement; + Assert.Equal("1", error.GetProperty("api_version").GetString()); + Assert.Equal("error", error.GetProperty("status").GetString()); + Assert.Equal("src/missing.cs", error.GetProperty("path").GetString()); + Assert.Equal(CommandErrorCodes.FileNotFound, error.GetProperty("error_code").GetString()); + Assert.Equal("not_found", error.GetProperty("category").GetString()); + Assert.Equal("outline", error.GetProperty("command").GetString()); + Assert.Equal(CommandExitCodes.NotFound, error.GetProperty("exit_code").GetInt32()); } foreach (var lang in new[] { "cshtml", "razor" })