Skip to content
23 changes: 23 additions & 0 deletions changelog.d/unreleased/1582.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
category: changed
issues:
- 1582
affected:
- src/CodeIndex/Cli/ConsoleUi.cs
- src/CodeIndex/Cli/IndexCommandRunner.cs
- src/CodeIndex/Cli/QueryCommandRunner.cs
- src/CodeIndex/Mcp/McpServer.cs
- src/CodeIndex/Mcp/McpToolHandlers.cs
- tests/CodeIndex.Tests/ConsoleUiTests.cs
- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
- tests/CodeIndex.Tests/QueryCommandRunnerTests.cs
- tests/CodeIndex.Tests/McpServerTests.cs
---

## English

- **"Did you mean" suggestions now cover flags and enum-style values, not just subcommands (#1582)** — the Damerau-Levenshtein suggester previously used only by the subcommand router is now exposed as a generic `ConsoleUi.FindClosestMatch` / `FindClosestMatches` helper and applied to (a) unknown long flags on `index`, `backfill-fold`, `find`, and every `search` / graph query family path that already reports "is not supported" — both the separated `--paht src/**` and inline `--paht=src/**` forms surface the same suggestion, (b) `--lang` values that do not match the indexed language set (with a fallback to `ReferenceExtractor.GetSupportedLanguages()` so typos like `--lang csarp` still suggest `--lang csharp` against an empty/fresh index), and (c) `--kind` and graph reference-kind values, including `validate --kind` typos which are now matched against the full file-issue-kind allowlist instead of only kinds present in the index. A typo like `cdidx index --rebild` now prints `Did you mean: --rebuild?` to stderr in addition to the existing warning, `cdidx search foo --lang csarp` now prints `Did you mean: --lang csharp?`, and `cdidx validate --kind replacement_chra` now prints `Did you mean: --kind replacement_char?` instead of silently treating the typo as a clean repo. The same length-aware threshold (`<=4 chars: 1`, `<=10: 2`, else `3`) is reused, so off-by-many tokens like `--zzzzzzzz` still print no suggestion. MCP responses for unknown enumerated values (currently `suggest_improvement`'s `category` argument) now also carry a structured `result.data.similar_values: [...]` array next to the human-readable "Did you mean: ..." text so MCP clients can offer one-tap recovery without parsing the message string.

## 日本語

- **「もしかして」提案を flag / enum 値にも拡張 (#1582)** — これまでサブコマンド誤入力時にしか出ていなかった Damerau-Levenshtein サジェスタを `ConsoleUi.FindClosestMatch` / `FindClosestMatches` として汎用化し、(a) `index` / `backfill-fold` / `find` および `search` / グラフ系コマンドで既に "is not supported" を出している未知の long flag (separated 形式 `--paht src/**` と inline 形式 `--paht=src/**` の両方で同じ提案を出す)、(b) インデックス済み言語集合と一致しない `--lang` 値 (空の index や未マッチのときは `ReferenceExtractor.GetSupportedLanguages()` にフォールバックし `--lang csarp` でも `--lang csharp` を提案)、(c) `--kind` およびグラフ参照 kind 値 (`validate --kind` も index 内に存在する kind だけでなく file-issue-kind 全体の許可リストと突き合わせる) に対しても "Did you mean: ..." を stderr に追加で出力するようにしました。例えば `cdidx index --rebild` は警告に続いて `Did you mean: --rebuild?` を、`cdidx search foo --lang csarp` は `Did you mean: --lang csharp?` を、`cdidx validate --kind replacement_chra` は typo をクリーンな状態と誤認せず `Did you mean: --kind replacement_char?` を表示します。長さに応じた閾値 (`<=4 chars: 1`、`<=10: 2`、それ以外 `3`) はそのまま流用しているため、`--zzzzzzzz` のように離れすぎたトークンには引き続き提案を出しません。MCP の未知 enum 値レスポンス (現状 `suggest_improvement` の `category` 引数) も、人間向けの "Did you mean: ..." テキストに加えて `result.data.similar_values: [...]` を構造化して返すようになり、MCP クライアントはメッセージ文字列を解析せずに 1-tap で復旧候補を提示できます。
63 changes: 56 additions & 7 deletions src/CodeIndex/Cli/ConsoleUi.cs
Original file line number Diff line number Diff line change
Expand Up @@ -576,29 +576,78 @@ public static void PrintLicenseSummary()
/// Short commands use a stricter threshold to avoid unrelated suggestions.
/// Damerau-Levenshtein距離で最も近いコマンド名を返す。短いコマンドは無関係な推薦を避けるため閾値を厳しくする。
/// </summary>
public static string? FindClosestCommand(string input)
public static string? FindClosestCommand(string input) =>
FindClosestMatch(input, Commands);

/// <summary>
/// Find the closest match for <paramref name="input"/> from <paramref name="candidates"/>
/// using Damerau-Levenshtein distance with the same length-aware threshold the
/// command suggester uses (#1582). Comparison is case-insensitive. Returns the original
/// (cased) candidate string, or <c>null</c> when no candidate is within the threshold.
/// 任意の候補集合に対して Damerau-Levenshtein 距離で最も近い候補を返す (#1582)。
/// 短い入力には厳しめの距離閾値を適用し、無関係な推薦を避ける。比較は case-insensitive。
/// </summary>
public static string? FindClosestMatch(string? input, IEnumerable<string> candidates)
{
if (string.IsNullOrWhiteSpace(input))
return null;

input = input.ToLowerInvariant();
var normalized = input.ToLowerInvariant();
string? best = null;
var bestDist = int.MaxValue;
foreach (var cmd in Commands)
foreach (var candidate in candidates)
{
var dist = DamerauLevenshteinDistance(input, cmd);
if (dist > GetSuggestionDistanceThreshold(input.Length, cmd.Length))
if (string.IsNullOrEmpty(candidate))
continue;
var candidateNormalized = candidate.ToLowerInvariant();
if (string.Equals(normalized, candidateNormalized, StringComparison.Ordinal))
return candidate;
var dist = DamerauLevenshteinDistance(normalized, candidateNormalized);
if (dist > GetSuggestionDistanceThreshold(normalized.Length, candidateNormalized.Length))
continue;

if (dist < bestDist)
{
bestDist = dist;
best = cmd;
best = candidate;
}
}
return best;
}

/// <summary>
/// Return up to <paramref name="maxResults"/> closest candidates for <paramref name="input"/>,
/// ordered by Damerau-Levenshtein distance. Useful for structured suggestions in MCP
/// error payloads (#1582). Returns an empty list when no candidate is within the threshold.
/// Damerau-Levenshtein 距離で近い候補を最大 <paramref name="maxResults"/> 件まで返す。
/// MCP の structured error payload で `similar_values` を返す用途を想定する (#1582)。
/// </summary>
public static IReadOnlyList<string> FindClosestMatches(string? input, IEnumerable<string> candidates, int maxResults = 3)
{
if (string.IsNullOrWhiteSpace(input) || maxResults <= 0)
return Array.Empty<string>();

var normalized = input.ToLowerInvariant();
var matches = new List<(string Candidate, int Distance)>();
foreach (var candidate in candidates)
{
if (string.IsNullOrEmpty(candidate))
continue;
var candidateNormalized = candidate.ToLowerInvariant();
if (string.Equals(normalized, candidateNormalized, StringComparison.Ordinal))
continue;
var dist = DamerauLevenshteinDistance(normalized, candidateNormalized);
if (dist > GetSuggestionDistanceThreshold(normalized.Length, candidateNormalized.Length))
continue;
matches.Add((candidate, dist));
}
return matches
.OrderBy(m => m.Distance)
.ThenBy(m => m.Candidate, StringComparer.Ordinal)
.Select(m => m.Candidate)
.Take(maxResults)
.ToList();
}

private static int GetSuggestionDistanceThreshold(int inputLength, int commandLength)
{
var shorter = Math.Min(inputLength, commandLength);
Expand Down
45 changes: 45 additions & 0 deletions src/CodeIndex/Cli/IndexCommandRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,45 @@ public static int RunBackfillFold(string[] cmdArgs, JsonSerializerOptions jsonOp
}


// Index-mode flag names recognized by `ParseArgs`. Kept in sync with the switch above
// so `Warning: unknown option ...` can suggest the closest accepted flag (#1582). Easter-egg
// and random-spinner flags are excluded since they are intentionally undiscoverable.
// `ParseArgs` の switch と同期した index 系の受理フラグ一覧。`unknown option` 警告で
// 最も近い受理フラグを did-you-mean 提案するのに用いる (#1582)。
// easter egg や random-spinner は意図的に未公開なので除外する。
private static readonly string[] AcceptedIndexFlags =
[
"--db", "--rebuild", "--verbose", "--json", "--dry-run", "--force",
"--watch", "--debounce", "--commits", "--files", "--help",
];

private static readonly string[] AcceptedBackfillFoldFlags =
[
"--db", "--json", "--help",
];

private static void WriteUnknownIndexOptionSuggestion(string token)
{
var name = TrimInlineValue(token);
var suggestion = ConsoleUi.FindClosestMatch(name, AcceptedIndexFlags);
if (suggestion != null)
Console.Error.WriteLine($"Did you mean: {suggestion}?");
}

private static void WriteUnknownBackfillFoldOptionSuggestion(string token)
{
var name = TrimInlineValue(token);
var suggestion = ConsoleUi.FindClosestMatch(name, AcceptedBackfillFoldFlags);
if (suggestion != null)
Console.Error.WriteLine($"Did you mean: {suggestion}?");
}

private static string TrimInlineValue(string token)
{
var eq = token.IndexOf('=');
return eq < 0 ? token : token[..eq];
}

public static IndexCommandOptions ParseArgs(string[] args)
{
string? projectPath = null;
Expand Down Expand Up @@ -565,7 +604,10 @@ public static IndexCommandOptions ParseArgs(string[] args)
break;
default:
if (args[i].StartsWith('-'))
{
Console.Error.WriteLine($"Warning: unknown option '{args[i]}' (ignored) / 不明なオプション '{args[i]}'(無視されます)");
WriteUnknownIndexOptionSuggestion(args[i]);
}
else
projectPath = args[i];
break;
Expand Down Expand Up @@ -680,7 +722,10 @@ private static BackfillFoldCommandOptions ParseBackfillFoldArgs(string[] args)
return new BackfillFoldCommandOptions { ShowHelp = true, DbPath = dbPath, Json = json };
default:
if (args[i].StartsWith('-'))
{
Console.Error.WriteLine($"Warning: unknown option '{args[i]}' (ignored) / 不明なオプション '{args[i]}'(無視されます)");
WriteUnknownBackfillFoldOptionSuggestion(args[i]);
}
else
return new BackfillFoldCommandOptions
{
Expand Down
Loading
Loading