From ed5a39672c8af9e18a8f1210fb05974ba8b6292d Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 30 Jul 2026 01:13:11 +0900 Subject: [PATCH] Make workspace status CI-actionable (#4885) --- DEVELOPER_GUIDE.md | 22 +- README.md | 22 ++ TESTING_GUIDE.md | 4 + changelog.d/unreleased/4885.changed.md | 21 ++ src/CodeIndex/Cli/ConsoleUi.cs | 9 +- src/CodeIndex/Cli/WorkspaceCommandRunner.cs | 202 ++++++++++++-- src/CodeIndex/Cli/WorkspaceManifest.cs | 31 ++- .../WorkspaceCommandRunnerTests.cs | 248 +++++++++++++++++- 8 files changed, 520 insertions(+), 39 deletions(-) create mode 100644 changelog.d/unreleased/4885.changed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index f0a59b85f..256929193 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -225,7 +225,9 @@ ownership boundaries so behavior changes remain reviewable and testable. ### Workspaces -`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single` with unknown values rejected, `default_db_name` is a plain file name that overrides `codeindex.db`, and `shared_ignores` is reserved for shared ignore policy. Invalid `members` entries are rejected with bounded diagnostics, and valid entries are normalized and deduplicated with the workspace path casing policy before DB paths are materialized. `cdidx workspace list` and `cdidx workspace status` report member DB paths. `workspace status` also reports each member's database existence, probe status and reason, schema compatibility, exact workspace freshness, timestamps, index completeness, and graph readiness. It probes at most 64 distinct existing member databases per invocation, reuses a probe when members share a database under the `single` strategy, and marks later members as `not_checked` with a top-level truncation summary. In JSON mode, invalid manifest schema or safety failures are returned as a structured `workspace_manifest_invalid` error instead of falling through to the top-level crash handler. +`cdidx.workspace.json` and `.cdidx-workspace.json` declare monorepo members without adding a YAML dependency. Workspace manifests are capped at 64 KiB, 16 JSON nesting levels, 1024 members, 4096 characters per member path, and 255 characters for `default_db_name`. The supported schema is additive: `members` is an array of member paths that must be relative to and resolve under the manifest directory, `index_strategy` is `per_member` or `single` with unknown values rejected, `default_db_name` is a plain file name that overrides `codeindex.db`, and `shared_ignores` is reserved for shared ignore policy. Invalid `members` entries are rejected with bounded diagnostics, and valid entries are normalized and deduplicated with the workspace path casing policy before DB paths are materialized. `cdidx workspace list` and `cdidx workspace status` report member DB paths. `workspace status` also reports each member's project-directory and database existence as the unambiguous sibling fields `project_exists` and `db_exists`; the older `exists` field remains a compatibility alias for `project_exists`. Per-member `index_health` reports probe status and stable reason, a structured `repair_action`, schema compatibility, exact workspace freshness, timestamps, index completeness, and graph readiness. It probes at most 64 distinct existing member databases per invocation, reuses a probe when members share a database under the `single` strategy, and marks later members as `not_checked` with a top-level truncation summary. + +`member_health_summary` aggregates healthy, degraded, and missing members, reports the exit code that enforcement would use, and deduplicates recommended action codes. `cdidx workspace status --check` returns success `0` only when every required member is `ready`; it returns not-found `2` when the manifest is missing, the manifest has no members, or any required project/database is missing, and stale-index `5` for every other degraded aggregate (including stale, incomplete, incompatible, invalid, unavailable, or probe-limit-skipped members). Missing takes precedence over degraded in a mixed workspace. Without `--check`, `workspace status` remains informational and returns `0` after a successful report. Invalid manifest schema or safety failures remain usage exit `1` and, in JSON mode, are returned as a structured `workspace_manifest_invalid` error instead of falling through to the top-level crash handler. Repair commands are emitted as `name` plus `args[]`, not shell-quoted strings, so paths remain portable across Windows and POSIX shells. `cdidx workspace use ` writes an existing manifest member or `default` workspace to the per-user config directory and rejects missing manifest members. A directory name remains a shorthand when it identifies exactly one member; repeated directory names remain ambiguous. A manifest-relative path selects the exact normalized member, accepts either slash spelling, and stores the canonical forward-slash relative path in active workspace state. Manifest-member selections also persist `manifest_member: true`, so members named `default` or `env` remain distinguishable from the reserved non-manifest states. Active workspace names share the manifest member path's 4096-character bound. `cdidx workspace clear` (also available as `workspace deactivate`) removes that persisted selection instead of rebinding `default` to the current directory. When `CDIDX_ACTIVE_WORKSPACE` is set, clear reports that the environment override must be unset because it takes precedence over persisted state. Query DB resolution keeps existing precedence: explicit `--db`, then explicit `--data-dir` / `CDIDX_DATA_DIR`, then active workspace state, then ancestor/CWD discovery. @@ -3572,11 +3574,23 @@ invalid な `members` entries は件数を制限した diagnostics で拒否さ `cdidx workspace list` と `cdidx workspace status` は member DB path を報告します。 `workspace status` はさらに、member ごとの database 存在有無、probe status / reason、 schema compatibility、workspace との厳密な freshness、timestamp、index completeness、 -graph readiness を報告します。1 回の実行で probe する既存の異なる member database は最大 64 個で、 +graph readiness を報告します。project directory と database の存在有無は sibling field の +`project_exists` / `db_exists` で明示し、従来の `exists` は `project_exists` の互換 alias として +維持します。member ごとの `index_health` は安定した reason と構造化 `repair_action` も返します。 +1 回の実行で probe する既存の異なる member database は最大 64 個で、 `single` strategy で database が共有される場合は probe 結果を再利用し、それ以降の member は `not_checked` として top-level の truncation summary に反映します。 -JSON mode では、manifest schema または safety validation の失敗は top-level crash handler へ -落とさず、構造化された `workspace_manifest_invalid` error として返します。 +`member_health_summary` は healthy / degraded / missing member を集約し、enforcement 時の exit code と +重複排除した recommended action code を返します。`cdidx workspace status --check` はすべての +required member が `ready` の場合だけ success `0`、manifest 不在、member 0件、required project / +database が1件でも missing の場合は not-found `2`、それ以外の degraded aggregate(stale、 +incomplete、incompatible、invalid、unavailable、probe limit による未確認を含む)では +stale-index `5` を返します。mixed workspace では missing が degraded より優先されます。 +`--check` なしの `workspace status` は informational なままで、report 成功時は `0` です。 +不正な manifest schema / safety validation は従来どおり usage exit `1` で、JSON mode では +top-level crash handler へ落とさず構造化 `workspace_manifest_invalid` error を返します。 +repair command は shell quoting 済み文字列ではなく `name` と `args[]` で返すため、 +Windows / POSIX shell 間でも path を安全に扱えます。 `cdidx workspace use ` は既存の manifest member または `default` を active workspace として per-user config directory に保存し、存在しない member は拒否します。 diff --git a/README.md b/README.md index e78222746..3e9e12ce8 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,17 @@ Check-mode JSON includes `query_context.check_mode` (`explicit` or `implied_by_stale_after`) and the effective `query_context.stale_after_seconds`; ordinary status JSON omits `query_context`. +`cdidx workspace status --json` reports sibling `project_exists` and `db_exists` +fields for each member; the older `exists` field remains as a compatibility +alias for `project_exists`. Each `index_health` includes a stable reason and a +structured `repair_action` (`action` plus optional `command.name` / +`command.args`), while `member_health_summary` aggregates healthy, degraded, +and missing counts. `workspace status --check` returns `0` only when every +required member is ready, `2` for a missing manifest, an empty workspace, or +any missing required member/database, and `5` for other degraded health. +Malformed manifests retain usage exit `1`. Ordinary `workspace status` remains +informational and returns `0` after a successful status report. + Index-generation readiness is derived from persisted evidence and is shared by the index command result, immediate `status` / workspace status, and MCP responses. `index_complete=false` identifies omitted input or extraction work, @@ -694,6 +705,17 @@ check mode の JSON は `query_context.check_mode`(`explicit` または `implied_by_stale_after`)と有効な `query_context.stale_after_seconds` を含み、 通常の status JSON では `query_context` を省略します。 +`cdidx workspace status --json` は member ごとに sibling field の +`project_exists` と `db_exists` を返します。従来の `exists` は +`project_exists` の互換 alias として維持します。各 `index_health` は安定した reason と、 +`action` および任意の `command.name` / `command.args` からなる構造化 +`repair_action` を含み、`member_health_summary` は healthy / degraded / missing +件数を集約します。`workspace status --check` はすべての required member が ready の +場合だけ `0`、manifest 不在、空 workspace、required member / database が1件でも +missing の場合は `2`、その他の degraded health では `5` を返します。 +不正な manifest は従来どおり usage exit `1` です。通常の `workspace status` は +informational なままで、status report が成功すれば `0` を返します。 + index generation の readiness は永続化済みの証拠から導出し、index command の結果、 直後の `status` / workspace status、MCP response で同じ snapshot を共有します。 symbols-only run、file size / symbol count / reference count の上限、extractor failure、 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 76fe2516b..df26cf159 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -77,6 +77,8 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding Search snippet origin-priority coverage keeps PascalCase, snake_case, and phrase queries in coordinated mixed comment/string/code fixtures so identifier focus, same-line code-column clamping, over-1-MiB valid chunks, final-window dropped counts, filtered-origin refocusing, and the phrase control share one contract. Recovery-command coverage keeps resolved execution arguments separate from support-safe display arguments. Assert structured argv, current `dotnet`/apphost prefix preservation, replay of option-like paths under CLI `--show-paths`, default CLI/MCP redaction metadata, and correct quoting for both POSIX sh and PowerShell. Include paths with spaces, quotes, dollar signs, shell metacharacters, POSIX home/temp roots, Windows drives, UNC roots, option-like source names such as `--db`, and file-URI database query parameters containing raw/encoded paths, percent-encoded sensitive keys, or path values with embedded sensitive assignments. Default-output assertions must reject the fixture's full absolute paths and secrets while preserving safe URI controls. Pair this with `status --config` coverage for default DB/data/log path and URI-query redaction, always-redacted secrets, and explicit `--show-paths`. Console writer synchronization coverage yields between character writes instead of sleeping per character; use enough whole-line iterations to expose interleaving without adding wall-clock delay. +- `WorkspaceCommandRunnerTests.cs` + Workspace status coverage keeps missing manifests, empty and malformed manifests, missing project directories, all-missing databases, mixed healthy/degraded members, and shared-database layouts independently observable. Assert the compatibility `exists` alias beside unambiguous `project_exists` and `db_exists` fields, structured repair command names and argv (including paths with spaces), human labels, aggregate reasons/actions, and the stable `--check` exit policy: ready `0`, missing `2`, degraded `5`, and invalid input `1`. - `SymbolExtractor*Tests.cs` and `ReferenceExtractor*Tests.cs` Extractor coverage is split by language or feature area with partial test classes, while shared helpers remain on the root `SymbolExtractorTests` / `ReferenceExtractorTests` parts. YAML sequence-hierarchy fixtures must keep sequence items path-only, count empty/scalar/alias items in stable indexes, preserve anchor and block-scalar boundaries, assert logical qualified paths separately from nearest emitted symbol parents, and prove a normal full scan refreshes unchanged YAML rows stamped with the prior extractor contract. @@ -1009,6 +1011,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" search snippet の origin 優先順位 coverage は PascalCase、snake_case、phrase query を連携した comment / string / code 混在 fixture にまとめ、identifier focus、同一行の code 列への clamping、1 MiB を超える有効 chunk、最終 window の dropped count、filter 後 origin への再 focus、phrase の control を一つの contract として検証します。 recovery command の coverage では、解決済みの実行引数とサポート共有向けの表示引数を分離して検証します。構造化 argv、現在の `dotnet` / apphost prefix の維持、CLI `--show-paths` による option と紛らわしい path の再実行、既定の CLI/MCP redaction metadata、POSIX sh と PowerShell 双方の正しい quoting を確認してください。空白、quote、dollar sign、shell metacharacter、POSIX の home/temp root、Windows drive、UNC root、`--db` のように option と紛らわしい source 名、raw / encoded path、percent-encoded な機密 key、機密 assignment を内包する path 値を持つ file-URI database query parameter を含めます。既定出力に fixture の完全な絶対パスや secret が残らず、安全な URI control は維持されることを assertion にします。`status --config` の DB/data/log path と URI query の既定 redaction、mode に関係なく維持される secret redaction、明示的 `--show-paths` も対で検証してください。 console writer synchronization coverageは文字writeごとのsleepではなくyieldを使い、wall-clock delayを追加せずinterleavingを露出できる十分なwhole-line iterationを維持してください。 +- `WorkspaceCommandRunnerTests.cs` + workspace status の coverage では、manifest 不在、空 / malformed manifest、project directory 不在、全 database 不在、healthy / degraded member の混在、shared-database layout をそれぞれ独立して観測可能にします。曖昧さのない `project_exists` / `db_exists` と互換用 `exists` alias、構造化された修復 command 名と argv(空白を含む path を含む)、human-readable label、集約 reason / action、ならびに ready `0`、missing `2`、degraded `5`、invalid input `1` の安定した `--check` exit policy を検証してください。 - `SymbolExtractor*Tests.cs` と `ReferenceExtractor*Tests.cs` extractor のカバレッジは言語または機能領域ごとの partial test class に分割し、共有 helper は root 側の `SymbolExtractorTests` / `ReferenceExtractorTests` に残します。 YAML sequence hierarchy の fixture では、sequence item を path 専用のまま保ち、empty / scalar / alias item を安定した index に数え、anchor と block scalar の境界を維持し、論理 qualified path と最寄りの出力済み symbol parent を分けて検証し、旧 extractor contract が stamp された未変更 YAML row を通常の full scan が更新することも確認してください。 diff --git a/changelog.d/unreleased/4885.changed.md b/changelog.d/unreleased/4885.changed.md new file mode 100644 index 000000000..7817a3525 --- /dev/null +++ b/changelog.d/unreleased/4885.changed.md @@ -0,0 +1,21 @@ +--- +category: changed +issues: + - 4885 +affected: + - src/CodeIndex/Cli/WorkspaceCommandRunner.cs + - src/CodeIndex/Cli/WorkspaceManifest.cs + - src/CodeIndex/Cli/ConsoleUi.cs + - tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs + - README.md + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Workspace status is now unambiguous and CI-actionable (#4885)** — member JSON adds sibling `project_exists` / `db_exists` fields while retaining `exists` as a compatibility alias, publishes per-member structured repair actions and an aggregate health summary, and supports `workspace status --check` with stable healthy (`0`), missing (`2`), and degraded (`5`) exit policies. + +## 日本語 + +- **workspace status の存在判定を明確化し、CI で判定可能にしました (#4885)** — member JSON に sibling field の `project_exists` / `db_exists` を追加しつつ `exists` を互換 alias として維持し、member ごとの構造化 repair action と aggregate health summary を公開しました。また、healthy (`0`)、missing (`2`)、degraded (`5`) の安定した終了ポリシーを持つ `workspace status --check` を追加しました。 diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 22e8b6755..1d588ec33 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -101,9 +101,9 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("inspect", "cdidx inspect --path --line [--end-line ] [--db ] [--json] [--redact-paths|--show-paths] [--format ] [--pretty] [--compact] [--fields ] [--outline-only] [--body-only] [--cursor ] [--max-json-bytes ] [--body] [--body-start ] [--body-lines |--body-line-count ] [--context |--before |--after ] [--max-line-width ]"), ("outline", "cdidx outline [--db ] [--json] [--pretty] [--compact] [--verbose] [--limit |--top ] [--cursor ] [--max-json-bytes ] [--sort ] [--kind ] [--outline-fields ]"), ("status", "cdidx status [--db ] [--json] [--format ] [--compact] [--fields ] [--cursor ] [--max-json-bytes ] [--verbose] [--check[=workspace,fold,graph,issues,hotspot,csharp,sql,newer]] [--stale-after ] [--explain ] [--log-path] [--config [--redact-paths|--show-paths]] [--check-updates]"), - ("workspace", "cdidx workspace [name-or-relative-path] [--json]"), + ("workspace", "cdidx workspace [name-or-relative-path] [--json] [--check]"), ("workspace-list", "cdidx workspace list [--json]"), - ("workspace-status", "cdidx workspace status [--json]"), + ("workspace-status", "cdidx workspace status [--json] [--check]"), ("workspace-use", "cdidx workspace use [--json]"), ("workspace-current", "cdidx workspace current [--json]"), ("workspace-clear", "cdidx workspace clear [--json]"), @@ -203,8 +203,9 @@ private static readonly (string Command, string Note)[] CommandUsageNotes = ("hooks-status", "Example: `cdidx hooks status --project . --json`."), ("workspace-list", "Discovers the nearest workspace manifest and lists its members without changing active workspace state."), ("workspace-list", "Example: `cdidx workspace list --json`."), - ("workspace-status", "Lists manifest members and probes bounded index-health details without changing active workspace state."), - ("workspace-status", "Example: `cdidx workspace status --json`."), + ("workspace-status", "Lists manifest members and probes bounded index-health details without changing active workspace state. --check returns 0 for healthy members, 2 for a missing manifest, an empty workspace, or any missing required member/database, and 5 for other degraded member health."), + ("workspace-status", "JSON keeps the legacy `exists` compatibility alias while adding unambiguous member-level `project_exists` and `db_exists` fields, aggregate health, and structured repair actions."), + ("workspace-status", "Example: `cdidx workspace status --check --json`."), ("workspace-use", "Requires exactly one manifest member name or manifest-relative path; `default` selects the current directory without a manifest. The selection is persisted in the per-user cdidx configuration."), ("workspace-use", $"If {ActiveWorkspace.EnvironmentVariable} is set, environment configuration takes precedence over persisted active-workspace state."), ("workspace-use", "Example: `cdidx workspace use src/service --json`."), diff --git a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs index 133150a64..46ec9906f 100644 --- a/src/CodeIndex/Cli/WorkspaceCommandRunner.cs +++ b/src/CodeIndex/Cli/WorkspaceCommandRunner.cs @@ -17,7 +17,18 @@ internal static int Run( CancellationToken cancellationToken = default) { var json = args.Contains("--json", StringComparer.Ordinal); - args = args.Where(a => a != "--json").ToArray(); + var check = args.Contains("--check", StringComparer.Ordinal); + args = args.Where(a => a is not "--json" and not "--check").ToArray(); + if (check && (args.Length == 0 || args[0] != "status")) + { + return CommandErrorWriter.WriteJsonOrHuman( + json, + jsonOptions, + "workspace --check is only valid with workspace status.", + CommandExitCodes.UsageError, + "run `cdidx workspace status --check`."); + } + if (args.Length == 0) return List(json, jsonOptions); @@ -28,6 +39,7 @@ internal static int Run( json, jsonOptions, includeActiveWorkspaceStatus: true, + check: check, cancellationToken: cancellationToken), "current" => Current(json, jsonOptions), "use" => Use(args[1..], json, jsonOptions), @@ -40,11 +52,15 @@ private static int List( bool json, JsonSerializerOptions jsonOptions, bool includeActiveWorkspaceStatus = false, + bool check = false, CancellationToken cancellationToken = default) { var discovery = WorkspaceManifestLoader.Discover(Environment.CurrentDirectory); if (discovery.Path == null) { + var memberHealthSummary = includeActiveWorkspaceStatus + ? BuildMissingManifestHealthSummary() + : null; if (json) { var manifestStatus = new WorkspaceManifestStatusJsonResult( @@ -59,12 +75,20 @@ private static int List( Array.Empty(), manifestStatus, BuildActiveWorkspaceStatus(includeActiveWorkspaceStatus, manifest: null), - MemberHealthSummary: null), + memberHealthSummary, + CheckMode: includeActiveWorkspaceStatus ? check : null), jsonOptions)); } else + { Console.WriteLine("No cdidx.workspace.json or .cdidx-workspace.json found."); - return CommandExitCodes.Success; + if (memberHealthSummary is not null) + Console.WriteLine(FormatMemberHealthSummary(memberHealthSummary)); + } + + return check + ? memberHealthSummary!.CheckExitCode + : CommandExitCodes.Success; } WorkspaceManifest manifest; @@ -94,25 +118,34 @@ private static int List( memberHealth?.Members ?? manifest.Members, manifestStatus, BuildActiveWorkspaceStatus(includeActiveWorkspaceStatus, manifest), - memberHealth?.Summary), + memberHealth?.Summary, + CheckMode: includeActiveWorkspaceStatus ? check : null), jsonOptions)); - return CommandExitCodes.Success; + return check + ? memberHealth!.Summary.CheckExitCode + : CommandExitCodes.Success; } Console.WriteLine($"Manifest : {manifest.Path}"); Console.WriteLine($"Strategy : {manifest.IndexStrategy}"); - var humanMembers = includeActiveWorkspaceStatus - ? BuildMemberHealth(manifest, cancellationToken).Members - : manifest.Members; + var humanMemberHealth = includeActiveWorkspaceStatus + ? BuildMemberHealth(manifest, cancellationToken) + : null; + var humanMembers = humanMemberHealth?.Members ?? manifest.Members; foreach (var member in humanMembers) { var label = member.IndexHealth?.Status ?? (member.Exists ? "ok" : "missing"); var healthSuffix = member.IndexHealth is null ? string.Empty - : $" ({FormatMemberHealth(member.IndexHealth)})"; + : $" (project {(member.ProjectExists ? "present" : "missing")}; database {(member.DatabaseExists == true ? "present" : "missing")}; {FormatMemberHealth(member.IndexHealth)})"; Console.WriteLine($" {label,-11} {member.Path} -> {member.DbPath}{healthSuffix}"); } - return CommandExitCodes.Success; + if (humanMemberHealth is not null) + Console.WriteLine(FormatMemberHealthSummary(humanMemberHealth.Summary)); + + return check + ? humanMemberHealth!.Summary.CheckExitCode + : CommandExitCodes.Success; } private static ActiveWorkspaceJsonResult? BuildActiveWorkspaceStatus(bool include, WorkspaceManifest? manifest) @@ -282,13 +315,15 @@ private static MemberHealthBuildResult BuildMemberHealth( cancellationToken.ThrowIfCancellationRequested(); WorkspaceMemberIndexHealth health; var dbExists = File.Exists(LongPath.EnsureWindowsPrefix(member.DbPath)); + var projectRoot = singleStrategy ? manifest.Root : member.Path; if (!member.Exists) { health = new WorkspaceMemberIndexHealth( DbExists: dbExists, Probed: false, Status: "missing", - Reason: "member_missing"); + Reason: "member_missing", + RepairAction: new WorkspaceMemberRepairAction("create_project_directory")); } else if (!dbExists) { @@ -296,7 +331,11 @@ private static MemberHealthBuildResult BuildMemberHealth( DbExists: false, Probed: false, Status: "missing", - Reason: "database_not_found"); + Reason: "database_not_found", + RepairAction: BuildIndexRepairAction( + "index_member", + projectRoot, + member.DbPath)); } else if (cache.TryGetValue(member.DbPath, out var cachedHealth)) { @@ -308,13 +347,13 @@ private static MemberHealthBuildResult BuildMemberHealth( DbExists: true, Probed: false, Status: "not_checked", - Reason: "database_probe_limit_reached"); + Reason: "database_probe_limit_reached", + RepairAction: new WorkspaceMemberRepairAction("reduce_workspace_probe_scope")); unprobedMemberCount++; } else { databaseProbeCount++; - var projectRoot = singleStrategy ? manifest.Root : member.Path; health = ProbeMemberHealth( member.DbPath, projectRoot, @@ -327,12 +366,10 @@ private static MemberHealthBuildResult BuildMemberHealth( return new MemberHealthBuildResult( members, - new WorkspaceMemberHealthSummary( - manifest.Members.Count, + BuildMemberHealthSummary( + members, databaseProbeCount, - MaxMemberHealthDatabaseProbes, - unprobedMemberCount, - unprobedMemberCount > 0)); + unprobedMemberCount)); } private static WorkspaceMemberIndexHealth ProbeMemberHealth( @@ -353,6 +390,11 @@ private static WorkspaceMemberIndexHealth ProbeMemberHealth( Probed: true, Status: "invalid", Reason: "invalid_codeindex_database", + RepairAction: BuildIndexRepairAction( + "rebuild_member_index", + projectRoot, + dbPath, + rebuild: true), SchemaCompatible: false); } @@ -370,6 +412,7 @@ private static WorkspaceMemberIndexHealth ProbeMemberHealth( Probed: true, Status: "incompatible", Reason: "index_newer_than_reader", + RepairAction: new WorkspaceMemberRepairAction("upgrade_cdidx"), SchemaCompatible: false, FreshnessReason: "schema_incompatible", IndexedAt: snapshot.IndexedAt, @@ -425,6 +468,11 @@ private static WorkspaceMemberIndexHealth ProbeMemberHealth( Probed: true, Status: status, Reason: reason, + RepairAction: BuildProbedMemberRepairAction( + status, + reason, + projectRoot, + dbPath), SchemaCompatible: true, IndexMatchesWorkspace: freshness.Checked ? freshness.MatchesWorkspace : null, FreshnessReason: freshness.Reason, @@ -443,10 +491,119 @@ private static WorkspaceMemberIndexHealth ProbeMemberHealth( DbExists: true, Probed: true, Status: "unavailable", - Reason: "database_probe_failed"); + Reason: "database_probe_failed", + RepairAction: new WorkspaceMemberRepairAction("inspect_database_and_retry")); + } + } + + private static WorkspaceMemberRepairAction BuildProbedMemberRepairAction( + string status, + string reason, + string projectRoot, + string dbPath) + { + if (status == "ready") + return new WorkspaceMemberRepairAction("none"); + if (status == "stale") + return BuildIndexRepairAction("refresh_member_index", projectRoot, dbPath); + if (reason == "freshness_check_unavailable") + return new WorkspaceMemberRepairAction("inspect_workspace_and_retry"); + return BuildIndexRepairAction("rebuild_member_index", projectRoot, dbPath, rebuild: true); + } + + private static WorkspaceMemberRepairAction BuildIndexRepairAction( + string action, + string projectRoot, + string dbPath, + bool rebuild = false) + { + var args = new List { "index", projectRoot, "--db", dbPath }; + if (rebuild) + { + args.Add("--rebuild"); + args.Add("--yes"); } + + return new WorkspaceMemberRepairAction( + action, + new WorkspaceRepairCommand("cdidx", args)); } + private static WorkspaceMemberHealthSummary BuildMemberHealthSummary( + IReadOnlyList members, + int databaseProbeCount, + int unprobedMemberCount) + { + var healthyMemberCount = members.Count(member => member.IndexHealth?.Status == "ready"); + var missingMemberCount = members.Count(member => member.IndexHealth?.Status == "missing"); + var degradedMemberCount = members.Count - healthyMemberCount - missingMemberCount; + string status; + string reason; + int checkExitCode; + if (members.Count == 0) + { + status = "empty"; + reason = "workspace_has_no_members"; + checkExitCode = CommandExitCodes.NotFound; + } + else if (missingMemberCount > 0) + { + status = "missing"; + reason = "required_member_missing"; + checkExitCode = CommandExitCodes.NotFound; + } + else if (degradedMemberCount > 0) + { + status = "degraded"; + reason = "member_health_degraded"; + checkExitCode = CommandExitCodes.StaleIndex; + } + else + { + status = "healthy"; + reason = "all_members_ready"; + checkExitCode = CommandExitCodes.Success; + } + + var recommendedActions = members + .Select(member => member.IndexHealth?.RepairAction.Action) + .Where(action => action is not null and not "none") + .Distinct(StringComparer.Ordinal) + .Cast() + .ToArray(); + if (members.Count == 0) + recommendedActions = ["add_workspace_members"]; + + return new WorkspaceMemberHealthSummary( + members.Count, + databaseProbeCount, + MaxMemberHealthDatabaseProbes, + unprobedMemberCount, + unprobedMemberCount > 0, + healthyMemberCount, + degradedMemberCount, + missingMemberCount, + status, + reason, + checkExitCode, + recommendedActions); + } + + private static WorkspaceMemberHealthSummary BuildMissingManifestHealthSummary() + => new( + MemberCount: 0, + DatabaseProbeCount: 0, + DatabaseProbeLimit: MaxMemberHealthDatabaseProbes, + ProbeLimitSkippedMemberCount: 0, + Truncated: false, + HealthyMemberCount: 0, + DegradedMemberCount: 0, + MissingMemberCount: 0, + Status: "missing", + Reason: "workspace_manifest_not_found", + CheckExitCode: CommandExitCodes.NotFound, + RecommendedActions: ["create_workspace_manifest"]); + private static bool IsMemberHealthProbeFailure(Exception ex) => ex is SqliteException or CodeIndexException @@ -477,9 +634,12 @@ private static string FormatMemberHealth(WorkspaceMemberIndexHealth health) false => "graph degraded", _ => "graph unknown", }; - return $"{schema}; {freshness}; {graph}; reason={health.Reason}"; + return $"{schema}; {freshness}; {graph}; reason={health.Reason}; action={health.RepairAction.Action}"; } + private static string FormatMemberHealthSummary(WorkspaceMemberHealthSummary summary) + => $"Workspace health: {summary.Status}; reason={summary.Reason}; healthy={summary.HealthyMemberCount}; degraded={summary.DegradedMemberCount}; missing={summary.MissingMemberCount}; check_exit_code={summary.CheckExitCode}"; + private sealed record MemberHealthBuildResult( IReadOnlyList Members, WorkspaceMemberHealthSummary Summary); diff --git a/src/CodeIndex/Cli/WorkspaceManifest.cs b/src/CodeIndex/Cli/WorkspaceManifest.cs index 6166f60de..f11166f52 100644 --- a/src/CodeIndex/Cli/WorkspaceManifest.cs +++ b/src/CodeIndex/Cli/WorkspaceManifest.cs @@ -11,13 +11,31 @@ internal sealed record WorkspaceMember( string DbPath, bool Exists, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] - WorkspaceMemberIndexHealth? IndexHealth = null); + WorkspaceMemberIndexHealth? IndexHealth = null) +{ + [JsonPropertyName("project_exists")] + public bool ProjectExists => Exists; + + [JsonPropertyName("db_exists")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public bool? DatabaseExists => IndexHealth?.DbExists; +} + +internal sealed record WorkspaceRepairCommand( + string Name, + IReadOnlyList Args); + +internal sealed record WorkspaceMemberRepairAction( + string Action, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + WorkspaceRepairCommand? Command = null); internal sealed record WorkspaceMemberIndexHealth( bool DbExists, bool Probed, string Status, string Reason, + WorkspaceMemberRepairAction RepairAction, bool? SchemaCompatible = null, bool? IndexMatchesWorkspace = null, string? FreshnessReason = null, @@ -35,7 +53,14 @@ internal sealed record WorkspaceMemberHealthSummary( int DatabaseProbeCount, int DatabaseProbeLimit, int ProbeLimitSkippedMemberCount, - bool Truncated); + bool Truncated, + int HealthyMemberCount, + int DegradedMemberCount, + int MissingMemberCount, + string Status, + string Reason, + int CheckExitCode, + IReadOnlyList RecommendedActions); internal sealed record WorkspaceManifest( string Path, @@ -51,6 +76,8 @@ internal sealed record WorkspaceListJsonResult( ActiveWorkspaceJsonResult? ActiveWorkspaceStatus = null, [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] WorkspaceMemberHealthSummary? MemberHealthSummary = null, + [property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + bool? CheckMode = null, [property: JsonPropertyName("api_version")] string ApiVersion = JsonOutputContract.ApiVersion) : IVersionedJsonResult { [JsonPropertyName("manifest_found")] diff --git a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs index 6766364b3..e204d8cc8 100644 --- a/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/WorkspaceCommandRunnerTests.cs @@ -10,6 +10,14 @@ public class WorkspaceCommandRunnerTests { private readonly JsonSerializerOptions _jsonOptions = ProgramRunner.CreateDefaultJsonOptions(); + private void IndexProject(string projectRoot) + { + var (exitCode, _, stderr) = ConsoleCapture.Capture( + () => IndexCommandRunner.Run([projectRoot, "--json", "--quiet"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + } + [Fact] public void WorkspaceList_ReadsManifestMembers() { @@ -100,6 +108,7 @@ public void WorkspaceStatusJson_ReportsBoundedMemberIndexHealth_Issue4726() Assert.Empty(stderr); using var document = JsonDocument.Parse(stdout); var payload = document.RootElement; + Assert.False(payload.GetProperty("check_mode").GetBoolean()); var healthSummary = payload.GetProperty("member_health_summary"); Assert.Equal(4, healthSummary.GetProperty("member_count").GetInt32()); Assert.Equal(3, healthSummary.GetProperty("database_probe_count").GetInt32()); @@ -108,15 +117,25 @@ public void WorkspaceStatusJson_ReportsBoundedMemberIndexHealth_Issue4726() healthSummary.GetProperty("database_probe_limit").GetInt32()); Assert.Equal(0, healthSummary.GetProperty("probe_limit_skipped_member_count").GetInt32()); Assert.False(healthSummary.GetProperty("truncated").GetBoolean()); + Assert.Equal(0, healthSummary.GetProperty("healthy_member_count").GetInt32()); + Assert.Equal(3, healthSummary.GetProperty("degraded_member_count").GetInt32()); + Assert.Equal(1, healthSummary.GetProperty("missing_member_count").GetInt32()); + Assert.Equal("missing", healthSummary.GetProperty("status").GetString()); + Assert.Equal("required_member_missing", healthSummary.GetProperty("reason").GetString()); + Assert.Equal(CommandExitCodes.NotFound, healthSummary.GetProperty("check_exit_code").GetInt32()); var members = payload.GetProperty("members").EnumerateArray().ToArray(); - var ready = members.Single(member => PathCasing.PathsEqual(member.GetProperty("path").GetString()!, runtimeReadyRoot)) - .GetProperty("index_health"); + var readyMember = members.Single(member => PathCasing.PathsEqual(member.GetProperty("path").GetString()!, runtimeReadyRoot)); + Assert.True(readyMember.GetProperty("exists").GetBoolean()); + Assert.True(readyMember.GetProperty("project_exists").GetBoolean()); + Assert.True(readyMember.GetProperty("db_exists").GetBoolean()); + var ready = readyMember.GetProperty("index_health"); Assert.True(ready.GetProperty("db_exists").GetBoolean()); Assert.True(ready.GetProperty("probed").GetBoolean()); Assert.True(ready.GetProperty("schema_compatible").GetBoolean()); Assert.True(ready.GetProperty("index_matches_workspace").GetBoolean()); Assert.Equal("matched", ready.GetProperty("freshness_reason").GetString()); + Assert.Equal("rebuild_member_index", ready.GetProperty("repair_action").GetProperty("action").GetString()); var graphTableAvailable = ready.GetProperty("graph_table_available").GetBoolean(); var graphDataCurrent = ready.GetProperty("graph_data_current").GetBoolean(); var referenceGraphComplete = ready.GetProperty("reference_graph_complete").GetBoolean(); @@ -137,13 +156,219 @@ public void WorkspaceStatusJson_ReportsBoundedMemberIndexHealth_Issue4726() Assert.Equal("index_newer_than_reader", future.GetProperty("reason").GetString()); Assert.False(future.GetProperty("schema_compatible").GetBoolean()); Assert.True(future.GetProperty("index_newer_than_reader").GetBoolean()); + Assert.Equal("upgrade_cdidx", future.GetProperty("repair_action").GetProperty("action").GetString()); - var missingDb = members.Single(member => PathCasing.PathsEqual(member.GetProperty("path").GetString()!, runtimeMissingDbRoot)) - .GetProperty("index_health"); + var missingDbMember = members.Single(member => PathCasing.PathsEqual(member.GetProperty("path").GetString()!, runtimeMissingDbRoot)); + Assert.True(missingDbMember.GetProperty("project_exists").GetBoolean()); + Assert.False(missingDbMember.GetProperty("db_exists").GetBoolean()); + var missingDb = missingDbMember.GetProperty("index_health"); Assert.Equal("missing", missingDb.GetProperty("status").GetString()); Assert.Equal("database_not_found", missingDb.GetProperty("reason").GetString()); Assert.False(missingDb.GetProperty("db_exists").GetBoolean()); Assert.False(missingDb.GetProperty("probed").GetBoolean()); + Assert.Equal("index_member", missingDb.GetProperty("repair_action").GetProperty("action").GetString()); + + var (checkExitCode, checkStdout, checkStderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check", "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.NotFound, checkExitCode); + Assert.Empty(checkStderr); + using var checkDocument = JsonDocument.Parse(checkStdout); + Assert.True(checkDocument.RootElement.GetProperty("check_mode").GetBoolean()); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + + [Fact] + public void WorkspaceStatusCheck_UsesStableAggregateExitPolicy_Issue4885() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_workspace_status_check"); + var root = project.Root; + var healthyRoot = Path.Combine(root, "members", "project with spaces"); + var degradedRoot = Path.Combine(root, "members", "degraded"); + var missingRoot = Path.Combine(root, "members", "missing"); + Directory.CreateDirectory(healthyRoot); + Directory.CreateDirectory(degradedRoot); + var manifestPath = Path.Combine(root, "cdidx.workspace.json"); + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + + var (informationalExitCode, _, informationalStderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, informationalExitCode); + Assert.Empty(informationalStderr); + var (noManifestExitCode, noManifestStdout, noManifestStderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check", "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.NotFound, noManifestExitCode); + Assert.Empty(noManifestStderr); + using (var noManifestDocument = JsonDocument.Parse(noManifestStdout)) + { + var summary = noManifestDocument.RootElement.GetProperty("member_health_summary"); + Assert.Equal("missing", summary.GetProperty("status").GetString()); + Assert.Equal("workspace_manifest_not_found", summary.GetProperty("reason").GetString()); + } + + File.WriteAllText(manifestPath, """{ "members": [] }"""); + var (emptyExitCode, emptyStdout, emptyStderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check", "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.NotFound, emptyExitCode); + Assert.Empty(emptyStderr); + using (var emptyDocument = JsonDocument.Parse(emptyStdout)) + { + var summary = emptyDocument.RootElement.GetProperty("member_health_summary"); + Assert.Equal("empty", summary.GetProperty("status").GetString()); + Assert.Equal("workspace_has_no_members", summary.GetProperty("reason").GetString()); + Assert.Equal(["add_workspace_members"], summary.GetProperty("recommended_actions").EnumerateArray().Select(item => item.GetString())); + } + + File.WriteAllText( + manifestPath, + JsonSerializer.Serialize(new { members = new[] { "members/missing" } })); + var (missingProjectExitCode, missingProjectStdout, _) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check", "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.NotFound, missingProjectExitCode); + using (var missingProjectDocument = JsonDocument.Parse(missingProjectStdout)) + { + var member = missingProjectDocument.RootElement.GetProperty("members")[0]; + Assert.False(member.GetProperty("project_exists").GetBoolean()); + Assert.False(member.GetProperty("db_exists").GetBoolean()); + Assert.Equal( + "create_project_directory", + member.GetProperty("index_health").GetProperty("repair_action").GetProperty("action").GetString()); + } + + File.WriteAllText( + manifestPath, + JsonSerializer.Serialize(new + { + members = new[] + { + "members/project with spaces", + "members/degraded", + }, + })); + var (missingDatabaseExitCode, missingDatabaseStdout, _) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check", "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.NotFound, missingDatabaseExitCode); + using (var missingDatabaseDocument = JsonDocument.Parse(missingDatabaseStdout)) + { + var members = missingDatabaseDocument.RootElement.GetProperty("members").EnumerateArray().ToArray(); + Assert.All(members, member => Assert.False(member.GetProperty("db_exists").GetBoolean())); + var repairCommand = members[0] + .GetProperty("index_health") + .GetProperty("repair_action") + .GetProperty("command"); + Assert.Equal("cdidx", repairCommand.GetProperty("name").GetString()); + var memberPath = members[0].GetProperty("path").GetString(); + Assert.Contains( + repairCommand.GetProperty("args").EnumerateArray().Select(item => item.GetString()), + argument => argument == memberPath); + } + + const string IndexedContent = "class App {}\n"; + File.WriteAllText(Path.Combine(healthyRoot, "App.cs"), IndexedContent); + IndexProject(healthyRoot); + File.WriteAllText(Path.Combine(degradedRoot, "App.cs"), IndexedContent); + IndexProject(degradedRoot); + File.WriteAllText(Path.Combine(degradedRoot, "App.cs"), "class App { void Changed() {} }\n"); + + var (degradedExitCode, degradedStdout, degradedStderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check", "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.StaleIndex, degradedExitCode); + Assert.Empty(degradedStderr); + using (var degradedDocument = JsonDocument.Parse(degradedStdout)) + { + var summary = degradedDocument.RootElement.GetProperty("member_health_summary"); + Assert.Equal("degraded", summary.GetProperty("status").GetString()); + Assert.Equal(1, summary.GetProperty("healthy_member_count").GetInt32()); + Assert.Equal(1, summary.GetProperty("degraded_member_count").GetInt32()); + Assert.Equal(0, summary.GetProperty("missing_member_count").GetInt32()); + } + + File.WriteAllText(Path.Combine(degradedRoot, "App.cs"), IndexedContent); + var (healthyExitCode, healthyStdout, healthyStderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check", "--json"], _jsonOptions)); + Assert.Equal(CommandExitCodes.Success, healthyExitCode); + Assert.Empty(healthyStderr); + using var healthyDocument = JsonDocument.Parse(healthyStdout); + var healthySummary = healthyDocument.RootElement.GetProperty("member_health_summary"); + Assert.Equal("healthy", healthySummary.GetProperty("status").GetString()); + Assert.Equal("all_members_ready", healthySummary.GetProperty("reason").GetString()); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + + [Fact] + public void WorkspaceStatusJson_SingleStrategyReusesSharedDatabaseProbe_Issue4885() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_workspace_status_shared_db"); + var root = project.Root; + Directory.CreateDirectory(Path.Combine(root, "members", "a")); + Directory.CreateDirectory(Path.Combine(root, "members", "b")); + const string ManifestContent = """ + { + "members": ["members/a", "members/b"], + "index_strategy": "single" + } + """; + File.WriteAllText(Path.Combine(root, "cdidx.workspace.json"), ManifestContent); + IndexProject(root); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, stdout, stderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check", "--json"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Empty(stderr); + using var document = JsonDocument.Parse(stdout); + var summary = document.RootElement.GetProperty("member_health_summary"); + Assert.Equal(1, summary.GetProperty("database_probe_count").GetInt32()); + Assert.Equal(2, summary.GetProperty("healthy_member_count").GetInt32()); + var members = document.RootElement.GetProperty("members").EnumerateArray().ToArray(); + Assert.All(members, member => Assert.True(member.GetProperty("db_exists").GetBoolean())); + Assert.All( + members, + member => Assert.Equal("ready", member.GetProperty("index_health").GetProperty("status").GetString())); + } + finally + { + Environment.CurrentDirectory = previous; + } + } + + [Fact] + public void WorkspaceStatusCheckHuman_ReportsUnambiguousExistenceAndAggregate_Issue4885() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_workspace_status_human"); + var root = project.Root; + Directory.CreateDirectory(Path.Combine(root, "member with spaces")); + File.WriteAllText( + Path.Combine(root, "cdidx.workspace.json"), + """{ "members": ["member with spaces"] }"""); + + var previous = Environment.CurrentDirectory; + try + { + Environment.CurrentDirectory = root; + var (exitCode, stdout, stderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(["status", "--check"], _jsonOptions)); + + Assert.Equal(CommandExitCodes.NotFound, exitCode); + Assert.Empty(stderr); + Assert.Contains("project present; database missing", stdout); + Assert.Contains("reason=database_not_found; action=index_member", stdout); + Assert.Contains("Workspace health: missing", stdout); + Assert.Contains($"check_exit_code={CommandExitCodes.NotFound}", stdout); } finally { @@ -253,9 +478,12 @@ public void CheckedInWorkspaceManifest_ResolvesExistingProjectMembers_Issue4476( } [Theory] - [InlineData("list")] - [InlineData("status")] - public void WorkspaceListJson_InvalidMemberShape_ReturnsStructuredError_Issue4359(string command) + [InlineData("list", false)] + [InlineData("status", false)] + [InlineData("status", true)] + public void WorkspaceListJson_InvalidMemberShape_ReturnsStructuredError_Issue4359( + string command, + bool check) { using var project = TestProjectHelper.CreateTempProjectScope("cdidx_workspace_manifest_invalid_shape"); var root = project.Root; @@ -272,7 +500,11 @@ public void WorkspaceListJson_InvalidMemberShape_ReturnsStructuredError_Issue435 try { Environment.CurrentDirectory = root; - var (exitCode, stdout, stderr) = ConsoleCapture.Capture(() => WorkspaceCommandRunner.Run([command, "--json"], _jsonOptions)); + var commandArgs = check + ? new[] { command, "--check", "--json" } + : [command, "--json"]; + var (exitCode, stdout, stderr) = ConsoleCapture.Capture( + () => WorkspaceCommandRunner.Run(commandArgs, _jsonOptions)); Assert.Equal(CommandExitCodes.UsageError, exitCode); Assert.Equal(string.Empty, stderr);