Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions changelog.d/unreleased/3226.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
category: fixed
issues:
- 3226
affected:
- src/CodeIndex/Cli/CliFlagSchema.cs
- src/CodeIndex/Cli/ConsoleUi.cs
- src/CodeIndex/Cli/ProgramRunner.cs
- tests/CodeIndex.Tests/CliFlagSchemaTests.cs
- tests/CodeIndex.Tests/ConsoleUiTests.cs
---

## English

- **Top-level global log flags are now covered by the CLI schema and completion contracts (#3226)** - `--log-format`, `--log-retain-count`, and `--log-max-size-mb` are covered by the shared top-level flag schema, documented in help, and surfaced in shell completions, with tests guarding parser drift.

## 日本語

- **トップレベルの global log flags を CLI schema と completion contract で検証するようになりました (#3226)** - `--log-format`、`--log-retain-count`、`--log-max-size-mb` は共有 top-level flag schema で管理し、help に記載して shell completion に出すようになり、parser との drift をテストで検出します。
60 changes: 56 additions & 4 deletions src/CodeIndex/Cli/CliFlagSchema.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ internal sealed record CliFlag
/// </summary>
public required IReadOnlySet<string> Commands { get; init; }

/// <summary>
/// Whether this flag is accepted before a subcommand and should be surfaced in
/// top-level completion/help contracts.
/// サブコマンド前に受理され、トップレベル補完 / help 契約にも出すフラグかどうか。
/// </summary>
public bool TopLevel { get; init; }

/// <summary>
/// Commands for which the parser accepts the flag (typically to emit a friendlier
/// error like "use --exact-substring on search instead of --exact-name") but for
Expand Down Expand Up @@ -217,11 +224,20 @@ private static IReadOnlyList<CliFlag> BuildAll()
new() { Name = "--workspace-db", ValuePlaceholder = "<path>", Description = "Additional workspace member database path for dependency aggregation", Commands = Set(WorkspaceDbCommands) },
new() { Name = "--data-dir", ValuePlaceholder = "<dir>", Description = "Directory containing codeindex.db; overrides CDIDX_DATA_DIR/XDG/workspace defaults", Commands = Set(DataDirCommands) },
new() { Name = "--json", Description = "JSON output; search/files/validate also accept --json=array for a single JSON array", Commands = Set(JsonCommands) },
new() { Name = "--pretty", Description = "Pretty-print JSON output with indentation", Commands = Set(JsonCommands) },
new() { Name = "--pretty", Description = "Pretty-print JSON output with indentation", Commands = Set(JsonCommands), TopLevel = true },
new() { Name = "--compact", Description = "AI-oriented compact JSON with capped list sections and truncation metadata", Commands = Set(CompactJsonCommands) },
new() { Name = "--format", ValuePlaceholder = "<text|json|count|compact|csv|tsv|lsp|qf|sarif|issue-drafts>", Description = "Standard output format for token budgets, editor integrations, and CI; search recipes also accept issue-drafts", Commands = Set(FormatCommands) },
new() { Name = "--quiet", ShortName = "-q", Description = "Suppress informational stderr output; errors still print", Commands = Set(AllCommands.ToArray()) },
new() { Name = "--silent", Description = "Alias for --quiet", Commands = Set(AllCommands.ToArray()) },
new() { Name = "--quiet", ShortName = "-q", Description = "Suppress informational stderr output; errors still print", Commands = Set(AllCommands.ToArray()), TopLevel = true },
new() { Name = "--silent", Description = "Alias for --quiet", Commands = Set(AllCommands.ToArray()), TopLevel = true },
new() { Name = "--color", ValuePlaceholder = "<auto|always|never>", Description = "Color output mode", Commands = Set(), TopLevel = true },
new() { Name = "--palette", ValuePlaceholder = "<basic|256|truecolor>", Description = "ANSI color palette", Commands = Set(), TopLevel = true },
new() { Name = "--ascii", Description = "Use ASCII progress glyphs", Commands = Set(), TopLevel = true },
new() { Name = "--metrics", ValuePlaceholder = "<path>", Description = "Append command metrics JSONL to a file", Commands = Set(), TopLevel = true },
new() { Name = "--debug-unsafe", Description = "Allow raw debug dumps when CDIDX_DEBUG=unsafe is also set", Commands = Set(), TopLevel = true },
new() { Name = "--strict-version", Description = "Fail when the workspace version pin does not match this binary", Commands = Set(), TopLevel = true },
new() { Name = "--log-format", ValuePlaceholder = "<text|json>", Description = "Persistent stderr log format", Commands = Set(), TopLevel = true },
new() { Name = "--log-retain-count", ValuePlaceholder = "<n>", Description = "Persistent stderr log file retention count", Commands = Set(), TopLevel = true },
new() { Name = "--log-max-size-mb", ValuePlaceholder = "<n>", Description = "Persistent stderr log rotation size cap in MiB", Commands = Set(), TopLevel = true },
new() { Name = "--profile", Description = "Emit SQL timing and EXPLAIN QUERY PLAN profile JSON after the normal result", Commands = Set(ProfileCommands) },
new() { Name = "--verbose", Description = "Emit query debug diagnostics to stderr, or _debug JSON when combined with --json", Commands = Set(VerboseQueryCommands.Concat(new[] { "index" }).ToArray()) },
new() { Name = "--notify", ValuePlaceholder = "<auto|bell|osc9|desktop|none>", Description = "Signal long index completion; desktop currently emits OSC 9 terminal notification", Commands = Set("index") },
Expand Down Expand Up @@ -273,7 +289,7 @@ private static IReadOnlyList<CliFlag> BuildAll()
new() { Name = "--reject-before", ValuePlaceholder = "<query>", Description = "Search: reject primary matches with a nearby guard query before them", Commands = Set("search") },
new() { Name = "--reject-after", ValuePlaceholder = "<query>", Description = "Search: reject primary matches with a nearby guard query after them", Commands = Set("search") },
new() { Name = "--guard-window", ValuePlaceholder = "<n>", Description = "Search: line window for require/reject guard queries", Commands = Set("search") },
new() { Name = "--no-progress", Description = "Disable animated progress and spinner output", Commands = Set(AllCommands.ToArray()) },
new() { Name = "--no-progress", Description = "Disable animated progress and spinner output", Commands = Set(AllCommands.ToArray()), TopLevel = true },
new() { Name = "--name", ValuePlaceholder = "<name>", Description = "Exact symbol name", Commands = Set("symbols") },
new() { Name = "--max-line-width", ValuePlaceholder = "<n>", Description = "Clamp long single-line payloads (0 disables clamping)", Commands = Set(MaxLineWidthCommands) },
new() { Name = "--snippet-lines", ValuePlaceholder = "<n>", Description = "Snippet length", Commands = Set("search", "find", "references", "callers", "callees", "impact") },
Expand Down Expand Up @@ -359,6 +375,42 @@ public static IReadOnlyList<CliFlag> GetCompletionFlagsForCommand(string command
return All.Where(f => f.AppliesTo(command)).ToList();
}

/// <summary>
/// Flags accepted before a subcommand and surfaced in top-level shell completion.
/// サブコマンド前に受理され、トップレベル補完に出すフラグ集合。
/// </summary>
public static IReadOnlyList<CliFlag> GetTopLevelCompletionFlags()
{
return All.Where(f => f.TopLevel).ToList();
}

public static HashSet<string> GetTopLevelGlobalOptionNames(bool includeLogOptions)
{
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var flag in All)
{
if (!flag.TopLevel)
continue;
if (!includeLogOptions && flag.Name.StartsWith("--log-", StringComparison.Ordinal))
continue;
names.Add(flag.Name);
if (flag.ShortName is not null)
names.Add(flag.ShortName);
}
return names;
}

public static HashSet<string> GetTopLevelValueOptionNames()
{
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var flag in All)
{
if (flag is { TopLevel: true, IsValueBearing: true })
names.Add(flag.Name);
}
return names;
}

