From c01469169bce92c628ab45a47bf79bb928232e55 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 27 Aug 2026 20:00:02 +0900 Subject: [PATCH 1/3] Fix shell completion option parity (#5194) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 2 + changelog.d/unreleased/5194.fixed.md | 21 ++ src/CodeIndex/Cli/CliCommandMetadata.cs | 2 +- src/CodeIndex/Cli/CliFlagSchema.cs | 140 +++++++- .../Cli/ConsoleCompletionRenderer.cs | 93 +++--- tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 315 +++++++++++++++++- tests/CodeIndex.Tests/ConsoleUiTests.cs | 21 +- 8 files changed, 532 insertions(+), 66 deletions(-) create mode 100644 changelog.d/unreleased/5194.fixed.md diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 389f38926..5702d4b46 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -37,7 +37,7 @@ Development contracts: | `cdidx validate` | This is the user-facing integrity scan for indexed content issues such as replacement characters, BOMs, NUL bytes, mixed line endings, UTF-16 BOMs, and likely non-UTF8 content. Keep its CLI usage, README entry, and help summary in sync when adding validation issue kinds or filters. | | `cdidx doctor` | This is the copy-pasteable environment summary for support requests. Keep it redacted by default: secret-like `CDIDX_*` values must not be printed, and new diagnostic fields should be stable enough for issue triage. Full environment inventory filters (`--env-domain`, `--env-category`, and `--env-sensitivity`) use case-insensitive exact values and compose with AND; filtered JSON summaries describe the returned inventory rather than the global catalog. `--max-json-bytes` is valid with `--json --env-inventory=full` or `--integrations --json`, counts the serialized UTF-8 document plus its newline, and returns a structured usage error rather than an oversized successful document. The `github` block reports `proxy_default_credentials` as `enabled` / `disabled` and the bounded `max_request_timeout_s`; never print proxy credential material or raw secret values. `license --json` returns the versioned `license`, `commercial_use`, `trademark`, and controlling `documents` contract. | | Exception diagnostics | User-facing CLI, JSON, MCP, file-issue, and local diagnostic output must not echo raw `ex.Message` directly. Route exception prose through `CommandErrorWriter.FormatSanitizedExceptionMessage`, `DiagnosticSanitizer.ForMessage`, or an existing bounded `DiagnosticRedactor` helper, and use stable error codes/categories when the message is not needed for recovery. Intentional broad catches should match the `risky-code/broad-exception-catch` taxonomy and normalize to bounded diagnostics, private best-effort suppression, or a documented fallback. | -| Shell completions | Generated shell completion scripts include a comment with the `cdidx` version that produced them. Completion candidates come from `CliFlagSchema`: `ValueKind` / `CommandValueKinds` select contextual path, project, repository, language, and symbol-kind behavior; `ValueDomain` / `CommandValueDomains` define exhaustive finite choices; `SubcommandValueDomains` narrows choices for a nested verb without widening its parent command; and `SupplementalCompletionValues` preserves real reserved literals for mixed inputs such as path-or-`github`. Command discovery must skip schema-defined leading global options and their separated or inline values before resolving command and nested-command context. Display placeholders such as `` are metavariables and must never be parsed into candidates. When command or flag schema changes, update completion tests and keep the README guidance that installed completions should be regenerated after upgrades. | +| Shell completions | Generated shell completion scripts include a comment with the `cdidx` version that produced them. Completion candidates come from `CliFlagSchema`: `ValueKind` / `CommandValueKinds` select contextual path, project, repository, language, and symbol-kind behavior; `ValueDomain` / `CommandValueDomains` define exhaustive finite choices; `SubcommandValueDomains` narrows choices for a nested verb without widening its parent command; `CompletionSubcommands` restricts a flag to exact nested verbs; `ParentCompletionCommands` retains it for a valid default parent operation; and `SupplementalCompletionValues` preserves real reserved literals for mixed inputs such as path-or-`github`. Command discovery must skip schema-defined leading global options and their separated or inline values before resolving command and nested-command context. Display placeholders such as `` are metavariables and must never be parsed into candidates. Dedicated parser/help inventories and every generated Bash, zsh, fish, and PowerShell context must have bidirectional tests: every public accepted flag is registered and rendered, and no sibling context advertises a rejected flag. When command or flag schema changes, update those completion tests and keep the README guidance that installed completions should be regenerated after upgrades. | | Target frameworks | The production CLI and NuGet tool packaging target `net8.0`. The test project multi-targets `net8.0;net9.0`, and CI runs the test suite on both frameworks across Linux, Windows, and macOS. Use a .NET SDK that can restore and run both target frameworks when validating the full CI-equivalent test matrix. | | SDK selection | `global.json` pins the repository SDK to `9.0.301` with `rollForward` disabled. CI installs both `8.0.413` and `9.0.301` explicitly: `8.0.413` provides the `net8.0` runtime lane, while `9.0.301` is the selected SDK for restore, build, test, publish, and changelog validation. When rolling SDKs, update `global.json`, every `actions/setup-dotnet` version list, the Docker build image, and this guide together. | | GitHub Actions policy | Workflows pin hosted runners to versioned labels (`ubuntu-24.04`, `windows-2022`, `macos-14`), keep the top-level `contents` permission read-only by default, limit `continue-on-error` to failure-path diagnostic artifact upload, give every upload artifact explicit retention, and bound every artifact download by pattern and path. NuGet cache keys use workflow + runner OS + the exact reachable `packages.lock.json` restore graph, explicitly exclude `global.json` and unrelated locks, avoid broad restore-key fallbacks, and keep version-pinned tool caches separate. `PackagesLockTests` owns the exact restore/cache graph contract; `CiWorkflowTests.GitHubActionsWorkflows_FollowRunnerArtifactCacheAndContinueOnErrorPolicy` enforces the general workflow checklist. | @@ -4224,7 +4224,7 @@ net9 CI lane に合わせる場合は `FRAMEWORK=net9.0 make test` を使いま | `cdidx validate` | replacement character、BOM、NUL byte、混在改行、UTF-16 BOM、非 UTF-8 らしい内容など、indexed content の問題を user-facing に検査する integrity scan です。validation issue の種別や filter を追加する場合は、CLI usage、README entry、help summary を同期してください。 | | `cdidx doctor` | support request 向けにコピーしやすい environment summary です。既定では redacted に保ち、secret 風の `CDIDX_*` 値は出力しないでください。新しい diagnostic field は issue triage に使える程度に安定したものだけにします。full environment inventory の filter(`--env-domain`、`--env-category`、`--env-sensitivity`)は大文字小文字を区別しない完全一致で AND 合成し、filtered JSON summary は global catalog ではなく返却 inventory を表します。`--max-json-bytes` は `--json --env-inventory=full` または `--integrations --json` と組み合わせ、serialize した UTF-8 文書と改行を数え、上限を超える成功文書の代わりに structured usage error を返します。`github` block は `proxy_default_credentials` を `enabled` / `disabled` として出力し、bounded な `max_request_timeout_s` も出します。proxy credential material や raw secret value は出力しないでください。`license --json` は version 付きの `license`、`commercial_use`、`trademark`、controlling `documents` contract を返します。 | | 例外診断 | user-facing な CLI / JSON / MCP / file issue / local diagnostic output では raw `ex.Message` を直接 echo しないでください。例外の prose は `CommandErrorWriter.FormatSanitizedExceptionMessage`、`DiagnosticSanitizer.ForMessage`、または既存の bounded な `DiagnosticRedactor` helper を通し、回復に message が不要な場合は安定した error code/category を使ってください。意図的に残す broad catch は `risky-code/broad-exception-catch` taxonomy に沿い、bounded diagnostic、private な best-effort suppression、または documented fallback に正規化してください。 | -| shell completion | 生成された shell completion script には、生成元の `cdidx` version comment が含まれます。completion candidate は `CliFlagSchema` を基準にし、`ValueKind` / `CommandValueKinds` が path、project、repository、language、symbol kind の文脈別動作を選び、`ValueDomain` / `CommandValueDomains` は網羅的な有限候補を定義し、`SubcommandValueDomains` は親 command の候補を広げずに nested verb 固有の候補へ絞り込み、`SupplementalCompletionValues` は path または `github` のような混合入力で実在する予約 literal を維持します。command context と nested-command context を解決する前に、schema 定義済みの先頭 global option と、その分離形式または inline 形式の値を読み飛ばしてください。`` のような表示用 placeholder は metavariable であり、候補へ分解してはいけません。command や flag の schema を変えた場合は completion test を更新し、upgrade 後に installed completion を再生成する README guidance も保ってください。 | +| shell completion | 生成された shell completion script には、生成元の `cdidx` version comment が含まれます。completion candidate は `CliFlagSchema` を基準にし、`ValueKind` / `CommandValueKinds` が path、project、repository、language、symbol kind の文脈別動作を選び、`ValueDomain` / `CommandValueDomains` は網羅的な有限候補を定義し、`SubcommandValueDomains` は親 command の候補を広げずに nested verb 固有の候補へ絞り込みます。`CompletionSubcommands` は flag を正確な nested verb に限定し、`ParentCompletionCommands` は有効な既定の親操作でもその flag を維持します。`SupplementalCompletionValues` は path または `github` のような混合入力で実在する予約 literal を維持します。command context と nested-command context を解決する前に、schema 定義済みの先頭 global option と、その分離形式または inline 形式の値を読み飛ばしてください。`` のような表示用 placeholder は metavariable であり、候補へ分解してはいけません。専用 parser / help inventory と生成された Bash、zsh、fish、PowerShell の各 context には双方向 test を置き、公開された受理 flag がすべて登録・生成されること、および sibling context が拒否される flag を提示しないことを検証してください。command や flag の schema を変えた場合はそれらの completion test を更新し、upgrade 後に installed completion を再生成する README guidance も保ってください。 | | target framework | 製品版 CLI と NuGet tool packaging は `net8.0` を対象にしています。test project は `net8.0;net9.0` の multi-target で、CI は Linux、Windows、macOS の各 lane で両方の framework に対して test suite を実行します。CI 相当の full matrix を検証する場合は、両方の target framework を restore / 実行できる .NET SDK を使ってください。 | | SDK selection | `global.json` は repository SDK を `9.0.301` に固定し、`rollForward` を無効化します。CI は `8.0.413` と `9.0.301` を明示的に install します。`8.0.413` は `net8.0` runtime lane を提供し、`9.0.301` は restore、build、test、publish、changelog 検証で選択される SDK です。SDK を更新する場合は、`global.json`、すべての `actions/setup-dotnet` version list、Docker build image、この guide を同じ変更で更新してください。 | | GitHub Actions policy | workflow は hosted runner を version 付き label(`ubuntu-24.04`、`windows-2022`、`macos-14`)に固定し、top-level の `contents` permission は既定で read-only に保ちます。`continue-on-error` は failure path の diagnostic artifact upload に限定し、すべての upload artifact に明示的な retention を付け、artifact download は pattern と path で境界を絞ります。NuGet cache key は workflow + runner OS + 到達可能な `packages.lock.json` の厳密な restore graph を使い、`global.json` と無関係な lock を明示的に除外し、広い restore-key fallback を避け、version 固定 tool cache を分離します。`PackagesLockTests` が厳密な restore/cache graph 契約を所有し、`CiWorkflowTests.GitHubActionsWorkflows_FollowRunnerArtifactCacheAndContinueOnErrorPolicy` が一般的な workflow checklist を強制します。 | diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 5bf45e37e..6eefaf52e 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -19,6 +19,7 @@ Use the full suite by default. Use targeted filters only while iterating locally ## Test Stack +- Issue #5194 shell-completion inventory coverage belongs in `CliFlagSchemaTests.cs` and `ConsoleUiTests.cs`. Keep exact contextual inventories for lsp, every workspace verb, config show, diff, import, archive export, and ctags export synchronized with authoritative help and safe parser probes. Preserve positive and negative nested cases, value-taking versus boolean metadata, context-specific short aliases, mutually exclusive diff modes, exact schema-to-renderer sets for Bash/zsh/fish/PowerShell, and the available-shell generated-script execution contract. - Issue #5187 exact graph-selector coverage belongs in `QueryCommandRunnerIssue5187Tests.cs` and `McpServerIssue5187Tests.cs`. Keep one fixture with unrelated same-name methods, distinct callees, and multiple callers. Round-trip selectors emitted by `inspect` through CLI and MCP references/callers/callees/impact; preserve explicit bare-name ambiguity metadata, exact selected zero behavior after filters, stable invalid/stale/cross-database diagnostics, selector-bound cursor mismatch, structured-field discovery, command help, and all four shell completions. - Issue #5157 C# primary-constructor property coverage belongs in `SymbolExtractorCSharpTests.cs` and `QueryCommandRunnerSymbolTests.cs`. Keep ordinary class/struct and nested/private attributed multiline parameters absent from property symbols while preserving positional `record`, `record class`, and `record struct` properties, including nullable and generic components. Exercise `symbols`, `definition`, `outline`, public-visibility filtering, and full/incremental indexing parity. When this extractor gate changes, keep `CSharpContractVersion` and the stale-index refresh assertions in `IndexCommandRunnerFullScanTests.cs` synchronized. - Issue #5156 file-impact projection coverage belongs in `QueryCommandRunnerImpactTests.cs` and `ProjectionFieldRegistryIssue4836Tests.cs`. Reuse one non-empty two-dependent fixture across full JSON, compact identity, both endpoint leaves, every supported `FileDependencyResult` leaf, typed rejection of the ambiguous `file_impacts.path` alias, cursor paging, and UTF-8 byte bounds. Keep the inactive zero-result collection assertion in `JsonEnvelopeWrapperIssue4585Tests.cs` on a real file-impact leaf. @@ -1158,6 +1159,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" ## テストスタック +- Issue #5194 の shell completion inventory coverage は `CliFlagSchemaTests.cs` と `ConsoleUiTests.cs` が担当します。lsp、workspace の全 verb、config show、diff、import、archive export、ctags export の正確な context 別 inventory を、authoritative help と安全な parser probe に同期してください。nested context の positive / negative case、value-taking と boolean の metadata、context 固有の short alias、diff の排他的 mode、Bash / zsh / fish / PowerShell における schema-to-renderer の完全一致、および利用可能な shell で生成 script を実行する契約を維持します。 - Issue #5187 の exact graph-selector coverage は `QueryCommandRunnerIssue5187Tests.cs` と `McpServerIssue5187Tests.cs` が担当します。無関係な同名 method、異なる callee、複数 caller を持つ 1 つの fixture を維持してください。`inspect` が出力した selector を CLI / MCP の references、callers、callees、impact へ round-trip し、bare-name ambiguity metadata の明示、filter 適用後も正確な selected zero behavior、安定した invalid / stale / cross-database diagnostic、selector に束縛された cursor mismatch、structured-field discovery、command help、4 種類すべての shell completion を保ちます。 - Issue #5157 の C# primary-constructor property coverage は `SymbolExtractorCSharpTests.cs` と `QueryCommandRunnerSymbolTests.cs` が担当します。通常の class / struct および attribute 付き multiline parameter を持つ nested / private type では property symbol を生成せず、nullable / generic component を含む位置 `record`、`record class`、`record struct` の property は維持してください。`symbols`、`definition`、`outline`、public visibility filter、full / incremental indexing の parity を検証します。この extractor gate を変更するときは、`CSharpContractVersion` と `IndexCommandRunnerFullScanTests.cs` の stale-index refresh assertion も同期してください。 - Issue #5156 の file-impact projection coverage は `QueryCommandRunnerImpactTests.cs` と `ProjectionFieldRegistryIssue4836Tests.cs` が担当します。full JSON、compact row identity、両 endpoint leaf、対応する全 `FileDependencyResult` leaf、曖昧な `file_impacts.path` alias の型付き拒否、cursor paging、UTF-8 byte 上限を、空でない 2 dependent の fixture 1 つで共有してください。`JsonEnvelopeWrapperIssue4585Tests.cs` の inactive な 0 件 collection assertion は、実在する file-impact leaf を指定したまま維持します。 diff --git a/changelog.d/unreleased/5194.fixed.md b/changelog.d/unreleased/5194.fixed.md new file mode 100644 index 000000000..1eebb2727 --- /dev/null +++ b/changelog.d/unreleased/5194.fixed.md @@ -0,0 +1,21 @@ +--- +category: fixed +issues: + - 5194 +affected: + - src/CodeIndex/Cli/CliCommandMetadata.cs + - src/CodeIndex/Cli/CliFlagSchema.cs + - src/CodeIndex/Cli/ConsoleCompletionRenderer.cs + - tests/CodeIndex.Tests/CliFlagSchemaTests.cs + - tests/CodeIndex.Tests/ConsoleUiTests.cs + - DEVELOPER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Generated shell completions now match dedicated parser and help inventories (#5194)** — Bash, zsh, fish, and PowerShell expose the accepted lsp, workspace, config show, diff, import, archive export, and ctags export options only in their valid parent or nested contexts. + +## 日本語 + +- **生成 shell completion が専用 parser と help の inventory に一致するようになりました (#5194)** — Bash、zsh、fish、PowerShell は、lsp、workspace、config show、diff、import、archive export、ctags export で受理される option を、有効な親または nested context にだけ提示します。 diff --git a/src/CodeIndex/Cli/CliCommandMetadata.cs b/src/CodeIndex/Cli/CliCommandMetadata.cs index 7db0343e4..14c6cbe3f 100644 --- a/src/CodeIndex/Cli/CliCommandMetadata.cs +++ b/src/CodeIndex/Cli/CliCommandMetadata.cs @@ -39,7 +39,7 @@ internal static class CliCommandMetadata ]); internal static IReadOnlySet OptionalSubcommandCommands { get; } = - new[] { "recipes", "suggestions" }.ToFrozenSet(StringComparer.Ordinal); + new[] { "recipes", "suggestions", "workspace", "export" }.ToFrozenSet(StringComparer.Ordinal); // These commands render process-static metadata and must not discover or parse // project configuration. validate-config owns malformed-config reporting so it diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 73c8f8459..ce0207d3e 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -86,6 +86,7 @@ internal sealed record CliFlag { public required string Name { get; init; } public string? ShortName { get; init; } + public IReadOnlySet? ShortNameCommands { get; init; } public string? ValuePlaceholder { get; init; } public CliOptionValueKind ValueKind { get; init; } = CliOptionValueKind.FreeText; public required string Description { get; init; } @@ -104,6 +105,14 @@ internal sealed record CliFlag public IReadOnlyList SupplementalCompletionValues { get; init; } = []; public IReadOnlyDictionary> CompletionSubcommands { get; init; } = new Dictionary>(StringComparer.Ordinal); + /// + /// Commands whose parent/default operation should keep this flag when + /// also narrows nested verbs. + /// で nested verb を絞る場合でも、このフラグを + /// parent / default operation に残す command 集合。 + /// + public IReadOnlySet ParentCompletionCommands { get; init; } = + new HashSet(StringComparer.Ordinal); /// /// Commands for which this flag is "primary" — accepted by the parser AND surfaced @@ -138,12 +147,20 @@ ValuePlaceholder is not null || CommandValuePlaceholders.Count > 0; public bool AppliesTo(string command) => PrimaryCommands.Contains(command); public bool IsAcceptedBy(string command) => AppliesTo(command) || AlsoAcceptedBy.Contains(command); + public string? GetShortName(string command) => + ShortName is not null + && (ShortNameCommands is null || ShortNameCommands.Contains(command)) + ? ShortName + : null; public bool AppliesToCompletionContext(string command, string? subcommand) { if (!AppliesTo(command)) return false; - return !CompletionSubcommands.TryGetValue(command, out var allowedSubcommands) - || subcommand is not null && allowedSubcommands.Contains(subcommand); + if (!CompletionSubcommands.TryGetValue(command, out var allowedSubcommands)) + return true; + return subcommand is null + ? ParentCompletionCommands.Contains(command) + : allowedSubcommands.Contains(subcommand); } public string GetDescription(string command) => @@ -233,13 +250,13 @@ public static bool HasAuthoritativeHelpOptions(string command) => private static readonly string[] PathFilterCommands = [ "search", "definition", "goto", "references", "callers", "callees", "symbols", "files", - "find", "map", "inspect", "deps", "impact", "unused", "hotspots", "validate", + "find", "map", "inspect", "deps", "impact", "unused", "hotspots", "validate", "export", ]; private static readonly string[] ExcludeFilterCommands = [ "search", "definition", "goto", "references", "callers", "callees", "symbols", "files", - "find", "map", "inspect", "validate", "deps", "impact", "unused", "hotspots", + "find", "map", "inspect", "validate", "deps", "impact", "unused", "hotspots", "export", ]; private static readonly string[] CountCommands = @@ -270,14 +287,14 @@ public static bool HasAuthoritativeHelpOptions(string command) => private static readonly string[] UnusedFilterCommands = ["unused"]; private static readonly string[] BoundedProjectionCommands = ProjectionFieldRegistry.SupportedCommands.ToArray(); - private static readonly string[] CursorCommands = ["search", "outline", "unused", "deps", "inspect", .. BoundedProjectionCommands]; + private static readonly string[] CursorCommands = ["search", "outline", "unused", "deps", "inspect", "diff", .. BoundedProjectionCommands]; private static readonly string[] AllResultCommands = ["goto", "find", "unused"]; private static readonly string[] SinceCommands = ["search", "definition", "symbols", "files", "suggestions"]; private static readonly string[] ByteFormatCommands = ["files", "map"]; private static readonly string[] EntrypointConfidenceCommands = ["map"]; private static readonly string[] MapSectionCommands = ["map"]; - private static readonly string[] SummaryOnlyCommands = ["map", "search", "recipes", "audit", "symbols", "files", "deps", "unused", "hotspots", "languages", "suggestions"]; + private static readonly string[] SummaryOnlyCommands = ["map", "search", "recipes", "audit", "symbols", "files", "deps", "unused", "hotspots", "languages", "suggestions", "diff"]; private static readonly string[] DependencyCycleCommands = ["deps"]; private static readonly string[] LanguagesFilterCommands = ["languages"]; @@ -320,6 +337,7 @@ public static bool HasAuthoritativeHelpOptions(string command) => "index", "backfill-fold", "optimize", "search", "definition", "goto", "references", "callers", "callees", "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", "validate", "deps", "impact", "unused", "hotspots", "suggestions", "languages", "db", "vacuum", "report", "batch", "mcp", + "lsp", "export", "import", ]; private static readonly string[] WorkspaceDbCommands = ["deps"]; @@ -343,6 +361,7 @@ public static bool HasAuthoritativeHelpOptions(string command) => "index", "backfill-fold", "optimize", "vacuum", "search", "recipes", "audit", "definition", "goto", "references", "callers", "callees", "symbols", "files", "find", "excerpt", "map", "inspect", "outline", "status", "validate", "validate-config", "deps", "impact", "unused", "hotspots", "suggestions", "languages", "db", "report", "upgrade", "doctor", "license", + "workspace", "config", "diff", "export", "import", ]; private static readonly string[] PrettyCommands = @@ -428,7 +447,16 @@ private static IReadOnlyList BuildAll() new() { Name = "--immutable", Description = "Alias for --read-only", PrimaryCommands = Set(ReadOnlyDbCommands), Safety = CliOptionSafety.ReadOnly }, new() { Name = "--workspace-db", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, Description = "Additional workspace member database path for dependency aggregation; repeat up to 7 distinct additional DBs", PrimaryCommands = Set(WorkspaceDbCommands) }, new() { Name = "--data-dir", ValuePlaceholder = "", ValueKind = CliOptionValueKind.DirectoryPath, Description = "Directory containing codeindex.db; overrides CDIDX_DATA_DIR/XDG/workspace defaults", PrimaryCommands = Set(DataDirCommands), Safety = CliOptionSafety.Scope }, - new() { Name = "--json", Description = "JSON output; search/symbols/files/validate also accept --json=array for a single JSON array", PrimaryCommands = Set(JsonCommands.Concat(["hooks"]).ToArray()) }, + new() + { + Name = "--json", + Description = "JSON output; search/symbols/files/validate also accept --json=array for a single JSON array", + PrimaryCommands = Set(JsonCommands.Concat(["hooks"]).ToArray()), + CompletionSubcommands = new Dictionary>(StringComparer.Ordinal) + { + ["config"] = Set("show"), + }, + }, new() { Name = "--json-summary", Description = "Batch: emit one typed result/error record per input plus a final summary", PrimaryCommands = Set("batch") }, new() { Name = "--include-raw-streams", Description = "Batch JSON-summary: attach bounded child stdout/stderr to failed records", PrimaryCommands = Set("batch") }, new() { Name = "--max-input-lines", ValuePlaceholder = "", Description = $"Batch: input-line budget (default {QueryCommandRunner.BatchDefaultInputLines}, max {QueryCommandRunner.BatchMaxInputLines})", PrimaryCommands = Set("batch") }, @@ -453,11 +481,11 @@ private static IReadOnlyList BuildAll() new() { Name = "--notify", ValueDomain = Values(["auto", "bell", "osc9", "desktop", "none"]), Description = "Signal long index completion; desktop currently emits OSC 9 terminal notification", PrimaryCommands = Set("index") }, new() { Name = "--slow-query-ms", ValuePlaceholder = "", Description = "Log profiled SQL statements at or above this millisecond threshold", PrimaryCommands = Set(ProfileCommands) }, new() { Name = "--trace", ValueDomain = Values(["none", "stderr", "file"]), Description = "Emit one structured JSON query trace line to stderr or a daily log file", PrimaryCommands = Set(TraceCommands), Safety = CliOptionSafety.Diagnostics }, - new() { Name = "--limit", ValuePlaceholder = "", Description = "Max results", PrimaryCommands = Set(LimitCapableCommands.Concat(new[] { "suggestions" }).ToArray()) }, + new() { Name = "--limit", ValuePlaceholder = "", Description = "Max results", PrimaryCommands = Set(LimitCapableCommands.Concat(new[] { "suggestions", "diff", "import" }).ToArray()) }, new() { Name = "--max-results", ValuePlaceholder = "", Description = "Search alias for --limit", PrimaryCommands = Set("search") }, new() { Name = "--top", ValuePlaceholder = "", Description = "Max results", PrimaryCommands = Set(LimitCapableCommands) }, - new() { Name = "--offset", ValuePlaceholder = "", Description = "Suggestions: skip this many filtered rows before output", PrimaryCommands = Set("suggestions") }, - new() { Name = "--lang", ValuePlaceholder = "", ValueKind = CliOptionValueKind.Language, Description = "Filter by a registered language, alias, or extension-like spelling", PrimaryCommands = Set(LangCapableCommands), AlsoAcceptedBy = Set("suggestions") }, + new() { Name = "--offset", ValuePlaceholder = "", Description = "Skip this many rows before paged diff, import preview, or suggestion output", PrimaryCommands = Set("suggestions", "diff", "import") }, + new() { Name = "--lang", ValuePlaceholder = "", ValueKind = CliOptionValueKind.Language, Description = "Filter by a registered language, alias, or extension-like spelling", PrimaryCommands = Set(LangCapableCommands.Concat(["export"]).ToArray()), AlsoAcceptedBy = Set("suggestions") }, new() { Name = "--allow-unknown-lang", Description = "Allow an unregistered plugin language ID and preserve its exact spelling", PrimaryCommands = Set(LangCapableCommands) }, new() { Name = "--language", ValuePlaceholder = "", ValueKind = CliOptionValueKind.Language, Description = "Suggestions: filter by language; languages: look up one language by canonical name or recognized language spelling", PrimaryCommands = Set("suggestions", "languages") }, new() { Name = "--extension", ValuePlaceholder = "", Description = "Languages: look up language support by extension or recognized filename pattern", PrimaryCommands = Set(LanguagesFilterCommands) }, @@ -474,6 +502,11 @@ private static IReadOnlyList BuildAll() ["hooks"] = "Repository/worktree directory used to resolve Git metadata for the managed hook", }, PrimaryCommands = Set(PathFilterCommands.Concat(new[] { "index", "hooks" }).ToArray()), + CompletionSubcommands = new Dictionary>(StringComparer.Ordinal) + { + ["export"] = Set(), + }, + ParentCompletionCommands = Set("export"), CommandValuePlaceholders = new Dictionary(StringComparer.Ordinal) { ["hooks"] = "", @@ -484,10 +517,32 @@ private static IReadOnlyList BuildAll() }, Safety = CliOptionSafety.Scope, }, - new() { Name = "--solution", ValuePlaceholder = "", ValueKind = CliOptionValueKind.FilePath, Description = "Solution file used to resolve --project", PrimaryCommands = Set(PathFilterCommands.Concat(new[] { "index" }).ToArray()), Safety = CliOptionSafety.Scope }, + new() + { + Name = "--solution", + ValuePlaceholder = "", + ValueKind = CliOptionValueKind.FilePath, + Description = "Solution file used to resolve --project", + PrimaryCommands = Set(PathFilterCommands.Concat(new[] { "index" }).ToArray()), + CompletionSubcommands = new Dictionary>(StringComparer.Ordinal) + { + ["export"] = Set(), + }, + ParentCompletionCommands = Set("export"), + Safety = CliOptionSafety.Scope, + }, new() { Name = "--exclude-path", ValuePlaceholder = "", ValueKind = CliOptionValueKind.PathPattern, Description = "Exclude path", PrimaryCommands = Set(ExcludeFilterCommands) }, new() { Name = "--exclude-tests", Description = "Exclude tests", PrimaryCommands = Set(ExcludeFilterCommands) }, - new() { Name = "--include-generated", Description = "Include generated files", PrimaryCommands = Set(ExcludeFilterCommands) }, + new() + { + Name = "--include-generated", + Description = "Include generated files; export supports it only for ctags", + PrimaryCommands = Set(ExcludeFilterCommands), + CompletionSubcommands = new Dictionary>(StringComparer.Ordinal) + { + ["export"] = Set("ctags"), + }, + }, new() { Name = "--generated", Description = "Files alias for --include-generated", PrimaryCommands = Set("files") }, new() { Name = "--kind", ValuePlaceholder = "", ValueKind = CliOptionValueKind.SymbolKind, Description = "Filter by kind", PrimaryCommands = Set(KindCommands) }, new() { Name = "--severity", ValueDomain = Values(["info", "warning", "error"]), Description = "Validate: filter validation issues by severity", PrimaryCommands = Set(SeverityCommands) }, @@ -521,6 +576,8 @@ private static IReadOnlyList BuildAll() PrimaryCommands = Set(MapSectionCommands), }, new() { Name = "--summary-only", Description = "Map/Diff/Recipes/Audit/Files/Symbols/Deps/Hotspots/Languages: return only aggregate summary fields where supported", PrimaryCommands = Set(SummaryOnlyCommands) }, + new() { Name = "--detailed", Description = "Diff: compare deterministic row-level records", PrimaryCommands = Set("diff") }, + new() { Name = "--include-content", Description = "Diff detailed JSON: include indexed content instead of redacted hashes", PrimaryCommands = Set("diff") }, new() { Name = "--data-only", Description = "Diff: include indexed data and schema in identity while excluding readiness/provenance and volatile telemetry", PrimaryCommands = Set("diff") }, new() { Name = "--include-telemetry", Description = "Diff: include volatile index-run and FTS maintenance telemetry in identity", PrimaryCommands = Set("diff") }, new() { Name = "--cycles", Description = "Deps: return deterministically ranked dependency SCCs with stable pagination", PrimaryCommands = Set(DependencyCycleCommands) }, @@ -590,9 +647,20 @@ private static IReadOnlyList BuildAll() new() { Name = "--description", ValuePlaceholder = "", Description = "Suggestions add: local suggestion description", PrimaryCommands = Set("suggestions") }, new() { Name = "--title", ValuePlaceholder = "", Description = "Suggestions add: optional issue-draft title source", PrimaryCommands = Set("suggestions") }, new() { Name = "--evidence-path", ValuePlaceholder = "<path>", ValueKind = CliOptionValueKind.FilePath, Description = "Suggestions add: repository-relative evidence path; repeat for multiple paths", PrimaryCommands = Set("suggestions") }, - new() { Name = "--overwrite", Description = "Portable archive, report bundle, or suggestions export: atomically replace an existing output file", PrimaryCommands = Set("export", "report", "suggestions") }, + new() + { + Name = "--overwrite", + Description = "Portable archive, report bundle, or suggestions export: atomically replace an existing output file", + PrimaryCommands = Set("export", "report", "suggestions"), + CompletionSubcommands = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal) + { + ["export"] = Set(), + }, + ParentCompletionCommands = Set("export"), + }, new() { Name = "--restore", ValuePlaceholder = "<id>", Description = "DB restore-backups: select a managed backup ID to validate and restore atomically", PrimaryCommands = Set("db") }, new() { Name = "--no-backup", Description = "Import/DB restore: explicitly skip creating managed rollback material before replacement", PrimaryCommands = Set("import", "db") }, + new() { Name = "--prune-paths", Description = "Import: remove indexed rows whose source paths are absent after replacement", PrimaryCommands = Set("import") }, new() { Name = "--body", Description = "Include definition snippets and graph call-site evidence in JSON-capable result rows", PrimaryCommands = Set(BodyCommands) }, new() { Name = "--body-start", ValuePlaceholder = "<line>", Description = "Inspect: start definition body slice at this 1-based source line", PrimaryCommands = Set(InspectFieldCommands) }, new() { Name = "--body-lines", ValuePlaceholder = "<n>", Description = "Inspect: return at most this many definition body lines", PrimaryCommands = Set(InspectFieldCommands) }, @@ -630,7 +698,7 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--env-domain", ValuePlaceholder = "<domain>", Description = "Doctor full environment inventory: filter by exact domain", PrimaryCommands = Set("doctor") }, new() { Name = "--env-category", ValuePlaceholder = "<category>", Description = "Doctor full environment inventory: filter by exact category", PrimaryCommands = Set("doctor") }, new() { Name = "--env-sensitivity", ValuePlaceholder = "<sensitivity>", Description = "Doctor full environment inventory: filter by exact sensitivity", PrimaryCommands = Set("doctor") }, - new() { Name = "--max-json-bytes", ValuePlaceholder = "<n>", Description = "Bound emitted JSON bytes; bounded responses omit whole rows with recovery metadata, including schema-valid audit SARIF", PrimaryCommands = Set("search", "definition", "find", "status", "references", "callers", "callees", "excerpt", "inspect", "outline", "impact", "recipes", "audit", "map", "files", "symbols", "deps", "hotspots", "languages", "unused", "doctor", "suggestions") }, + new() { Name = "--max-json-bytes", ValuePlaceholder = "<n>", Description = "Bound emitted JSON bytes; bounded responses omit whole rows with recovery metadata, including schema-valid audit SARIF", PrimaryCommands = Set("search", "definition", "find", "status", "references", "callers", "callees", "excerpt", "inspect", "outline", "impact", "recipes", "audit", "map", "files", "symbols", "deps", "hotspots", "languages", "unused", "doctor", "suggestions", "diff") }, new() { Name = "--next-steps", Description = "Search: print inspect/excerpt follow-up commands for top hits", PrimaryCommands = Set("search") }, new() { Name = "--exclude-comments", Description = "Search: suppress comment-only matches after origin classification", PrimaryCommands = Set("search") }, new() { Name = "--exclude-strings", Description = "Search: suppress string, regex, and help-text matches after origin classification", PrimaryCommands = Set("search") }, @@ -673,7 +741,16 @@ private static IReadOnlyList<CliFlag> BuildAll() }, }, new() { Name = "--group-by-name", Description = "Hotspots: collapse same-name rows; JSON keeps capped paths plus full definition details", PrimaryCommands = Set("hotspots") }, - new() { Name = "--check", Description = "Verify status freshness/readiness; doctor integrations fails on warning or error", PrimaryCommands = Set("status", "doctor") }, + new() + { + Name = "--check", + Description = "Verify status/workspace freshness, fail doctor integrations on diagnostics, or preview an import", + PrimaryCommands = Set("status", "doctor", "workspace", "import"), + CompletionSubcommands = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal) + { + ["workspace"] = Set("status"), + }, + }, new() { Name = "--config", Description = "Print effective configuration with source attribution", PrimaryCommands = Set("status") }, new() { Name = "--stale-after", ValuePlaceholder = "<duration>", Description = "Status: freshness age threshold (e.g. 30m, 2h, 7d)", PrimaryCommands = Set("status") }, new() { Name = "--explain", ValuePlaceholder = "<field>", Description = "Explain one visible status field", PrimaryCommands = Set("status") }, @@ -692,14 +769,23 @@ private static IReadOnlyList<CliFlag> BuildAll() { Name = "--dry-run", Description = "Preview without writing; hooks supports install and uninstall", - PrimaryCommands = Set("index", "hooks", "backfill-fold", "optimize", "vacuum"), + PrimaryCommands = Set("index", "hooks", "backfill-fold", "optimize", "vacuum", "import"), CompletionSubcommands = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal) { ["hooks"] = Set("install", "uninstall"), }, Safety = CliOptionSafety.Preview, }, - new() { Name = "--show-paths", Description = "Show resolved local paths in maintenance diagnostics, recovery commands, or status --config output", PrimaryCommands = Set(RecoveryPathCommands.Concat(["index", "backfill-fold", "optimize", "vacuum", "db", "status"]).ToArray()) }, + new() + { + Name = "--show-paths", + Description = "Show resolved local paths in maintenance diagnostics, recovery commands, status --config, or config show output", + PrimaryCommands = Set(RecoveryPathCommands.Concat(["index", "backfill-fold", "optimize", "vacuum", "db", "status", "config"]).ToArray()), + CompletionSubcommands = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal) + { + ["config"] = Set("show"), + }, + }, new() { Name = "--dry-run-path-limit", ValuePlaceholder = "<n>", Description = "Dry run only: candidate path processing limit (1..1000000); explicit invalid values fail before database setup", PrimaryCommands = Set("index") }, new() { Name = "--checkpoint", Description = "Create a DB checkpoint even when backfill preflight is already complete", PrimaryCommands = Set("backfill-fold") }, new() { Name = "--no-checkpoint", Description = "Skip the automatic DB checkpoint before a required backfill mutation", PrimaryCommands = Set("backfill-fold") }, @@ -730,7 +816,25 @@ private static IReadOnlyList<CliFlag> BuildAll() new() { Name = "--watch", Description = "Continuous reindex on file changes (rejects --commits / --changed-between / --files / --dry-run)", PrimaryCommands = Set("index") }, new() { Name = "--debounce", ValuePlaceholder = "<ms>", Description = "Watch only: coalesce file events after 0..60000 ms of quiet (default 500)", PrimaryCommands = Set("index") }, new() { Name = "--watch-pending-path-limit", ValuePlaceholder = "<n>", Description = "Watch only: changed-path queue limit (1..262144); invalid CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT warns and uses the documented effective value", PrimaryCommands = Set("index") }, - new() { Name = "--output", ShortName = "-o", ValuePlaceholder = "<path>", ValueKind = CliOptionValueKind.FilePath, Description = "Report bundle or suggestions export output path", PrimaryCommands = Set("report", "suggestions") }, + new() + { + Name = "--output", + ShortName = "-o", + ShortNameCommands = Set("report", "suggestions"), + ValuePlaceholder = "<path>", + ValueKind = CliOptionValueKind.FilePath, + Description = "Report bundle, suggestions export, or ctags output path", + CommandDescriptions = new Dictionary<string, string>(StringComparer.Ordinal) + { + ["report"] = "Report bundle or suggestions export output path", + ["suggestions"] = "Report bundle or suggestions export output path", + }, + PrimaryCommands = Set("report", "suggestions", "export"), + CompletionSubcommands = new Dictionary<string, IReadOnlySet<string>>(StringComparer.Ordinal) + { + ["export"] = Set("ctags"), + }, + }, new() { Name = "--redact-paths", Description = "Redact machine-specific paths (the default for recovery/config JSON and reports)", PrimaryCommands = Set(RecoveryPathCommands.Concat(["status", "report"]).ToArray()) }, new() { Name = "--no-log", Description = "Exclude global tool log from bundle", PrimaryCommands = Set("report") }, new() { Name = "--include-args", Description = "Include args in bundle log", PrimaryCommands = Set("report") }, diff --git a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs index 7fd07f54d..151490d09 100644 --- a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs +++ b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs @@ -164,24 +164,19 @@ private static string GetBashCompletions() sb.Append($" COMPREPLY=($(compgen -W \"{string.Join(' ', GetNestedSubcommandNames("hooks"))}\" -- \"$cur\"))\n"); sb.Append(" return\n"); sb.Append(" fi\n"); - for (var i = 0; i < EnumeratedCompletionCommands.Length; i++) + var wroteCompletionBranch = false; + foreach (var command in EnumeratedCompletionCommands) { - var command = EnumeratedCompletionCommands[i]; - var keyword = i == 0 ? "if" : "elif"; - if (command == "hooks") - { - sb.Append($" {keyword} [ \"$cmd\" = \"hooks\" ] && [ \"$nested\" = \"install\" ]; then\n"); - sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashFlagList("hooks", "install")}\" -- \"$cur\"))\n"); - sb.Append(" elif [ \"$cmd\" = \"hooks\" ] && [ \"$nested\" = \"uninstall\" ]; then\n"); - sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashFlagList("hooks", "uninstall")}\" -- \"$cur\"))\n"); - sb.Append(" elif [ \"$cmd\" = \"hooks\" ]; then\n"); - sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashFlagList("hooks", "status")}\" -- \"$cur\"))\n"); - } - else + foreach (var subcommand in GetCompletionContextSubcommands(command)) { - sb.Append($" {keyword} [ \"$cmd\" = \"{command}\" ]; then\n"); - sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashFlagList(command)}\" -- \"$cur\"))\n"); + sb.Append($" {(wroteCompletionBranch ? "elif" : "if")} [ \"$cmd\" = \"{command}\" ] && [ \"$nested\" = \"{subcommand}\" ]; then\n"); + sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashFlagList(command, subcommand)}\" -- \"$cur\"))\n"); + wroteCompletionBranch = true; } + + sb.Append($" {(wroteCompletionBranch ? "elif" : "if")} [ \"$cmd\" = \"{command}\" ]; then\n"); + sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashFlagList(command)}\" -- \"$cur\"))\n"); + wroteCompletionBranch = true; } sb.Append(" else\n"); sb.Append($" COMPREPLY=($(compgen -W \"{BuildBashGenericFlagList()}\" -- \"$cur\"))\n"); @@ -202,8 +197,8 @@ private static string BuildBashFlagList(string command, string? subcommand = nul foreach (var flag in CliFlagSchema.GetCompletionFlagsForCommand(command, subcommand)) { tokens.Add(flag.Name); - if (flag.ShortName is not null) - tokens.Add(flag.ShortName); + if (flag.GetShortName(command) is { } shortName) + tokens.Add(shortName); } tokens.Add("--help"); if (command == "find") @@ -226,8 +221,8 @@ private static string BuildBashGenericFlagList() if (seen.Add(flag.Name)) { tokens.Add(flag.Name); - if (flag.ShortName is not null) - tokens.Add(flag.ShortName); + if (flag.GetShortName(command) is { } shortName) + tokens.Add(shortName); } } } @@ -417,8 +412,8 @@ private static IEnumerable<string> FormatZshArguments( string? subcommand = null) { yield return FormatZshArgument(flag.Name, flag, langs, kinds, command, subcommand); - if (flag.ShortName is not null) - yield return FormatZshArgument(flag.ShortName, flag, langs, kinds, command, subcommand); + if (flag.GetShortName(command ?? string.Empty) is { } shortName) + yield return FormatZshArgument(shortName, flag, langs, kinds, command, subcommand); } private static string FormatZshArgument( @@ -541,7 +536,9 @@ private static string GetFishCompletions() foreach (var flag in CliFlagSchema.GetTopLevelCompletionFlags()) { var name = flag.Name.TrimStart('-'); - var shortName = flag.ShortName is null ? "" : $" -s {flag.ShortName.TrimStart('-')}"; + var shortName = flag.GetShortName(string.Empty) is not { } topLevelShortName + ? "" + : $" -s {topLevelShortName.TrimStart('-')}"; var requiresArg = flag.IsValueBearing ? " -r" : ""; var valueKind = flag.GetValueKind(string.Empty); var argSpec = valueKind switch @@ -603,6 +600,16 @@ CliOptionValueKind.Finite when GetEnumValues(flag) is { } values => $" -a '{stri { if (flag.CompletionSubcommands.TryGetValue(command, out var nestedSubcommands)) { + if (flag.ParentCompletionCommands.Contains(command)) + { + lines.Add(BuildFishFlagCompletion( + flag, + $"__fish_cdidx_using_context {command}", + command, + subcommand: null, + langs, + kinds)); + } foreach (var nestedSubcommand in nestedSubcommands.OrderBy(value => value, StringComparer.Ordinal)) { lines.Add(BuildFishFlagCompletion( @@ -664,7 +671,9 @@ private static string BuildFishFlagCompletion( { var name = flag.Name.TrimStart('-'); var requiresArg = flag.IsValueBearing ? " -r" : ""; - var shortName = flag.ShortName is null ? "" : $" -s {flag.ShortName.TrimStart('-')}"; + var shortName = flag.GetShortName(command) is not { } commandShortName + ? "" + : $" -s {commandShortName.TrimStart('-')}"; var description = name switch { "group-by-name" => "Collapse same-name rows across files", @@ -797,13 +806,18 @@ private static string GetPowerShellCompletions() sb.AppendLine(" return"); sb.AppendLine(" }"); sb.AppendLine(" }"); - sb.AppendLine(" if ($subcmd -eq 'hooks' -and $nested -eq 'install') {"); - sb.AppendLine($" $flags = @({FormatPowerShellArray(BuildPowerShellFlagList("hooks", "install"))})"); - sb.AppendLine(" } elseif ($subcmd -eq 'hooks' -and $nested -eq 'uninstall') {"); - sb.AppendLine($" $flags = @({FormatPowerShellArray(BuildPowerShellFlagList("hooks", "uninstall"))})"); - sb.AppendLine(" } elseif ($subcmd -eq 'hooks') {"); - sb.AppendLine($" $flags = @({FormatPowerShellArray(BuildPowerShellFlagList("hooks", "status"))})"); - sb.AppendLine(" } else {"); + var wroteNestedPowerShellBranch = false; + foreach (var command in EnumeratedCompletionCommands) + { + foreach (var subcommand in GetCompletionContextSubcommands(command)) + { + sb.AppendLine($" {(wroteNestedPowerShellBranch ? "} elseif" : "if")} ($subcmd -eq '{EscapePowerShellSingleQuoted(command)}' -and $nested -eq '{EscapePowerShellSingleQuoted(subcommand)}') {{"); + sb.AppendLine($" $flags = @({FormatPowerShellArray(BuildPowerShellFlagList(command, subcommand))})"); + wroteNestedPowerShellBranch = true; + } + } + if (wroteNestedPowerShellBranch) + sb.AppendLine(" } else {"); sb.AppendLine(" switch ($subcmd) {"); foreach (var command in EnumeratedCompletionCommands) { @@ -812,7 +826,8 @@ private static string GetPowerShellCompletions() } sb.AppendLine($" default {{ $flags = @({FormatPowerShellArray(BuildPowerShellGenericFlagList())}) }}"); sb.AppendLine(" }"); - sb.AppendLine(" }"); + if (wroteNestedPowerShellBranch) + sb.AppendLine(" }"); sb.AppendLine(" $flags | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ 'ParameterName' }"); sb.Append("}"); return sb.ToString(); @@ -824,8 +839,8 @@ private static List<string> BuildPowerShellFlagList(string command, string? subc foreach (var flag in CliFlagSchema.GetCompletionFlagsForCommand(command, subcommand)) { tokens.Add(flag.Name); - if (flag.ShortName is not null) - tokens.Add(flag.ShortName); + if (flag.GetShortName(command) is { } shortName) + tokens.Add(shortName); } tokens.Add("--help"); if (command == "find") @@ -839,8 +854,8 @@ private static IReadOnlyList<string> GetValueTakingFlagNamesForNestedCommand(str foreach (var flag in CliFlagSchema.GetHelpFlagsForCommand(command).Where(flag => flag.IsValueBearing)) { names.Add(flag.Name); - if (flag.ShortName is not null) - names.Add(flag.ShortName); + if (flag.GetShortName(command) is { } shortName) + names.Add(shortName); } return names; } @@ -894,8 +909,8 @@ private static List<string> BuildTopLevelFlagList() foreach (var flag in CliFlagSchema.GetTopLevelCompletionFlags()) { tokens.Add(flag.Name); - if (flag.ShortName is not null) - tokens.Add(flag.ShortName); + if (flag.GetShortName(string.Empty) is { } shortName) + tokens.Add(shortName); } return tokens; } @@ -913,8 +928,8 @@ private static List<string> BuildPowerShellGenericFlagList() if (seen.Add(flag.Name)) { tokens.Add(flag.Name); - if (flag.ShortName is not null) - tokens.Add(flag.ShortName); + if (flag.GetShortName(command) is { } shortName) + tokens.Add(shortName); } } } diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index eeb87acef..a9544fa9e 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -17,6 +17,9 @@ namespace CodeIndex.Tests; [Collection("Console sensitive")] public class CliFlagSchemaTests { + private static readonly IReadOnlySet<string> CompletionGlobalFlags = + new HashSet<string>(["--quiet", "--silent", "--no-progress"], StringComparer.Ordinal); + [Fact] public void AllCommands_MatchesCliCommandCatalog() { @@ -47,6 +50,20 @@ public void EveryFlagCommandsSet_OnlyReferencesKnownSubcommands() Assert.True(known.Contains(command), $"{flag.Name} AlsoAcceptedBy references unknown subcommand '{command}'"); foreach (var command in flag.CompletionSubcommands.Keys) Assert.True(known.Contains(command), $"{flag.Name} CompletionSubcommands references unknown command '{command}'"); + foreach (var command in flag.ParentCompletionCommands) + { + Assert.True(known.Contains(command), $"{flag.Name} ParentCompletionCommands references unknown command '{command}'"); + Assert.True(flag.CompletionSubcommands.ContainsKey(command), $"{flag.Name} keeps parent completion for '{command}' without a nested applicability entry"); + } + if (flag.ShortNameCommands is not null) + { + Assert.NotNull(flag.ShortName); + foreach (var command in flag.ShortNameCommands) + { + Assert.True(known.Contains(command), $"{flag.Name} ShortNameCommands references unknown command '{command}'"); + Assert.True(flag.PrimaryCommands.Contains(command), $"{flag.Name} exposes its short name for non-primary command '{command}'"); + } + } } } @@ -189,6 +206,179 @@ public void McpPublicOptionInventoryMatchesParserAndAuthoritativeHelp_Issue5096( Assert.Contains(option, helpOutput, StringComparison.Ordinal); } + [Fact] + public void DedicatedCommandOptionInventoriesMatchSchemaHelpAndParsers_Issue5194() + { + var expectedByContext = new Dictionary<(string Command, string? Nested), string[]> + { + [("lsp", null)] = ["--db"], + [("workspace", null)] = ["--json"], + [("workspace", "list")] = ["--json"], + [("workspace", "status")] = ["--json", "--check"], + [("workspace", "use")] = ["--json"], + [("workspace", "current")] = ["--json"], + [("workspace", "clear")] = ["--json"], + [("workspace", "deactivate")] = ["--json"], + [("config", null)] = [], + [("config", "show")] = ["--json", "--show-paths"], + [("diff", null)] = + [ + "--json", "--summary-only", "--detailed", "--data-only", "--include-telemetry", + "--include-content", "--max-json-bytes", "--limit", "--offset", "--cursor", + ], + [("import", null)] = + [ + "--db", "--prune-paths", "--no-backup", "--dry-run", "--check", "--limit", "--offset", "--json", + ], + [("export", null)] = + [ + "--db", "--json", "--overwrite", "--lang", "--path", "--exclude-path", "--project", "--solution", "--exclude-tests", + ], + [("export", "ctags")] = + [ + "--output", "--db", "--json", "--lang", "--path", "--exclude-path", "--exclude-tests", "--include-generated", + ], + }; + + foreach (var ((command, nested), expected) in expectedByContext) + { + var actual = CliFlagSchema.GetCompletionFlagsForCommand(command, nested) + .Select(flag => flag.Name) + .Where(name => !CompletionGlobalFlags.Contains(name)) + .ToHashSet(StringComparer.Ordinal); + AssertOptionSet(expected, actual, $"schema context {command}{(nested is null ? string.Empty : $" {nested}")}"); + } + + foreach (var commandGroup in expectedByContext.GroupBy(entry => entry.Key.Command, StringComparer.Ordinal)) + { + var expected = commandGroup.SelectMany(entry => entry.Value).ToHashSet(StringComparer.Ordinal); + var accepted = CliFlagSchema.GetAcceptedFlagNamesForCommand(commandGroup.Key) + .Where(name => !CompletionGlobalFlags.Contains(name)) + .ToHashSet(StringComparer.Ordinal); + AssertOptionSet(expected, accepted, $"accepted schema inventory for {commandGroup.Key}"); + + var (printed, help, error) = ConsoleCapture.Capture(() => + ConsoleUi.PrintCommandUsage(commandGroup.Key) ? 1 : 0); + Assert.Equal(1, printed); + Assert.Empty(error); + var documented = Regex.Matches(help, @"--[a-z][a-z0-9-]*") + .Select(match => match.Value) + .Where(name => name != "--help") + .ToHashSet(StringComparer.Ordinal); + AssertOptionSet(expected, documented, $"authoritative help inventory for {commandGroup.Key}"); + } + + var jsonOptions = ProgramRunner.CreateDefaultJsonOptions(); + var (_, _, lspError) = ConsoleCapture.Capture(() => + ProgramRunner.Run(["lsp", "--db"], appVersion: "1.10.0")); + Assert.DoesNotContain("--db is not supported for lsp", lspError, StringComparison.Ordinal); + + var (_, _, workspaceJsonError) = ConsoleCapture.Capture(() => + WorkspaceCommandRunner.Run(["list", "--json"], jsonOptions)); + Assert.Empty(workspaceJsonError); + var (_, _, workspaceCheckError) = ConsoleCapture.Capture(() => + WorkspaceCommandRunner.Run(["status", "--check"], jsonOptions)); + Assert.DoesNotContain("only valid", workspaceCheckError, StringComparison.OrdinalIgnoreCase); + var (invalidWorkspaceCheck, _, _) = ConsoleCapture.Capture(() => + WorkspaceCommandRunner.Run(["list", "--check"], jsonOptions)); + Assert.Equal(CommandExitCodes.UsageError, invalidWorkspaceCheck); + + var (configShowExitCode, _, configShowError) = ConsoleCapture.Capture(() => + CdidxConfigFile.RunShow(["--json", "--show-paths"], jsonOptions)); + Assert.Equal(CommandExitCodes.Success, configShowExitCode); + Assert.Empty(configShowError); + + foreach (var option in expectedByContext[("diff", null)]) + { + var parsed = DiffCommandOptionsParser.Parse(["left.db", "right.db", option], DiffCommandRunner.MaxDiffLimit); + Assert.DoesNotContain("does not support option", parsed.ParseError ?? string.Empty, StringComparison.Ordinal); + } + var conflictingDiffModes = DiffCommandOptionsParser.Parse( + ["left.db", "right.db", "--data-only", "--include-telemetry"], + DiffCommandRunner.MaxDiffLimit); + Assert.Contains("cannot be combined", conflictingDiffModes.ParseError, StringComparison.Ordinal); + var conflictingDiffPaging = DiffCommandOptionsParser.Parse( + ["left.db", "right.db", "--json", "--detailed", "--cursor", "cursor", "--offset", "0"], + DiffCommandRunner.MaxDiffLimit); + Assert.Contains("--cursor cannot be combined with --offset", conflictingDiffPaging.ParseError, StringComparison.Ordinal); + + foreach (var option in expectedByContext[("import", null)]) + { + var (_, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunImport([option], jsonOptions)); + Assert.DoesNotContain($"unknown import option `{option}`", stdout + stderr, StringComparison.Ordinal); + } + + foreach (var option in expectedByContext[("export", null)]) + { + var (_, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport([option], jsonOptions, "1.10.0")); + Assert.DoesNotContain($"unknown export option `{option}`", stdout + stderr, StringComparison.Ordinal); + } + + foreach (var option in expectedByContext[("export", "ctags")]) + { + var args = CliFlagSchema.GetFlag("export", option)!.IsValueBearing + ? new[] { "ctags", option } + : new[] { "ctags", option, "--issue5194-probe" }; + var (_, stdout, stderr) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport(args, jsonOptions, "1.10.0")); + Assert.DoesNotContain($"unknown ctags export option `{option}`", stdout + stderr, StringComparison.Ordinal); + } + + var output = CliFlagSchema.GetFlag("export", "--output"); + Assert.NotNull(output); + Assert.Null(output!.GetShortName("export")); + Assert.Equal("-o", output.GetShortName("report")); + Assert.Equal(CliOptionValueKind.FilePath, output.GetValueKind("export", "ctags")); + Assert.Equal(CliOptionValueKind.FilePath, CliFlagSchema.GetValueKindForCommand("lsp", "--db")); + Assert.Equal(CliOptionValueKind.Language, CliFlagSchema.GetValueKindForCommand("export", "--lang")); + Assert.False(CliFlagSchema.GetFlag("workspace", "--json")!.IsValueBearing); + + var (_, ctagsAliasOutput, ctagsAliasError) = ConsoleCapture.Capture(() => + ExportImportCommandRunner.RunExport(["ctags", "-o"], jsonOptions, "1.10.0")); + Assert.Contains("unknown ctags export option `-o`", ctagsAliasOutput + ctagsAliasError, StringComparison.Ordinal); + } + + [Fact] + public void DedicatedCommandContextsRenderExactlyAcrossAllShells_Issue5194() + { + (string Command, string? Nested)[] contexts = + [ + ("lsp", null), + ("workspace", null), + ("workspace", "list"), + ("workspace", "status"), + ("workspace", "use"), + ("workspace", "current"), + ("workspace", "clear"), + ("workspace", "deactivate"), + ("config", null), + ("config", "show"), + ("diff", null), + ("import", null), + ("export", null), + ("export", "ctags"), + ]; + + foreach (var shell in new[] { "bash", "zsh", "fish", "powershell" }) + { + var script = ConsoleCompletionRenderer.GetCompletionScript(shell); + foreach (var (command, nested) in contexts) + { + var expected = CliFlagSchema.GetCompletionFlagsForCommand(command, nested) + .Select(flag => flag.Name) + .Where(name => !CompletionGlobalFlags.Contains(name)); + var actual = ExtractContextLongFlags(script, shell, command, nested); + actual.ExceptWith(CompletionGlobalFlags); + AssertOptionSet( + expected, + actual, + $"{shell} completion context {command}{(nested is null ? string.Empty : $" {nested}")}"); + } + } + } + [Fact] public void Goto_AcceptsDocumentedExcludeFilters_Issue3934() { @@ -464,6 +654,8 @@ public void EveryFlagInFishCompletion_IsBackedBySchemaEntry() { if (flag.CompletionSubcommands.TryGetValue(command, out var nestedSubcommands)) { + if (flag.ParentCompletionCommands.Contains(command)) + expected.Add((name, command)); foreach (var nestedSubcommand in nestedSubcommands) expected.Add((name, $"{command}:{nestedSubcommand}")); } @@ -746,6 +938,123 @@ private static HashSet<string> GetProgramRunnerStringSet(string fieldName) return value!; } + private static void AssertOptionSet( + IEnumerable<string> expected, + IReadOnlySet<string> actual, + string label) + { + var expectedSet = expected.ToHashSet(StringComparer.Ordinal); + Assert.True( + expectedSet.SetEquals(actual), + $"{label} drifted. Missing: {string.Join(", ", expectedSet.Except(actual).OrderBy(value => value, StringComparer.Ordinal))}. " + + $"Unexpected: {string.Join(", ", actual.Except(expectedSet).OrderBy(value => value, StringComparer.Ordinal))}."); + } + + private static HashSet<string> ExtractContextLongFlags( + string script, + string shell, + string command, + string? nested) + { + var flags = shell switch + { + "bash" => ExtractBashContextLongFlags(script, command, nested), + "zsh" => ExtractZshContextLongFlags(script, command, nested), + "fish" => ExtractFishContextLongFlags(script, command, nested), + "powershell" => ExtractPowerShellContextLongFlags(script, command, nested), + _ => throw new ArgumentOutOfRangeException(nameof(shell), shell, "Unknown shell"), + }; + flags.Remove("--help"); + return flags; + } + + private static HashSet<string> ExtractBashContextLongFlags( + string script, + string command, + string? nested) + { + var condition = nested is null + ? @"\[\s*""\$cmd""\s*=\s*""" + Regex.Escape(command) + @"""\s*\]" + : @"\[\s*""\$cmd""\s*=\s*""" + Regex.Escape(command) + @"""\s*\]\s*&&\s*\[\s*""\$nested""\s*=\s*""" + Regex.Escape(nested) + @"""\s*\]"; + var match = Regex.Match( + script, + @"(?:if|elif)\s*" + condition + @"\s*;\s*then\s*\n\s*COMPREPLY=\(\$\(compgen\s+-W\s+""(?<flags>[^""]*)"""); + if (!match.Success && nested is not null) + return ExtractBashContextLongFlags(script, command, nested: null); + Assert.True(match.Success, $"bash completion branch not found for {command} {nested}"); + return Regex.Matches(match.Groups["flags"].Value, @"--[a-z][a-z0-9-]*") + .Select(flag => flag.Value) + .ToHashSet(StringComparer.Ordinal); + } + + private static HashSet<string> ExtractZshContextLongFlags( + string script, + string command, + string? nested) + { + var condition = @"\$subcmd\s*==\s*" + Regex.Escape(command); + if (nested is not null) + condition += @"\s*&&\s*\$nested\s*==\s*" + Regex.Escape(nested); + var match = Regex.Match( + script, + @"(?:if|elif)\s+\[\[\s*" + condition + @"\s*\]\];\s*then(?<branch>.*?)(?=\n\s*(?:elif|else|fi)\b)", + RegexOptions.Singleline); + if (!match.Success && nested is not null) + return ExtractZshContextLongFlags(script, command, nested: null); + Assert.True(match.Success, $"zsh completion branch not found for {command} {nested}"); + return Regex.Matches(match.Groups["branch"].Value, @"'(?<flag>--[a-z][a-z0-9-]*)\[") + .Select(flag => flag.Groups["flag"].Value) + .ToHashSet(StringComparer.Ordinal); + } + + private static HashSet<string> ExtractFishContextLongFlags( + string script, + string command, + string? nested) + { + var result = new HashSet<string>(StringComparer.Ordinal); + var exactContext = nested is null + ? $"__fish_cdidx_using_context {command}'" + : $"__fish_cdidx_using_context {command} {nested}'"; + var commandPattern = new Regex(@"__fish_cdidx_using_command\s+(?<list>[^;']+)"); + foreach (var line in script.Split('\n')) + { + var flag = Regex.Match(line, @"\s-l\s+(?<name>[a-z][a-z0-9-]*)\b"); + if (!flag.Success) + continue; + if (line.Contains(exactContext, StringComparison.Ordinal)) + { + result.Add("--" + flag.Groups["name"].Value); + continue; + } + + var commandMatch = commandPattern.Match(line); + if (!commandMatch.Success) + continue; + var commands = commandMatch.Groups["list"].Value.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (Array.IndexOf(commands, command) >= 0) + result.Add("--" + flag.Groups["name"].Value); + } + return result; + } + + private static HashSet<string> ExtractPowerShellContextLongFlags( + string script, + string command, + string? nested) + { + var pattern = nested is null + ? @"'" + Regex.Escape(command) + @"'\s*\{\s*\$flags\s*=\s*@\((?<flags>[^)]*)\)\s*\}" + : @"(?:if|}\s*elseif)\s*\(\$subcmd\s*-eq\s*'" + Regex.Escape(command) + @"'\s*-and\s*\$nested\s*-eq\s*'" + Regex.Escape(nested) + @"'\)\s*\{\s*\$flags\s*=\s*@\((?<flags>[^)]*)\)"; + var match = Regex.Match(script, pattern, RegexOptions.Singleline | RegexOptions.IgnoreCase); + if (!match.Success && nested is not null) + return ExtractPowerShellContextLongFlags(script, command, nested: null); + Assert.True(match.Success, $"PowerShell completion branch not found for {command} {nested}"); + return Regex.Matches(match.Groups["flags"].Value, @"--[a-z][a-z0-9-]*") + .Select(flag => flag.Value) + .ToHashSet(StringComparer.Ordinal); + } + private static SortedSet<string> ExtractBashSubcommandFlags(string script, string subcommand) { // Each per-command branch looks like: @@ -768,15 +1077,17 @@ private static SortedSet<string> ExtractBashSubcommandFlags(string script, strin { var result = new HashSet<(string, string)>(); var commandPattern = new Regex(@"__fish_cdidx_using_command\s+(?<list>[^;']+)[^']*'(?<rest>.+?)-l\s+(?<flag>[a-z][a-z0-9-]*)\b"); - var contextPattern = new Regex(@"__fish_cdidx_using_context\s+(?<command>[^\s']+)\s+(?<nested>[^\s']+)'(?<rest>.+?)-l\s+(?<flag>[a-z][a-z0-9-]*)\b"); + var contextPattern = new Regex(@"__fish_cdidx_using_context\s+(?<command>[^\s']+)(?:\s+(?<nested>[^\s']+))?'(?<rest>.+?)-l\s+(?<flag>[a-z][a-z0-9-]*)\b"); foreach (var line in script.Split('\n')) { var contextMatch = contextPattern.Match(line); if (contextMatch.Success) { + var command = contextMatch.Groups["command"].Value; + var nested = contextMatch.Groups["nested"].Value; result.Add(( contextMatch.Groups["flag"].Value, - $"{contextMatch.Groups["command"].Value}:{contextMatch.Groups["nested"].Value}")); + nested.Length == 0 ? command : $"{command}:{nested}")); } var commandMatch = commandPattern.Match(line); diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 8b6674a82..c61a0a7c5 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -66,7 +66,7 @@ public void CompletionRenderer_EnumValuesMatchAcrossShells_Issue4426() [Fact] public void CompletionRenderer_UtilityCommandsExcludeQueryFlags_Issue4427() { - foreach (var command in new[] { "completions", "license", "export", "import", "batch", "mcp", "lsp" }) + foreach (var command in new[] { "completions", "license", "import", "batch", "mcp", "lsp" }) { var expected = CliFlagSchema.GetCompletionFlagsForCommand(command).Select(flag => flag.Name).ToHashSet(StringComparer.Ordinal); Assert.DoesNotContain("--body", expected); @@ -74,6 +74,14 @@ public void CompletionRenderer_UtilityCommandsExcludeQueryFlags_Issue4427() Assert.DoesNotContain("--kind", expected); Assert.DoesNotContain("--lang", expected); } + + var export = CliFlagSchema.GetCompletionFlagsForCommand("export") + .Select(flag => flag.Name) + .ToHashSet(StringComparer.Ordinal); + Assert.Contains("--lang", export); + Assert.DoesNotContain("--body", export); + Assert.DoesNotContain("--exact", export); + Assert.DoesNotContain("--kind", export); } [Fact] @@ -1220,11 +1228,11 @@ public void CompletionRenderer_LeadingGlobalsNestedValuesAndValidateConfigStayCo Assert.Contains("case '--color' '--palette' '--metrics' '--log-format' '--log-retain-count' '--log-max-size-mb'", fish, StringComparison.Ordinal); Assert.Contains("__fish_cdidx_using_context suggestions update' -l status -r -a 'draft open_in_upstream resolved_in_upstream wont_fix duplicate superseded'", fish, StringComparison.Ordinal); Assert.Contains("__fish_cdidx_using_command suggestions; and not __fish_cdidx_using_context suggestions update' -l status -r -a 'all draft submitted_pending_triage", fish, StringComparison.Ordinal); - var fishJson = fish.Split('\n').Single(line => Regex.IsMatch(line, @"\s-l json(?:\s|$)")); + var fishJson = fish.Split('\n').Where(line => Regex.IsMatch(line, @"\s-l json(?:\s|$)")).ToArray(); var fishPretty = fish.Split('\n').Single(line => line.Contains("__fish_cdidx_using_command", StringComparison.Ordinal) && Regex.IsMatch(line, @"\s-l pretty(?:\s|$)")); - Assert.Contains("validate-config", fishJson, StringComparison.Ordinal); + Assert.Contains(fishJson, line => line.Contains("validate-config", StringComparison.Ordinal)); Assert.DoesNotContain("validate-config", fishPretty, StringComparison.Ordinal); var powershell = ConsoleCompletionRenderer.GetCompletionScript("powershell"); @@ -1329,7 +1337,7 @@ public void CompletionRenderer_IncludesNestedCommandFamilies_Issue4347() var fish = ConsoleCompletionRenderer.GetCompletionScript("fish"); var powershell = ConsoleCompletionRenderer.GetCompletionScript("powershell"); - foreach (var expected in new[] { "hooks) COMPREPLY=($(compgen -W \"install uninstall status\"", "workspace) COMPREPLY=($(compgen -W \"list status use current clear deactivate\"", "config) COMPREPLY=($(compgen -W \"show\"", "db) COMPREPLY=($(compgen -W \"integrity schema prune checkpoint checkpoints restore restore-backups\"" }) + foreach (var expected in new[] { "hooks) COMPREPLY=($(compgen -W \"install uninstall status\"", "workspace) COMPREPLY=($(compgen -W \"list status use current clear deactivate", "config) COMPREPLY=($(compgen -W \"show\"", "db) COMPREPLY=($(compgen -W \"integrity schema prune checkpoint checkpoints restore restore-backups\"" }) Assert.Contains(expected, bash); foreach (var expected in new[] { "'install:install subcommand'", "'list:list subcommand'", "'clear:clear subcommand'", "'deactivate:deactivate subcommand'", "'show:show subcommand'", "'schema:schema subcommand'", "'prune:prune subcommand'" }) @@ -1898,8 +1906,13 @@ private static SortedSet<string> ExtractFishSubcommandFlags(string script, strin { var flags = new SortedSet<string>(StringComparer.Ordinal); var pattern = new Regex($@"__fish_cdidx_using_command\s+(?<list>[^;']+)[^']*'\s+-l\s+(?<flag>[a-z][a-z0-9-]*)\b"); + var parentContextPattern = new Regex($@"__fish_cdidx_using_context\s+{Regex.Escape(subcommand)}'\s+-l\s+(?<flag>[a-z][a-z0-9-]*)\b"); foreach (var line in script.Split('\n')) { + var parentContextMatch = parentContextPattern.Match(line); + if (parentContextMatch.Success) + flags.Add(parentContextMatch.Groups["flag"].Value); + var match = pattern.Match(line); if (!match.Success) continue; From 817f9db898aabba3432a5667a38ea6145ec79ccf Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Thu, 27 Aug 2026 20:21:09 +0900 Subject: [PATCH 2/3] Preserve PowerShell hooks status completion (#5194) --- src/CodeIndex/Cli/ConsoleCompletionRenderer.cs | 11 +++++++++++ tests/CodeIndex.Tests/CliFlagSchemaTests.cs | 16 ++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs index 151490d09..c205c30d9 100644 --- a/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs +++ b/src/CodeIndex/Cli/ConsoleCompletionRenderer.cs @@ -816,6 +816,17 @@ private static string GetPowerShellCompletions() wroteNestedPowerShellBranch = true; } } + if (wroteNestedPowerShellBranch) + { + // hooks/status shares the parent schema inventory, but hooks is intentionally + // excluded from the generic command switch below. Preserve its exact fallback + // after emitting the schema-restricted install/uninstall branches. + // hooks/status は親 schema の inventory を共有する一方、下の汎用 command switch + // から hooks 自体は意図的に除外される。schema 制約付きの install/uninstall branch + // の後に、status 用の正確な fallback を維持する。 + sb.AppendLine(" } elseif ($subcmd -eq 'hooks' -and $nested -eq 'status') {"); + sb.AppendLine($" $flags = @({FormatPowerShellArray(BuildPowerShellFlagList("hooks", "status"))})"); + } if (wroteNestedPowerShellBranch) sb.AppendLine(" } else {"); sb.AppendLine(" switch ($subcmd) {"); diff --git a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs index a9544fa9e..721897371 100644 --- a/tests/CodeIndex.Tests/CliFlagSchemaTests.cs +++ b/tests/CodeIndex.Tests/CliFlagSchemaTests.cs @@ -379,6 +379,22 @@ public void DedicatedCommandContextsRenderExactlyAcrossAllShells_Issue5194() } } + [Fact] + public void PowerShellHooksStatusRetainsItsExactFallback_Issue5194() + { + var expected = CliFlagSchema.GetCompletionFlagsForCommand("hooks", "status") + .Select(flag => flag.Name) + .Where(name => !CompletionGlobalFlags.Contains(name)); + var actual = ExtractPowerShellContextLongFlags( + ConsoleCompletionRenderer.GetCompletionScript("powershell"), + "hooks", + "status"); + actual.Remove("--help"); + actual.ExceptWith(CompletionGlobalFlags); + + AssertOptionSet(expected, actual, "PowerShell completion context hooks status"); + } + [Fact] public void Goto_AcceptsDocumentedExcludeFilters_Issue3934() { From 6269f53a444774ced9f4003a3331de5438c4ce51 Mon Sep 17 00:00:00 2001 From: Widthdom <widthdom@gmail.com> Date: Thu, 27 Aug 2026 20:39:43 +0900 Subject: [PATCH 3/3] Clarify command-scoped output alias (#5194) --- src/CodeIndex/Cli/ConsoleUi.Help.cs | 9 ++++++++- tests/CodeIndex.Tests/ConsoleUiTests.cs | 2 ++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/ConsoleUi.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs index 8c9feda87..561425b3a 100644 --- a/src/CodeIndex/Cli/ConsoleUi.Help.cs +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -199,7 +199,14 @@ private static string FormatSharedFlagToken(CliFlag flag) var placeholder = flag.CommandValueDomains.Count > 0 ? "<command-specific-value>" : flag.GetValuePlaceholder(string.Empty); - return placeholder is null ? names : $"{names} {placeholder}"; + if (flag.ShortName is null || flag.ShortNameCommands is null) + return placeholder is null ? names : $"{names} {placeholder}"; + + var token = placeholder is null ? flag.Name : $"{flag.Name} {placeholder}"; + var aliasCommands = string.Join( + '/', + flag.ShortNameCommands.OrderBy(command => command, StringComparer.Ordinal)); + return $"{token} ({flag.ShortName}: {aliasCommands} only)"; } private static string FormatRelatedOptionTokens(string command, params string[] flagNames) => diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index c61a0a7c5..ce41e6767 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -2108,6 +2108,8 @@ public void PrintFlagUsage_ShowsFlagsWithoutCommands() Assert.Contains("--log-retain-count <n>", output); Assert.Contains("--log-max-size-mb <n>", output); Assert.Contains("--limit <n>, --top <n>", output); + Assert.Contains("--output <path> (-o: report/suggestions only)", output); + Assert.DoesNotContain("--output, -o <path>", output); Assert.DoesNotContain("Commands:", output); Assert.DoesNotContain("Examples:", output); }