From bd34aa328fda9bf625e055709f33f0fc5c5b081a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 16 May 2026 14:13:07 +0000 Subject: [PATCH 1/4] Extend "Did you mean" suggestions to flags and enum values (#1582) Generalize the existing Damerau-Levenshtein subcommand suggester into a reusable ConsoleUi.FindClosestMatch / FindClosestMatches helper, and wire it into the unknown long-flag warnings on index / backfill-fold / search plus --lang / --kind / graph reference-kind enum-value errors. MCP error responses for unknown enumerated values 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. Same length-aware threshold is reused so off-by-many tokens still print no suggestion. Fixes #1582 --- changelog.d/unreleased/1582.changed.md | 23 +++++++ src/CodeIndex/Cli/ConsoleUi.cs | 63 ++++++++++++++++--- src/CodeIndex/Cli/IndexCommandRunner.cs | 45 +++++++++++++ src/CodeIndex/Cli/QueryCommandRunner.cs | 27 ++++++++ src/CodeIndex/Mcp/McpServer.cs | 24 +++++-- src/CodeIndex/Mcp/McpToolHandlers.cs | 8 ++- tests/CodeIndex.Tests/ConsoleUiTests.cs | 62 ++++++++++++++++++ .../IndexCommandRunnerTests.cs | 45 +++++++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 21 +++++++ .../QueryCommandRunnerTests.cs | 15 +++++ 10 files changed, 321 insertions(+), 12 deletions(-) create mode 100644 changelog.d/unreleased/1582.changed.md diff --git a/changelog.d/unreleased/1582.changed.md b/changelog.d/unreleased/1582.changed.md new file mode 100644 index 0000000000..eb602339a0 --- /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`, and every `search` / graph query family path that already reports "is not supported", (b) `--lang` values that do not match the indexed language set, and (c) `--kind` and graph reference-kind values. A typo like `cdidx index --rebild` now prints `Did you mean: --rebuild?` to stderr in addition to the existing warning, and `cdidx search foo --lang csarp` now prints `Did you mean: --lang csharp?`. 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` および `search` / グラフ系コマンドで既に "is not supported" を出している未知の long flag、(b) インデックス済み言語集合と一致しない `--lang` 値、(c) `--kind` およびグラフ参照 kind 値に対しても "Did you mean: ..." を stderr に追加で出力するようにしました。例えば `cdidx index --rebild` は警告に続いて `Did you mean: --rebuild?` を、`cdidx search foo --lang csarp` は `Did you mean: --lang csharp?` を表示します。長さに応じた閾値 (`<=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 a0d94ede66..4f72ed62a6 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -573,29 +573,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 d6855ae694..96b4eac97e 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -449,6 +449,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", + "--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; @@ -509,7 +548,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; @@ -563,7 +605,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 683672d6de..b9db8cbdd8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -3366,6 +3366,15 @@ 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). + // ユーザーがフラグ名をミスタイプしたとき (例: `--paht` → `--path`) に + // そのコマンドで受理される最も近いフラグを提案する。サブコマンドの did-you-mean と + // 同じ suggester を共用し、回復体験を統一する (#1582)。 + var suggestion = ConsoleUi.FindClosestMatch(normalizedArg, 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; @@ -3832,7 +3841,19 @@ 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)) + { 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 (#1582). Fall back to the full supported set when + // the typo does not match anything currently in the DB. + // `--lang pythno` / `--lang csarp` のようなタイプミスから回復させるため、 + // インデックスに存在する言語の中から最も近いものを提案する (#1582)。 + // インデックスに無ければ extractor がサポートする全言語集合から探す。 + var suggestion = ConsoleUi.FindClosestMatch(lang, status.Languages.Keys) + ?? ConsoleUi.FindClosestMatch(lang, ReferenceExtractor.GetSupportedLanguages()); + if (suggestion != null) + Console.Error.WriteLine($"Did you mean: --lang {suggestion}?"); + } } // All valid symbol kinds emitted by SymbolExtractor / SymbolExtractor が出力する全有効シンボル種別 @@ -3860,6 +3881,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 @@ -3889,6 +3913,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 920b74d12a..b86108b4fa 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -1347,12 +1347,18 @@ 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) - => CreateToolErrorResponse(id is not null, id, message); + private static JsonObject CreateToolErrorResponse(JsonNode? id, string message, IReadOnlyList? similarValues = null) + => CreateToolErrorResponse(id is not null, id, message, similarValues); - private static JsonObject CreateToolErrorResponse(bool hasId, JsonNode? id, string message) + private static JsonObject CreateToolErrorResponse(bool hasId, JsonNode? id, string message, IReadOnlyList? similarValues = null) { var result = new JsonObject { @@ -1366,6 +1372,16 @@ private static JsonObject CreateToolErrorResponse(bool hasId, JsonNode? id, stri }, ["isError"] = true }; + 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 43130452e6..be168237a1 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2184,7 +2184,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 5e78c8eb02..81d1c8f7cd 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 fae12675f4..c662f78b32 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -6896,6 +6896,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..59e67c283b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -2509,6 +2509,21 @@ 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); + } + [Theory] [InlineData("search-limit", "search", "--limit requires a positive integer")] [InlineData("search-top", "search", "--limit requires a positive integer")] From e6403a7d258abb255f78cac3d59b0e6ff1dfa40e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 16 May 2026 14:55:26 +0000 Subject: [PATCH 2/4] Address codex round-2 review: extend did-you-mean coverage - RunSearch: call WriteLangHint in the zero-result branch so `search foo --lang csarp` surfaces `Did you mean: --lang csharp?`. - WriteLangHint: fall back to ReferenceExtractor.GetSupportedLanguages() when the indexed-language set has no match (or is empty for a fresh index), so the suggester works regardless of DB state. - The `find` unsupported-option path + search inline `--foo=bar` form: strip the `=value` portion before matching so `--paht`, `--paht=src`, and `search --paht=src/**` all suggest `--path`. - validate --kind: introduce AllValidValidateKinds allowlist + new WriteValidateKindHint so typos like `--kind replacement_chra` no longer silently mimic a clean repo. - AllValidValidateKinds includes utf16_bom to match FileIndexer emissions. - Tests for the four new behaviors plus updated bilingual changelog. Issue: #1582 Co-Authored-By: Claude Opus 4.7 --- changelog.d/unreleased/1582.changed.md | 4 +- src/CodeIndex/Cli/QueryCommandRunner.cs | 98 +++++++++++--- .../QueryCommandRunnerTests.cs | 126 ++++++++++++++++++ 3 files changed, 211 insertions(+), 17 deletions(-) diff --git a/changelog.d/unreleased/1582.changed.md b/changelog.d/unreleased/1582.changed.md index eb602339a0..6c1f9a26e6 100644 --- a/changelog.d/unreleased/1582.changed.md +++ b/changelog.d/unreleased/1582.changed.md @@ -16,8 +16,8 @@ affected: ## 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`, and every `search` / graph query family path that already reports "is not supported", (b) `--lang` values that do not match the indexed language set, and (c) `--kind` and graph reference-kind values. A typo like `cdidx index --rebild` now prints `Did you mean: --rebuild?` to stderr in addition to the existing warning, and `cdidx search foo --lang csarp` now prints `Did you mean: --lang csharp?`. 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. +- **"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` および `search` / グラフ系コマンドで既に "is not supported" を出している未知の long flag、(b) インデックス済み言語集合と一致しない `--lang` 値、(c) `--kind` およびグラフ参照 kind 値に対しても "Did you mean: ..." を stderr に追加で出力するようにしました。例えば `cdidx index --rebild` は警告に続いて `Did you mean: --rebuild?` を、`cdidx search foo --lang csarp` は `Did you mean: --lang csharp?` を表示します。長さに応じた閾値 (`<=4 chars: 1`、`<=10: 2`、それ以外 `3`) はそのまま流用しているため、`--zzzzzzzz` のように離れすぎたトークンには引き続き提案を出しません。MCP の未知 enum 値レスポンス (現状 `suggest_improvement` の `category` 引数) も、人間向けの "Did you mean: ..." テキストに加えて `result.data.similar_values: [...]` を構造化して返すようになり、MCP クライアントはメッセージ文字列を解析せずに 1-tap で復旧候補を提示できます。 +- **「もしかして」提案を 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/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index b9db8cbdd8..0a1a216c12 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; } @@ -3369,10 +3401,21 @@ private static bool TryWriteUnsupportedOptionError(string commandName, string[] // 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)。 - var suggestion = ConsoleUi.FindClosestMatch(normalizedArg, supported.Where(o => o != "--")); + // 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`."); @@ -3840,20 +3883,26 @@ 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 (#1582). Fall back to the full supported set when - // the typo does not match anything currently in the DB. - // `--lang pythno` / `--lang csarp` のようなタイプミスから回復させるため、 - // インデックスに存在する言語の中から最も近いものを提案する (#1582)。 - // インデックスに無ければ extractor がサポートする全言語集合から探す。 - var suggestion = ConsoleUi.FindClosestMatch(lang, status.Languages.Keys) - ?? ConsoleUi.FindClosestMatch(lang, ReferenceExtractor.GetSupportedLanguages()); - if (suggestion != null) - Console.Error.WriteLine($"Did you mean: --lang {suggestion}?"); - } + + // 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)。 + var suggestion = ConsoleUi.FindClosestMatch(lang, status.Languages.Keys) + ?? ConsoleUi.FindClosestMatch(lang, ReferenceExtractor.GetSupportedLanguages()); + if (suggestion != null) + Console.Error.WriteLine($"Did you mean: --lang {suggestion}?"); } // All valid symbol kinds emitted by SymbolExtractor / SymbolExtractor が出力する全有効シンボル種別 @@ -3893,6 +3942,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)) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 59e67c283b..4646b8c9da 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -2524,6 +2524,56 @@ public void RunSearch_UnsupportedFlagTypo_SuggestsClosestFlag() 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")] @@ -3048,6 +3098,82 @@ 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); + } + } + [Fact] public void BuildSymbolQueryList_TreatsPipeAsLiteralNameCharacter() { From 2a2e13fdfad11be0d5b9c3a90e0cb85c6a3ddd56 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 16 May 2026 15:33:14 +0000 Subject: [PATCH 3/4] Address codex round-3 review: suppress lang self-suggestion After round-2 added a `ReferenceExtractor.GetSupportedLanguages()` fallback to `WriteLangHint`, supplying a supported-but-not-indexed language (e.g. `--lang java` against a Java-free repo) would print `Did you mean: --lang java?` because `FindClosestMatch` returns the input verbatim when it is a member of the candidate set. Suppress the "Did you mean" line when the suggested value equals the user-supplied value (case-insensitive). Regression test inserts a csharp-only index and asserts the hint fires without the self-suggestion line. Issue: #1582 Co-Authored-By: Claude Opus 4.7 --- src/CodeIndex/Cli/QueryCommandRunner.cs | 9 ++++- .../QueryCommandRunnerTests.cs | 37 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index 02ff7bc147..0db4202484 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -3899,9 +3899,16 @@ private static void WriteLangHint(string? lang, DbReader reader) // インデックスに無い、もしくは 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) + if (suggestion != null && !string.Equals(suggestion, lang, StringComparison.OrdinalIgnoreCase)) Console.Error.WriteLine($"Did you mean: --lang {suggestion}?"); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 4646b8c9da..9f608a6d17 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -3174,6 +3174,43 @@ public void RunSearch_LangTypo_SuggestsClosestLanguage_Issue1582() } } + // `--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() { From 563b04612c67fb945e9add3593e9174af1e88b51 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sat, 16 May 2026 15:34:31 +0000 Subject: [PATCH 4/4] Add --watch / --debounce to AcceptedIndexFlags for did-you-mean After merging origin/main, `index` accepts two additional flags (`--watch` and `--debounce`) introduced by the IndexWatchRunner work on main but the AcceptedIndexFlags array on this branch did not include them. That would have caused `cdidx index --waatch` to either emit no suggestion or, worse, suggest a less-similar but listed flag. Add both so the suggestion array is consistent with the parser switch cases. Issue: #1582 Co-Authored-By: Claude Opus 4.7 --- src/CodeIndex/Cli/IndexCommandRunner.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 8aa2a85331..af332ed8b8 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -497,7 +497,7 @@ public static int RunBackfillFold(string[] cmdArgs, JsonSerializerOptions jsonOp private static readonly string[] AcceptedIndexFlags = [ "--db", "--rebuild", "--verbose", "--json", "--dry-run", "--force", - "--commits", "--files", "--help", + "--watch", "--debounce", "--commits", "--files", "--help", ]; private static readonly string[] AcceptedBackfillFoldFlags =