/// <summary>
/// Same allowlist as <see cref="GetAcceptedFlagNamesForCommand"/> but partitioned
/// into value-bearing options vs flag-only options, for parsers that need to know
Expand Down
70 changes: 66 additions & 4 deletions src/CodeIndex/Cli/ConsoleUi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -955,6 +955,11 @@ private static void PrintFlagReference(Action<string> WriteHelpLine)
WriteHelpLine(" --ascii Use ASCII spinner/progress glyphs instead of Unicode glyphs (also honors CDIDX_ASCII=1, NO_UNICODE, TERM=dumb, accessibility env hints, and non-UTF-8 locales)");
WriteHelpLine(" --no-progress Disable animated progress/spinner output (also honors CDIDX_DISABLE_PROGRESS=1 and PREFERS_REDUCED_MOTION)");
Console.WriteLine(" --metrics <path> Append one JSONL record per CLI command / MCP tool call to <path> (also honors CDIDX_METRICS=<path>)");
Console.WriteLine(" --log-format <text|json> Persistent stderr log format (also honors CDIDX_LOG_FORMAT)");
Console.WriteLine(" --log-retain-count <n> Persistent stderr log file retention count (also honors CDIDX_LOG_RETAIN)");
Console.WriteLine(" --log-max-size-mb <n> Persistent stderr log rotation size cap in MiB (also honors CDIDX_LOG_MAX_SIZE_MB)");
WriteHelpLine(" --debug-unsafe Allow raw debug dumps only when CDIDX_DEBUG=unsafe is also set; local troubleshooting only");
WriteHelpLine(" --strict-version Treat workspace version pin mismatches as exit code 64 instead of warnings");
Console.WriteLine(" --help, -h Show this help message");
Console.WriteLine(" --version, -V Show version information");
Console.WriteLine(" --license Show licensing, trademark, and commercial-use summary");
Expand Down Expand Up @@ -1404,6 +1409,7 @@ private static string GetCompletionKinds() =>
private static string GetBashCompletions()
{
var cmds = string.Join(" ", Commands);
var topLevelFlags = string.Join(" ", BuildTopLevelFlagList());
var langs = GetCompletionLangs();
var kinds = GetCompletionKinds();
var version = LoadVersion();
Expand All @@ -1419,12 +1425,15 @@ private static string GetBashCompletions()
sb.Append($" commands=\"{cmds}\"\n");
sb.Append("\n");
sb.Append(" if [ $COMP_CWORD -eq 1 ]; then\n");
sb.Append(" COMPREPLY=($(compgen -W \"$commands --help --version --license\" -- \"$cur\"))\n");
sb.Append($" COMPREPLY=($(compgen -W \"$commands --help --version --license {topLevelFlags}\" -- \"$cur\"))\n");
sb.Append(" return\n");
sb.Append(" fi\n");
sb.Append("\n");
sb.Append(" case \"$prev\" in\n");
sb.Append(" --db|--path|--exclude-path|--output|-o) COMPREPLY=($(compgen -f -- \"$cur\")) ;;\n");
sb.Append(" --db|--path|--exclude-path|--output|-o|--metrics) COMPREPLY=($(compgen -f -- \"$cur\")) ;;\n");
sb.Append(" --color) COMPREPLY=($(compgen -W \"auto always never\" -- \"$cur\")) ;;\n");
sb.Append(" --palette) COMPREPLY=($(compgen -W \"basic 256 truecolor\" -- \"$cur\")) ;;\n");
sb.Append(" --log-format) COMPREPLY=($(compgen -W \"text json\" -- \"$cur\")) ;;\n");
sb.Append($" --lang) COMPREPLY=($(compgen -W \"{langs}\" -- \"$cur\")) ;;\n");
sb.Append($" --kind) COMPREPLY=($(compgen -W \"{kinds}\" -- \"$cur\")) ;;\n");
sb.Append(" *)\n");
Expand Down Expand Up @@ -1510,6 +1519,8 @@ private static string GetZshCompletions()
sb.Append(" )\n");
sb.Append("\n");
sb.Append(" _arguments -C \\\n");
foreach (var arg in BuildZshTopLevelArgs(langs, kinds))
sb.Append($" {arg} \\\n");
sb.Append(" '1:command:->cmds' \\\n");
sb.Append(" '*::arg:->args'\n");
sb.Append("\n");
Expand Down Expand Up @@ -1556,6 +1567,17 @@ private static List<string> BuildZshArgsForCommand(string command, string langs,
return args;
}

private static List<string> BuildZshTopLevelArgs(string langs, string kinds)
{
var args = new List<string>();
foreach (var flag in CliFlagSchema.GetTopLevelCompletionFlags())
args.AddRange(FormatZshArguments(flag, langs, kinds));
args.Add("'--help[Show help]'");
args.Add("'--version[Show version]'");
args.Add("'--license[Show license summary]'");
return args;
}

private static List<string> BuildZshGenericArgs(string langs, string kinds)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
Expand Down Expand Up @@ -1597,6 +1619,9 @@ private static string FormatZshArgument(string name, CliFlag flag, string langs,
"<datetime>" => "datetime",
"<lang>" => $"language:({langs})",
"<kind>" => $"kind:({kinds})",
"<auto|always|never>" => "mode:(auto always never)",
"<basic|256|truecolor>" => "palette:(basic 256 truecolor)",
"<text|json>" => "format:(text json)",
"<query>" => "query",
"<name>" => "name",
"<host:port>" => "address",
Expand Down Expand Up @@ -1631,6 +1656,21 @@ private static string GetFishCompletions()
lines.Add("complete -c cdidx -n '__fish_use_subcommand' -l help -d 'Show help'");
lines.Add("complete -c cdidx -n '__fish_use_subcommand' -l version -d 'Show version'");
lines.Add("complete -c cdidx -n '__fish_use_subcommand' -l license -d 'Show license summary'");
foreach (var flag in CliFlagSchema.GetTopLevelCompletionFlags())
{
var name = flag.Name.TrimStart('-');
var shortName = flag.ShortName is null ? "" : $" -s {flag.ShortName.TrimStart('-')}";
var requiresArg = flag.IsValueBearing ? " -r" : "";
var argSpec = flag.ValuePlaceholder switch
{
"<auto|always|never>" => " -a 'auto always never'",
"<basic|256|truecolor>" => " -a 'basic 256 truecolor'",
"<text|json>" => " -a 'text json'",
_ => "",
};
var description = flag.Description.Replace("'", "\\'");
lines.Add($"complete -c cdidx -n '__fish_use_subcommand' -l {name}{shortName}{requiresArg}{argSpec} -d '{description}'");
}

// Emit one `complete` line per schema flag, joining the applicable command list into the
// fish `__fish_seen_subcommand_from` predicate. Hotspots' `--group-by-name` description is
Expand All @@ -1640,6 +1680,8 @@ private static string GetFishCompletions()
// という対応で生成する。`--group-by-name` のみ既存テストが期待する短い tooltip を維持。
foreach (var flag in CliFlagSchema.All)
{
if (flag.Commands.Count == 0)
continue;
var commands = string.Join(' ', flag.Commands.OrderBy(c => Array.IndexOf(Commands, c)));
var name = flag.Name.TrimStart('-');
// Token order is `-l name (-r)? (-a 'values')? -d 'description'` — matches the
Expand Down Expand Up @@ -1672,6 +1714,7 @@ private static string GetPowerShellCompletions()
var cmds = FormatPowerShellArray(Commands);
var langs = FormatPowerShellArray(GetCompletionLangs().Split(' ', StringSplitOptions.RemoveEmptyEntries));
var kinds = FormatPowerShellArray(GetCompletionKinds().Split(' ', StringSplitOptions.RemoveEmptyEntries));
var topLevelFlags = FormatPowerShellArray(BuildTopLevelFlagList());
var sb = new StringBuilder();
sb.AppendLine($"# cdidx PowerShell completions generated for version {LoadVersion()}");
sb.AppendLine("# Regenerate this script after upgrading cdidx.");
Expand All @@ -1680,6 +1723,10 @@ private static string GetPowerShellCompletions()
sb.AppendLine($" $commands = @({cmds})");
sb.AppendLine($" $langs = @({langs})");
sb.AppendLine($" $kinds = @({kinds})");
sb.AppendLine(" $colorModes = @('auto', 'always', 'never')");
sb.AppendLine(" $palettes = @('basic', '256', 'truecolor')");
sb.AppendLine(" $logFormats = @('text', 'json')");
sb.AppendLine($" $topLevelFlags = @({topLevelFlags})");
sb.AppendLine(" $elements = @($commandAst.CommandElements)");
sb.AppendLine(" $tokens = @($elements | ForEach-Object { $_.Extent.Text })");
sb.AppendLine(" $lastElement = if ($elements.Count -ge 1) { $elements[$elements.Count - 1] } else { $null }");
Expand All @@ -1690,15 +1737,18 @@ private static string GetPowerShellCompletions()
sb.AppendLine(" [System.Management.Automation.CompletionResult]::new($value, $value, $kind, $value)");
sb.AppendLine(" }");
sb.AppendLine(" switch ($prev) {");
sb.AppendLine(" { $_ -in @('--db', '--path', '--exclude-path', '--output', '-o') } {");
sb.AppendLine(" { $_ -in @('--db', '--path', '--exclude-path', '--output', '-o', '--metrics') } {");
sb.AppendLine(" Get-ChildItem -Name \"$wordToComplete*\" -ErrorAction SilentlyContinue | ForEach-Object { New-CdidxCompletion $_ 'ProviderItem' }");
sb.AppendLine(" return");
sb.AppendLine(" }");
sb.AppendLine(" '--color' { $colorModes | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }");
sb.AppendLine(" '--palette' { $palettes | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }");
sb.AppendLine(" '--log-format' { $logFormats | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }");
sb.AppendLine(" '--lang' { $langs | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }");
sb.AppendLine(" '--kind' { $kinds | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ }; return }");
sb.AppendLine(" }");
sb.AppendLine(" if (-not $subcmd -or ($tokens.Count -le 2 -and -not ([string]::IsNullOrEmpty($wordToComplete)) -and -not $afterLastToken)) {");
sb.AppendLine(" $commands + @('--help', '--version', '--license') | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ 'ParameterName' }");
sb.AppendLine(" $commands + @('--help', '--version', '--license') + $topLevelFlags | Where-Object { $_.StartsWith($wordToComplete, [System.StringComparison]::OrdinalIgnoreCase) } | ForEach-Object { New-CdidxCompletion $_ 'ParameterName' }");
sb.AppendLine(" return");
sb.AppendLine(" }");
sb.AppendLine(" switch ($subcmd) {");
Expand Down Expand Up @@ -1726,6 +1776,18 @@ private static List<string> BuildPowerShellFlagList(string command)
return tokens;
}

private static List<string> BuildTopLevelFlagList()
{
var tokens = new List<string>();
foreach (var flag in CliFlagSchema.GetTopLevelCompletionFlags())
{
tokens.Add(flag.Name);
if (flag.ShortName is not null)
tokens.Add(flag.ShortName);
}
return tokens;
}

private static List<string> BuildPowerShellGenericFlagList()
{
var seen = new HashSet<string>(StringComparer.Ordinal);
Expand Down
Loading
Loading