diff --git a/changelog.d/unreleased/1582.changed.md b/changelog.d/unreleased/1582.changed.md
new file mode 100644
index 0000000000..6c1f9a26e6
--- /dev/null
+++ b/changelog.d/unreleased/1582.changed.md
@@ -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 で復旧候補を提示できます。
diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs
index f439f372d7..6541ebd317 100644
--- a/src/CodeIndex/Cli/ConsoleUi.cs
+++ b/src/CodeIndex/Cli/ConsoleUi.cs
@@ -576,29 +576,78 @@ public static void PrintLicenseSummary()
/// Short commands use a stricter threshold to avoid unrelated suggestions.
/// Damerau-Levenshtein距離で最も近いコマンド名を返す。短いコマンドは無関係な推薦を避けるため閾値を厳しくする。
///
- public static string? FindClosestCommand(string input)
+ public static string? FindClosestCommand(string input) =>
+ FindClosestMatch(input, Commands);
+
+ ///
+ /// Find the closest match for from
+ /// 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 null when no candidate is within the threshold.
+ /// 任意の候補集合に対して Damerau-Levenshtein 距離で最も近い候補を返す (#1582)。
+ /// 短い入力には厳しめの距離閾値を適用し、無関係な推薦を避ける。比較は case-insensitive。
+ ///
+ public static string? FindClosestMatch(string? input, IEnumerable 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;
}
+ ///
+ /// Return up to closest candidates for ,
+ /// 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 距離で近い候補を最大 件まで返す。
+ /// MCP の structured error payload で `similar_values` を返す用途を想定する (#1582)。
+ ///
+ public static IReadOnlyList FindClosestMatches(string? input, IEnumerable candidates, int maxResults = 3)
+ {
+ if (string.IsNullOrWhiteSpace(input) || maxResults <= 0)
+ return Array.Empty();
+
+ 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);
diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs
index 59feffe6a2..d8919ed979 100644
--- a/src/CodeIndex/Cli/IndexCommandRunner.cs
+++ b/src/CodeIndex/Cli/IndexCommandRunner.cs
@@ -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;
@@ -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;
@@ -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
{
diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs
index a5dd333283..0db4202484 100644
--- a/src/CodeIndex/Cli/QueryCommandRunner.cs
+++ b/src/CodeIndex/Cli/QueryCommandRunner.cs
@@ -147,6 +147,7 @@ public static int RunSearch(string[] cmdArgs, JsonSerializerOptions jsonOptions)
else if (!options.Json)
{
Console.Error.WriteLine("No results found.");
+ WriteLangHint(options.Lang, reader);
WriteZeroResultHints(options, reader);
}
return CommandExitCodes.NotFound;
@@ -1228,7 +1229,25 @@ public static int RunFind(string[] cmdArgs, JsonSerializerOptions jsonOptions)
continue;
if (rawArg.StartsWith('-'))
- return $"Error: unsupported option for find: {rawArg}";
+ {
+ var error = $"Error: unsupported option for find: {rawArg}";
+ // Suggest the closest accepted find flag for typos like `--paht` → `--path`
+ // (#1582). Strip any inline `=value` portion before matching, since the prefix
+ // might not have been a recognized value-taking option (TrySplitInlineOptionValue
+ // only splits on known options).
+ // `--paht` → `--path` のようなタイプミスから回復させるため、find が受理する
+ // フラグの中で最も近いものを提案する (#1582)。`--foo=bar` 形では prefix が未知
+ // value-taking option の場合 TrySplitInlineOptionValue が分解しないので、
+ // suggester 用に `=` 前の部分を独自に切り出して照合する。
+ var nameForSuggestion = arg;
+ var eq = nameForSuggestion.IndexOf('=');
+ if (eq > 0)
+ nameForSuggestion = nameForSuggestion[..eq];
+ var suggestion = ConsoleUi.FindClosestMatch(nameForSuggestion, allowedWithValues.Concat(allowedFlags).Where(o => o != "--"));
+ if (suggestion != null)
+ error += $"\nDid you mean: {suggestion}?";
+ return error;
+ }
queryCount++;
if (queryCount > 1)
@@ -2615,6 +2634,16 @@ private static string BuildUnusedJsonPayload(IEnumerable res
_ => bucket,
};
+ // Issue kinds emitted by FileIndexer.ValidateFileContent for `validate --kind` filtering.
+ // Keep in sync with `Kind = "..."` assignments in FileIndexer.cs so typos like
+ // `--kind replacement_chra` produce a did-you-mean hint instead of silently filtering
+ // to zero results (#1582).
+ // FileIndexer.ValidateFileContent が出力する file_issues 行の Kind 一覧。
+ // `--kind replacement_chra` のようなタイプミスを did-you-mean で救うため、
+ // FileIndexer.cs 内の `Kind = "..."` 代入と同期させる (#1582)。
+ private static readonly string[] AllValidValidateKinds =
+ ["bom", "cr_only_line_endings", "line_too_long", "mixed_line_endings", "mixed_line_endings_three_way", "non_utf8_likely", "null_byte", "replacement_char", "utf16_bom"];
+
public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOptions)
{
var previewOptionError = ValidatePreviewOptions("validate", cmdArgs, allowMaxLineWidth: false, allowFocusOptions: false);
@@ -2648,7 +2677,10 @@ public static int RunValidate(string[] cmdArgs, JsonSerializerOptions jsonOption
else if (!issuesAvailable)
Console.Error.WriteLine("WARN: file_issues table missing in this index (legacy or read-only DB) — validate output is degraded, not a real clean signal.");
else
+ {
Console.Error.WriteLine("No encoding issues found.");
+ WriteValidateKindHint(options.Kind);
+ }
return CommandExitCodes.Success;
}
@@ -3366,6 +3398,26 @@ private static bool TryWriteUnsupportedOptionError(string commandName, string[]
i++;
Console.Error.WriteLine($"Error: {arg} is not supported for {commandName}.");
+ // Suggest the closest accepted flag for this command when the user mistypes
+ // a flag name (e.g. `--paht` → `--path`). Built on the same suggester used for
+ // subcommand typos so the recovery experience is consistent (#1582).
+ // TrySplitInlineOptionValue only splits inline `=value` when the prefix is a
+ // known value-taking option, so for an unrecognized `--paht=foo` the normalized
+ // arg keeps the `=value`. Strip any trailing `=value` here so the matcher can
+ // still find `--path` from `--paht=foo`.
+ // ユーザーがフラグ名をミスタイプしたとき (例: `--paht` → `--path`) に
+ // そのコマンドで受理される最も近いフラグを提案する。サブコマンドの did-you-mean と
+ // 同じ suggester を共用し、回復体験を統一する (#1582)。
+ // TrySplitInlineOptionValue は prefix が既知の value-taking option のときだけ
+ // inline `=value` を分解するため、`--paht=foo` のように未知のオプションでは
+ // `=value` が残る。matcher のために `=` 以降を除去してから候補を探す。
+ var nameForSuggestion = normalizedArg;
+ var eq = nameForSuggestion.IndexOf('=');
+ if (eq > 0)
+ nameForSuggestion = nameForSuggestion[..eq];
+ var suggestion = ConsoleUi.FindClosestMatch(nameForSuggestion, supported.Where(o => o != "--"));
+ if (suggestion != null)
+ Console.Error.WriteLine($"Did you mean: {suggestion}?");
Console.Error.WriteLine($"Hint: remove `{arg}` and rerun, or use only the options shown in `{commandName} --help`.");
Console.Error.WriteLine($"Usage: {GetUsageLineOrThrow(commandName)}");
return true;
@@ -3831,8 +3883,33 @@ private static void WriteLangHint(string? lang, DbReader reader)
{
if (lang == null) return;
var status = reader.GetStatus();
- if (status.Languages.Count > 0 && !status.Languages.ContainsKey(lang))
+ if (status.Languages.Count > 0 && status.Languages.ContainsKey(lang))
+ return;
+
+ if (status.Languages.Count > 0)
Console.Error.WriteLine($"Hint: '{lang}' not found in index. Available: {string.Join(", ", status.Languages.Keys.OrderBy(l => l))}");
+
+ // Recover from `--lang pythno` / `--lang csarp` typos by suggesting the
+ // closest indexed language first; if the typo does not match anything currently
+ // in the DB (or the DB has no languages yet) fall back to the full supported set
+ // exposed by `ReferenceExtractor.GetSupportedLanguages()` so the suggester is still
+ // useful against an empty/fresh index (#1582).
+ // `--lang pythno` / `--lang csarp` のようなタイプミスから回復させるため、
+ // インデックスに存在する言語の中から最も近いものを優先的に提案する。
+ // インデックスに無い、もしくは languages が空の場合は
+ // `ReferenceExtractor.GetSupportedLanguages()` 全体から候補を探し、
+ // 空のインデックスでも did-you-mean が機能するようにする (#1582)。
+ // Skip the suggestion entirely if the closest candidate is the exact value the user
+ // already supplied (case-insensitive). FindClosestMatch returns the input verbatim when
+ // it is a member of the candidate set — e.g. `--lang java` against a Java-supported but
+ // unindexed repo would otherwise self-suggest "Did you mean: --lang java?".
+ // 提案候補がユーザー指定値そのものと一致する場合は提案を出さない。
+ // FindClosestMatch は候補集合に同名がいれば入力をそのまま返すため、例えば Java は
+ // サポート対象だが index 済みでない場合の `--lang java` で自己提案を出してしまう。
+ var suggestion = ConsoleUi.FindClosestMatch(lang, status.Languages.Keys)
+ ?? ConsoleUi.FindClosestMatch(lang, ReferenceExtractor.GetSupportedLanguages());
+ if (suggestion != null && !string.Equals(suggestion, lang, StringComparison.OrdinalIgnoreCase))
+ Console.Error.WriteLine($"Did you mean: --lang {suggestion}?");
}
// All valid symbol kinds emitted by SymbolExtractor / SymbolExtractor が出力する全有効シンボル種別
@@ -3860,6 +3937,9 @@ private static void WriteKindHint(string? kind, DbReader reader)
if (!AllValidKinds.Contains(kind))
{
Console.Error.WriteLine($"Hint: '{kind}' is not a known kind. Available: {string.Join(", ", AllValidKinds)}");
+ var suggestion = ConsoleUi.FindClosestMatch(kind, AllValidKinds);
+ if (suggestion != null)
+ Console.Error.WriteLine($"Did you mean: --kind {suggestion}?");
return;
}
// Kind is valid but not found in this index — hint that no symbols of this kind exist
@@ -3869,6 +3949,25 @@ private static void WriteKindHint(string? kind, DbReader reader)
Console.Error.WriteLine($"Hint: no '{kind}' symbols in the index. Indexed kinds: {string.Join(", ", existingKinds)}");
}
+ private static void WriteValidateKindHint(string? kind)
+ {
+ if (string.IsNullOrEmpty(kind)) return;
+ if (AllValidValidateKinds.Contains(kind, StringComparer.Ordinal))
+ return;
+
+ // `validate --kind` accepts only the file-issue kinds emitted by FileIndexer. A typo
+ // like `--kind replacement_chra` filters to zero rows, which previously printed the
+ // same "No encoding issues found." message as a genuinely clean repo and silently
+ // hid the typo. Surface a hint + suggester for the closest known kind (#1582).
+ // `validate --kind` は FileIndexer が出す file_issues kind のみ受理する。
+ // `--kind replacement_chra` のようなタイプミスは 0 行となり、クリーンな状態と区別が
+ // つかないまま暗黙に握り潰されていた。ヒントと did-you-mean を出すよう改修 (#1582)。
+ Console.Error.WriteLine($"Hint: '{kind}' is not a known validate kind. Available: {string.Join(", ", AllValidValidateKinds)}");
+ var suggestion = ConsoleUi.FindClosestMatch(kind, AllValidValidateKinds);
+ if (suggestion != null)
+ Console.Error.WriteLine($"Did you mean: --kind {suggestion}?");
+ }
+
private static void WriteGraphReferenceKindHint(string command, string? kind, bool json)
{
if (json || string.IsNullOrWhiteSpace(kind))
@@ -3889,6 +3988,9 @@ private static void WriteGraphReferenceKindHint(string command, string? kind, bo
}
Console.Error.WriteLine($"Hint: '{kind}' is not a known reference kind for '{command}'. Available reference kinds: {string.Join(", ", acceptedKinds)}");
+ var suggestion = ConsoleUi.FindClosestMatch(kind, acceptedKinds);
+ if (suggestion != null)
+ Console.Error.WriteLine($"Did you mean: --kind {suggestion}?");
}
// Reference kinds that are valid `references --kind` values but NOT valid
diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs
index d31004faea..2d6bc6d864 100644
--- a/src/CodeIndex/Mcp/McpServer.cs
+++ b/src/CodeIndex/Mcp/McpServer.cs
@@ -1517,24 +1517,37 @@ private static JsonObject CreateToolResult(JsonNode? id, string text, JsonNode?
///
/// Create a tool error response (MCP format with isError flag).
- /// ツールエラーレスポンスを作成(isErrorフラグ付きMCP形式)。
+ /// Optional attach a structured
+ /// data.similar_values array to the result so MCP clients can offer
+ /// recovery alternatives without parsing the human-readable message (#1582).
+ /// ツールエラーレスポンスを作成(isError フラグ付き MCP 形式)。
+ /// を渡すと結果に構造化された
+ /// data.similar_values 配列を添えるので、MCP クライアントは
+ /// 人間向けメッセージを解析せずに代替候補を提示できる (#1582)。
///
private static JsonObject CreateToolErrorResponse(JsonNode? id, string message,
- string category, string suggestion, bool retrySafe, JsonObject? extraData = null)
- => CreateToolErrorResponse(id is not null, id, message, category, suggestion, retrySafe, extraData);
+ string category, string suggestion, bool retrySafe, JsonObject? extraData = null,
+ IReadOnlyList? similarValues = null)
+ => CreateToolErrorResponse(id is not null, id, message, category, suggestion, retrySafe, extraData, similarValues);
// Backward-compatible overload for tool handlers that return argument-validation
// failures (#1581). These were all "missing parameter / invalid argument" call sites
// before the envelope was introduced, so the default classification is `invalid_argument`
- // / retry_safe=false. Sites that have richer context should call the explicit overload.
+ // / retry_safe=false. The optional `similarValues` carries the structured did-you-mean
+ // candidates for unknown enum values (#1582). Sites that have richer context should
+ // call the explicit overload.
// 引数バリデーション失敗を返す既存ツールハンドラ向けの互換オーバーロード(#1581)。
// envelope 導入前の呼び出しは全て「引数不正」系だったため既定カテゴリを `invalid_argument`
- // / retry_safe=false とする。より具体的なカテゴリを持てる呼び出し元は明示オーバーロードを使う。
- private static JsonObject CreateToolErrorResponse(JsonNode? id, string message)
+ // / retry_safe=false とする。任意の `similarValues` は未知 enum 値に対する構造化された
+ // did-you-mean 候補 (#1582)。より具体的なカテゴリを持てる呼び出し元は明示オーバーロード
+ // を使う。
+ private static JsonObject CreateToolErrorResponse(JsonNode? id, string message,
+ IReadOnlyList? similarValues = null)
=> CreateToolErrorResponse(id, message,
category: McpErrorEnvelope.CategoryInvalidArgument,
suggestion: "Tool argument validation failed. Inspect the tool's `inputSchema` via tools/list and adjust the call.",
- retrySafe: false);
+ retrySafe: false,
+ similarValues: similarValues);
// Issue #1581: tool-result errors mirror the JSON-RPC error envelope by including
// the same `category` / `suggestion` / `retry_safe` triple under `result.structuredContent`.
@@ -1544,7 +1557,8 @@ private static JsonObject CreateToolErrorResponse(JsonNode? id, string message)
// を `result.structuredContent` に載せる。既存の `content[0].text` + `isError` だけを読む
// クライアントは互換のまま、新規クライアントは `structuredContent` でカテゴリ分岐できる。
private static JsonObject CreateToolErrorResponse(bool hasId, JsonNode? id, string message,
- string category, string suggestion, bool retrySafe, JsonObject? extraData = null)
+ string category, string suggestion, bool retrySafe, JsonObject? extraData = null,
+ IReadOnlyList? similarValues = null)
{
var result = new JsonObject
{
@@ -1559,6 +1573,16 @@ private static JsonObject CreateToolErrorResponse(bool hasId, JsonNode? id, stri
["isError"] = true,
["structuredContent"] = McpErrorEnvelope.BuildData(category, suggestion, retrySafe, extraData),
};
+ if (similarValues != null && similarValues.Count > 0)
+ {
+ var similarArray = new JsonArray();
+ foreach (var value in similarValues)
+ similarArray.Add(JsonValue.Create(value));
+ result["data"] = new JsonObject
+ {
+ ["similar_values"] = similarArray,
+ };
+ }
return CreateSuccessResponse(hasId, id, result);
}
diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs
index 74ccaf2b01..106621c1f4 100644
--- a/src/CodeIndex/Mcp/McpToolHandlers.cs
+++ b/src/CodeIndex/Mcp/McpToolHandlers.cs
@@ -2253,7 +2253,13 @@ private JsonNode ExecuteSuggestImprovement(JsonNode? id, JsonNode? args)
return CreateToolErrorResponse(id, "Missing required parameter: category");
if (!SuggestionRecord.ValidCategories.Contains(category))
- return CreateToolErrorResponse(id, $"Invalid category: '{category}'. Must be one of: {string.Join(", ", SuggestionRecord.ValidCategories)}");
+ {
+ var similar = ConsoleUi.FindClosestMatches(category, SuggestionRecord.ValidCategories);
+ var message = $"Invalid category: '{category}'. Must be one of: {string.Join(", ", SuggestionRecord.ValidCategories)}";
+ if (similar.Count > 0)
+ message += $". Did you mean: {string.Join(", ", similar)}?";
+ return CreateToolErrorResponse(id, message, similar);
+ }
var description = args?["description"]?.GetValue();
if (string.IsNullOrWhiteSpace(description))
diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs
index 5fb3270782..71d1c790ee 100644
--- a/tests/CodeIndex.Tests/ConsoleUiTests.cs
+++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs
@@ -621,6 +621,68 @@ public void FindClosestCommand_GarbageInput_ReturnsNull(string input)
Assert.Null(ConsoleUi.FindClosestCommand(input));
}
+ [Theory]
+ [InlineData("--paht", "--path")]
+ [InlineData("--exclud-path", "--exclude-path")]
+ [InlineData("--limti", "--limit")]
+ [InlineData("--lnag", "--lang")]
+ public void FindClosestMatch_FlagTypo_SuggestsClosestFlag(string input, string expected)
+ {
+ var flags = new[] { "--path", "--exclude-path", "--limit", "--lang", "--kind", "--json", "--query" };
+
+ Assert.Equal(expected, ConsoleUi.FindClosestMatch(input, flags));
+ }
+
+ [Theory]
+ [InlineData("pythno", "python")]
+ [InlineData("csarp", "csharp")]
+ [InlineData("typescritp", "typescript")]
+ public void FindClosestMatch_LanguageTypo_SuggestsClosestLanguage(string input, string expected)
+ {
+ var languages = new[] { "python", "csharp", "typescript", "javascript", "go", "rust" };
+
+ Assert.Equal(expected, ConsoleUi.FindClosestMatch(input, languages));
+ }
+
+ [Fact]
+ public void FindClosestMatch_BlankInput_ReturnsNull()
+ {
+ Assert.Null(ConsoleUi.FindClosestMatch(null, new[] { "csharp" }));
+ Assert.Null(ConsoleUi.FindClosestMatch("", new[] { "csharp" }));
+ Assert.Null(ConsoleUi.FindClosestMatch(" ", new[] { "csharp" }));
+ }
+
+ [Fact]
+ public void FindClosestMatches_ReturnsRankedSuggestions()
+ {
+ var candidates = new[] { "added", "changed", "fixed", "removed", "security", "docs" };
+
+ var matches = ConsoleUi.FindClosestMatches("addd", candidates, maxResults: 2);
+
+ Assert.Contains("added", matches);
+ Assert.True(matches.Count <= 2);
+ }
+
+ [Fact]
+ public void FindClosestMatches_NoMatchesWithinThreshold_ReturnsEmpty()
+ {
+ var candidates = new[] { "added", "changed" };
+
+ var matches = ConsoleUi.FindClosestMatches("absolutelynotrelated", candidates);
+
+ Assert.Empty(matches);
+ }
+
+ [Fact]
+ public void FindClosestMatches_ZeroMaxResults_ReturnsEmpty()
+ {
+ var candidates = new[] { "added", "changed" };
+
+ var matches = ConsoleUi.FindClosestMatches("addd", candidates, maxResults: 0);
+
+ Assert.Empty(matches);
+ }
+
[Theory]
[InlineData("auto", ColorMode.Auto)]
[InlineData("Always", ColorMode.Always)]
diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
index b97fb7a8c9..cded5278a5 100644
--- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
+++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs
@@ -29,6 +29,51 @@ public void ParseArgs_HelpFlagSetsShowHelp()
Assert.Null(options.ProjectPath);
}
+ // `cdidx index . --rebild` should not just say "unknown option"; surface the closest accepted
+ // flag (`--rebuild`) so MCP callers can self-correct without re-reading docs (#1582).
+ // `cdidx index . --rebild` のような単純なミスタイプから `--rebuild` を提案できることを確認する (#1582)。
+ [Fact]
+ public void ParseArgs_UnknownIndexOption_SuggestsClosestFlag()
+ {
+ lock (TestConsoleLock.Gate)
+ {
+ var originalErr = Console.Error;
+ using var stderr = new StringWriter();
+ try
+ {
+ Console.SetError(stderr);
+ IndexCommandRunner.ParseArgs([".", "--rebild"]);
+ Assert.Contains("Warning: unknown option '--rebild'", stderr.ToString());
+ Assert.Contains("Did you mean: --rebuild?", stderr.ToString());
+ }
+ finally
+ {
+ Console.SetError(originalErr);
+ }
+ }
+ }
+
+ [Fact]
+ public void ParseArgs_UnknownIndexOption_NoSuggestionWhenFarFromAnyFlag()
+ {
+ lock (TestConsoleLock.Gate)
+ {
+ var originalErr = Console.Error;
+ using var stderr = new StringWriter();
+ try
+ {
+ Console.SetError(stderr);
+ IndexCommandRunner.ParseArgs([".", "--zzzzzzzz"]);
+ Assert.Contains("Warning: unknown option '--zzzzzzzz'", stderr.ToString());
+ Assert.DoesNotContain("Did you mean:", stderr.ToString());
+ }
+ finally
+ {
+ Console.SetError(originalErr);
+ }
+ }
+ }
+
[Fact]
public void ParseArgs_ForceFlag_SetsForce()
{
diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs
index c53302184b..7ac4cce6a6 100644
--- a/tests/CodeIndex.Tests/McpServerTests.cs
+++ b/tests/CodeIndex.Tests/McpServerTests.cs
@@ -6905,6 +6905,27 @@ public void SuggestImprovement_InvalidCategory_ReturnsError()
Assert.Contains("Invalid category", response["result"]!["content"]![0]!["text"]!.GetValue());
}
+ // Regression pin for issue #1582: typo'd category should surface a "Did you mean: ..." hint and
+ // expose machine-readable similar values via `result.data.similar_values` for MCP clients.
+ // #1582 回帰テスト: タイポしたカテゴリは "Did you mean: ..." ヒントを返し、MCP クライアント向けに
+ // `result.data.similar_values` で類似候補を構造化して提供する。
+ [Fact]
+ public void SuggestImprovement_InvalidCategoryTypo_ReturnsDidYouMeanWithSimilarValues_Issue1582()
+ {
+ var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"suggest_improvement","arguments":{"category":"symbol_extractoin","description":"Some description"}}}""")!;
+ var response = _server.HandleMessage(request)!;
+
+ var result = response["result"]!;
+ Assert.True(result["isError"]!.GetValue());
+ var text = result["content"]![0]!["text"]!.GetValue();
+ Assert.Contains("Invalid category", text);
+ Assert.Contains("Did you mean: symbol_extraction", text);
+
+ var data = result["data"]!.AsObject();
+ var similar = data["similar_values"]!.AsArray();
+ Assert.Contains(similar, n => n!.GetValue() == "symbol_extraction");
+ }
+
[Fact]
public void SuggestImprovement_MissingDescription_ReturnsError()
{
diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs
index ed7d9ba0b6..9f608a6d17 100644
--- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs
+++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs
@@ -2509,6 +2509,71 @@ public void QueryEntrypoints_UnsupportedOptionsReturnUsageError(string command,
Assert.DoesNotContain("database not found", stderr);
}
+ // `--paht` should surface as `--path` so MCP/CLI users do not have to read full help
+ // text to recover from a single-letter swap (#1582).
+ // `--paht` のような 1 文字入れ替えミスから `--path` を提案できることを確認する (#1582)。
+ [Fact]
+ public void RunSearch_UnsupportedFlagTypo_SuggestsClosestFlag()
+ {
+ var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(
+ ["foo", "--paht", "src/**"],
+ _jsonOptions));
+
+ Assert.Equal(CommandExitCodes.UsageError, exitCode);
+ Assert.Contains("Error: --paht is not supported for search.", stderr);
+ Assert.Contains("Did you mean: --path?", stderr);
+ }
+
+ // Inline `--foo=bar` form must surface the same suggestion as the separated form.
+ // ParseArgs only splits `=value` for known value-taking options, so for `--paht=...`
+ // the suggester previously saw the full `--paht=src/**` token and produced no match;
+ // the round-2 fix strips the `=value` portion before searching for a similar flag.
+ // インライン `--foo=bar` 形式も separated 形式と同じ提案を出すこと。ParseArgs は
+ // 既知の value-taking option でしか `=value` を分解しないため、`--paht=...` は
+ // まるごと matcher に渡され従来は提案が出なかった。round-2 修正で `=value` を
+ // 除去してから候補を探すようにした。
+ [Fact]
+ public void RunSearch_UnsupportedFlagTypoInInlineValueForm_SuggestsClosestFlag_Issue1582()
+ {
+ var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(
+ ["foo", "--paht=src/**"],
+ _jsonOptions));
+
+ Assert.Equal(CommandExitCodes.UsageError, exitCode);
+ Assert.Contains("is not supported for search", stderr);
+ Assert.Contains("Did you mean: --path?", stderr);
+ }
+
+ // `find` previously emitted only the raw `Error: unsupported option for find: --paht`
+ // line — round-2 fix routes the unknown token through the same suggester so users see
+ // `Did you mean: --path?`. Covers both the separated and inline `=value` forms.
+ // 従来 find は `Error: unsupported option for find: --paht` だけを出していたが、
+ // round-2 修正で同じ suggester を経由するようにし `Did you mean: --path?` を出す。
+ // separated 形式と inline `=value` 形式の両方を確認する。
+ [Fact]
+ public void RunFind_UnsupportedFlagTypo_SuggestsClosestFlag_Issue1582()
+ {
+ var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind(
+ ["guard", "--paht", "src/Auth.cs"],
+ _jsonOptions));
+
+ Assert.Equal(CommandExitCodes.UsageError, exitCode);
+ Assert.Contains("unsupported option for find: --paht", stderr);
+ Assert.Contains("Did you mean: --path?", stderr);
+ }
+
+ [Fact]
+ public void RunFind_UnsupportedFlagTypoInInlineValueForm_SuggestsClosestFlag_Issue1582()
+ {
+ var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunFind(
+ ["guard", "--paht=src/Auth.cs"],
+ _jsonOptions));
+
+ Assert.Equal(CommandExitCodes.UsageError, exitCode);
+ Assert.Contains("unsupported option for find: --paht=src/Auth.cs", stderr);
+ Assert.Contains("Did you mean: --path?", stderr);
+ }
+
[Theory]
[InlineData("search-limit", "search", "--limit requires a positive integer")]
[InlineData("search-top", "search", "--limit requires a positive integer")]
@@ -3033,6 +3098,119 @@ public void RunValidate_KindFilterNarrowsIssues()
}
}
+ // `validate --kind replacement_chra` previously filtered the file_issues table by an
+ // unknown kind, returned zero rows, and printed the same "No encoding issues found."
+ // message a genuinely-clean repo would print — silently masking the typo. Round-2 adds
+ // a known-kind allowlist + did-you-mean hint (#1582).
+ // 従来 `validate --kind replacement_chra` は file_issues を 0 行に絞り込み、本当に
+ // クリーンな状態と同じ "No encoding issues found." を出して typo を握り潰していた。
+ // round-2 で許可された kind 一覧と did-you-mean を追加した (#1582)。
+ [Fact]
+ public void RunValidate_KindTypo_SuggestsClosestKind_Issue1582()
+ {
+ var projectRoot = TestProjectHelper.CreateTempProject("cdidx_validate_kind_typo");
+ try
+ {
+ var dbPath = TestProjectHelper.CreateProjectDb(projectRoot);
+ Directory.CreateDirectory(Path.Combine(projectRoot, "src"));
+ File.WriteAllText(
+ Path.Combine(projectRoot, "src", "clean.cs"),
+ "class Clean {}\n");
+
+ var (indexExitCode, _, indexStderr) = CaptureConsole(() => IndexCommandRunner.Run(
+ [projectRoot, "--db", dbPath, "--json"],
+ _jsonOptions));
+ Assert.Equal(CommandExitCodes.Success, indexExitCode);
+ Assert.Equal(string.Empty, indexStderr);
+
+ var (exitCode, _, stderr) = CaptureConsole(() => QueryCommandRunner.RunValidate(
+ ["--db", dbPath, "--kind", "replacement_chra"],
+ _jsonOptions));
+
+ Assert.Equal(CommandExitCodes.Success, exitCode);
+ Assert.Contains("No encoding issues found.", stderr);
+ Assert.Contains("'replacement_chra' is not a known validate kind", stderr);
+ Assert.Contains("Did you mean: --kind replacement_char?", stderr);
+ }
+ finally
+ {
+ TestProjectHelper.DeleteDirectory(projectRoot);
+ }
+ }
+
+ // `search --lang csarp` previously emitted "No results found." with no language hint
+ // — RunSearch never called WriteLangHint. Round-2 wires WriteLangHint into the zero-
+ // result branch and lets it fall back to ReferenceExtractor.GetSupportedLanguages()
+ // when the typo'd value matches no indexed language (#1582).
+ // 従来 `search --lang csarp` は "No results found." だけ表示し、RunSearch から
+ // WriteLangHint を呼んでいなかった。round-2 で zero-result 分岐に WriteLangHint を
+ // 配線し、index 済み言語にマッチしない場合は ReferenceExtractor.GetSupportedLanguages()
+ // にフォールバックして提案を出すようにした (#1582)。
+ [Fact]
+ public void RunSearch_LangTypo_SuggestsClosestLanguage_Issue1582()
+ {
+ var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_lang_typo");
+ try
+ {
+ var dbPath = TestProjectHelper.CreateProjectDb(projectRoot);
+ TestProjectHelper.InsertIndexedFile(
+ dbPath,
+ "src/App.cs",
+ "csharp",
+ "class App { }\n");
+
+ var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(
+ ["nothing_matches_xyzzy", "--db", dbPath, "--lang", "csarp"],
+ _jsonOptions));
+
+ Assert.Equal(CommandExitCodes.NotFound, exitCode);
+ Assert.Equal(string.Empty, stdout);
+ Assert.Contains("No results found.", stderr);
+ Assert.Contains("Did you mean: --lang csharp?", stderr);
+ }
+ finally
+ {
+ TestProjectHelper.DeleteDirectory(projectRoot);
+ }
+ }
+
+ // `--lang java` against a repo with no Java files used to print a confusing
+ // "Did you mean: --lang java?" because the fallback ReferenceExtractor match returned
+ // the exact input. Regression coverage for the round-3 fix that suppresses
+ // self-suggestions in WriteLangHint (#1582).
+ // Java を含まないリポジトリで `--lang java` を指定した際、フォールバックの
+ // ReferenceExtractor が入力と同じ値を返すため "Did you mean: --lang java?" という
+ // 紛らわしいメッセージが出ていた。round-3 で WriteLangHint が自己提案を抑止する
+ // ようにしたことの回帰ロック (#1582)。
+ [Fact]
+ public void RunSearch_LangNotIndexedButSupported_DoesNotSelfSuggest_Issue1582()
+ {
+ var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_lang_no_self_suggest");
+ try
+ {
+ var dbPath = TestProjectHelper.CreateProjectDb(projectRoot);
+ TestProjectHelper.InsertIndexedFile(
+ dbPath,
+ "src/App.cs",
+ "csharp",
+ "class App { }\n");
+
+ var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch(
+ ["nothing_matches_xyzzy", "--db", dbPath, "--lang", "java"],
+ _jsonOptions));
+
+ Assert.Equal(CommandExitCodes.NotFound, exitCode);
+ Assert.Equal(string.Empty, stdout);
+ Assert.Contains("No results found.", stderr);
+ Assert.Contains("'java' not found in index", stderr);
+ Assert.DoesNotContain("Did you mean: --lang java?", stderr);
+ }
+ finally
+ {
+ TestProjectHelper.DeleteDirectory(projectRoot);
+ }
+ }
+
[Fact]
public void BuildSymbolQueryList_TreatsPipeAsLiteralNameCharacter()
{