From 21f956f462b709b4102c3d74630d8023f8b8a677 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 21:05:46 +0900 Subject: [PATCH 1/3] Fix active audit query hints (#4862) --- TESTING_GUIDE.md | 4 + USER_GUIDE.md | 18 +- changelog.d/unreleased/4862.fixed.md | 19 ++ .../Cli/QueryCommandRunner.SearchRecipes.cs | 187 ++++++++++++------ src/CodeIndex/Cli/SearchAuditRecipes.cs | 69 +++++++ .../QueryCommandRunnerSearchTests.cs | 169 +++++++++++++++- 6 files changed, 401 insertions(+), 65 deletions(-) create mode 100644 changelog.d/unreleased/4862.fixed.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 11475e38a..90f801a77 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -512,6 +512,8 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding uses one two-file recipe fixture to prove a compact search can emit a negative-score `next_cursor`, replay that exact cursor as a separated `--cursor` value, and return the next distinct page without an option-parsing error. - `QueryCommandRunnerTests.RunSearch_CursorDoesNotConsumeRecognizedShortOption_Issue4664` keeps the negative-cursor exception narrow by proving a recognized short option after `--cursor` remains an option and still produces the missing-cursor-value diagnostic. +- `QueryCommandRunnerTests.RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862`, `RunSearch_RecipeQuerySuggestionHandlesZeroOneAndManyQueries_Issue4862`, and `RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueries_Issue4862` + keep human/JSON usage errors, canonical names, current/deprecated aliases, deterministic typo correction, active-recipe isolation, normalized filter preservation, and shell-safe replay quoting in one recipe-selection contract. Suggestions and replay selectors must remain canonical names from the active recipe even when an alias is the nearest match. - `QueryCommandRunnerTests.RunReferences_ExactJson_StylesheetAndSqlFixturesShareIndexedWorkspace` keeps SCSS variable/mixin/extend references beside SQL multiline MERGE hints, non-ASCII identifiers, quoted-string masking, and temporary-table body boundaries in one multi-file workspace with one CLI index. Use language-specific queries and unique per-file sentinels so count, kind, and line assertions remain independently diagnostic. - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` @@ -1414,6 +1416,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" は 2 file の recipe fixture を 1 つ使い、compact search が負の score で始まる `next_cursor` を生成し、その値を separated `--cursor` として変更せず再利用しても option parse error にならず、別の次ページを返すことを検証します。 - `QueryCommandRunnerTests.RunSearch_CursorDoesNotConsumeRecognizedShortOption_Issue4664` は負の cursor に対する例外を狭く保ち、`--cursor` の直後にある既知の short option は cursor 値として消費されず、従来どおり cursor 値欠如の診断を返すことを検証します。 +- `QueryCommandRunnerTests.RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862`、`RunSearch_RecipeQuerySuggestionHandlesZeroOneAndManyQueries_Issue4862`、`RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueries_Issue4862` + は human / JSON usage error、canonical 名、現行 / deprecated alias、決定的な typo correction、active recipe への限定、正規化済み filter の保持、shell-safe な replay quoting を1つの recipe-selection 契約として維持します。alias が最も近い候補でも、suggestion と replay selector は active recipe の canonical 名でなければなりません。 - `QueryCommandRunnerTests.RunReferences_ExactJson_StylesheetAndSqlFixturesShareIndexedWorkspace` は SCSS の variable / mixin / extend 参照と、SQL の multiline MERGE hint、非 ASCII identifier、quoted-string masking、temporary-table body boundary を、CLI index 1回の multi-file workspace で共有します。言語別 query と file ごとの固有 sentinel を使い、件数・kind・line assertion の診断性を独立に保ってください。 - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 829ada0bb..d94ee0ac5 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1452,6 +1452,10 @@ Recipe JSON and compact output apply `--limit` per query, include a `summary` use a row selector with `next_cursor`; rerun a single child query as `--recipe / --cursor ` to page the next result set. +Unknown child-query diagnostics compare only the active recipe's canonical +query names and aliases. Their replay command keeps that recipe and normalized +search filters, quotes shell-sensitive values, and never substitutes a query +from another recipe. The MCP `search` tool exposes the same recipe surface with `{"listRecipes":true}` for discovery and `{"recipe":"risky-code"}` for execution. MCP recipe runs apply the same default source scope as the CLI; pass @@ -1463,6 +1467,10 @@ reported as bounded `recipe_source_diagnostics`. External recipes may declare recipe-level `default_scope`, `default_path_patterns`, and `default_exclude_paths`; each query may declare `severity`, `path_patterns`, and `exclude_paths` to narrow a query independently of the recipe default scope. +External queries may also declare `aliases` and `deprecated_aliases`; both +forms select the canonical query name, appear in full recipe discovery JSON, +and participate in active-recipe typo correction without becoming the replay +selector. For triage automation, `--format issue-drafts` emits draft issue objects with titles, labels, evidence paths, severity/confidence/evidence-count triage metadata, Markdown bodies, and duplicate-preflight metadata. `--open-issues ` accepts an open-issue JSON list such as @@ -1972,7 +1980,7 @@ same source location. | `--exclude-visibility ` | `definition`, `symbols`, `unused`, `hotspots` | Exclude symbols with the requested visibility values. Accepts the same comma-separated values and alias expansion as `--visibility`. | | `--path ` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | Restrict results to glob-style path patterns. `*` and `?` are wildcards. Repeatable; multiple values are OR'd together. Quote shell globs such as `--path 'src/**'` so the shell passes one literal pattern. | | `--query ` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | Pass a query literal explicitly, useful when the query starts with `-`. Query commands except `find` also accept `-- ` as a one-token query escape while continuing to parse later options. | -| `--recipe ` | `search` | Run a reusable audit recipe such as `risky-code`, `json-parse-apis`, `dotnet-risk-patterns`, `unsupported-operation-boundaries`, `nullable-contracts`, `xml-parser-security`, `filesystem-traversal`, `bounded-read-evidence`, `resource-materialization-audit`, or `concurrency-state-audit`. Use `recipe/query` form, such as `risky-code/raw-diagnostic-echo`, to run one child query directly. Unknown recipe/query selectors include likely matches across recipe groups. Recipe runs default to `--audit-scope source`, applying recipe production-code path and exclusion metadata before normal search filters and snippet controls; `--limit` / `--top` is per child query. Text, `--json` / `--format json`, `--format compact`, `--format sarif`, and `--format issue-drafts` are supported, and issue drafts include a replay command. | +| `--recipe ` | `search` | Run a reusable audit recipe such as `risky-code`, `json-parse-apis`, `dotnet-risk-patterns`, `unsupported-operation-boundaries`, `nullable-contracts`, `xml-parser-security`, `filesystem-traversal`, `bounded-read-evidence`, `resource-materialization-audit`, or `concurrency-state-audit`. Use `recipe/query` form, such as `risky-code/raw-diagnostic-echo`, to run one child query directly. An unknown recipe is compared with recipe names; an unknown child query is compared only with canonical names and aliases from the active recipe, and its safely quoted replay preserves that recipe and normalized filters. Recipe runs default to `--audit-scope source`, applying recipe production-code path and exclusion metadata before normal search filters and snippet controls; `--limit` / `--top` is per child query. Text, `--json` / `--format json`, `--format compact`, `--format sarif`, and `--format issue-drafts` are supported, and issue drafts include a replay command. | | `--include-query ` / `--exclude-query ` | `search --recipe ` | Include or exclude child recipe queries by name. Repeatable and comma-separated; names are listed by `cdidx search --list-recipes`. | | `--cursor ` | `search --recipe `, `outline`, `unused` | Fetch the next page for one selected recipe child query, outline result, or unused-symbol page. Use the `next_cursor` returned by the previous JSON or compact output; outline cursors use `outline:`. | | `--audit-scope ` | `search`, `unused` | Choose audit path scope. For recipe search, `source` applies recipe production-code path and exclusion metadata. For ad hoc and named-query searches, `source` adds `src/**` when no user path was supplied, and applies default doc/test/changelog exclusions, `--exclude-tests`, and default comment / CLI help-text origin exclusions. `all` intentionally searches every indexed path unless other filters exclude it. JSON output reports the effective scope, path filters, and exclusions where applicable. | @@ -4644,6 +4652,9 @@ list metadata ごとに grouped されるため usage error で拒否します `next_cursor` を付けます。 次の result set を取得するには、単一 child query を `--recipe / --cursor ` として再実行してください。 +未知の child query の診断は、active recipe 内の canonical query 名と alias だけを比較します。 +再実行コマンドは同じ recipe と正規化済み search filter を保持し、shell で意味を持つ値を引用し、 +別 recipe の query へ置き換えることはありません。 MCP `search` tool では `{"listRecipes":true}` で recipe を発見し、 `{"recipe":"risky-code"}` で実行できます。MCP の recipe run も CLI と同じ既定の source scope を適用します。docs、tests、changelog、recipe definitions を意図的に audit する場合は @@ -4654,6 +4665,9 @@ recipe array または `{ "recipes": [...] }` を受け付け、不正な source `default_scope`、`default_path_patterns`、`default_exclude_paths` を宣言できます。 各 query は `severity`、`path_patterns`、`exclude_paths` を宣言でき、recipe の既定 scope とは独立して query ごとの対象を狭められます。 +外部 query は `aliases` と `deprecated_aliases` も宣言できます。どちらも canonical query 名へ +解決され、完全な recipe discovery JSON に表示され、active recipe 内の typo correction 候補に +使われますが、再実行 selector には canonical query 名が使われます。 triage automation では `--format issue-drafts` を使うと、title、label、evidence path、 severity / confidence / evidence-count の triage metadata、Markdown body、 duplicate-preflight metadata を持つ issue draft object を出力します。 @@ -5139,7 +5153,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--exclude-visibility ` | `definition`, `symbols`, `unused`, `hotspots` | 指定した可視性のシンボルを除外する。値と alias 展開は `--visibility` と同じ | | `--path ` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `map`, `inspect`, `validate` | glob 形式のパスパターンで結果を絞る。`*` と `?` がワイルドカード。繰り返し指定可(複数値は OR で結合)。`--path 'src/**'` のように shell glob を引用し、shell が 1 つの literal pattern として渡すようにする。 | | `--query ` | `search`, `definition`, `references`, `callers`, `callees`, `symbols`, `files`, `find`, `inspect`, `impact` | クエリを明示的なリテラルとして渡す。クエリが `-` で始まる場合に有用。`find` 以外のクエリ系コマンドでは `-- ` も1トークンのクエリエスケープとして受け付け、その後のオプション解析を続ける。 | -| `--recipe ` | `search` | `risky-code`、`json-parse-apis`、`dotnet-risk-patterns`、`unsupported-operation-boundaries`、`nullable-contracts`、`xml-parser-security`、`filesystem-traversal`、`bounded-read-evidence`、`resource-materialization-audit`、`concurrency-state-audit` などの再利用可能な audit recipe を実行する。`risky-code/raw-diagnostic-echo` のような `recipe/query` 形式で child query を1つだけ直接実行できる。未知の recipe/query selector には recipe group をまたいだ近い候補が表示される。Recipe 実行は既定で `--audit-scope source` になり、recipe の本番コード向け path / exclusion metadata を適用したうえで、通常の search filter と snippet control を選択された各 query に適用する。`--limit` / `--top` は child query ごとの上限になる。text、`--json` / `--format json`、`--format compact`、`--format sarif`、`--format issue-drafts` に対応し、issue draft には再実行コマンドを含める。 | +| `--recipe ` | `search` | `risky-code`、`json-parse-apis`、`dotnet-risk-patterns`、`unsupported-operation-boundaries`、`nullable-contracts`、`xml-parser-security`、`filesystem-traversal`、`bounded-read-evidence`、`resource-materialization-audit`、`concurrency-state-audit` などの再利用可能な audit recipe を実行する。`risky-code/raw-diagnostic-echo` のような `recipe/query` 形式で child query を1つだけ直接実行できる。未知の recipe は recipe 名と比較し、未知の child query は active recipe 内の canonical 名と alias だけを比較する。安全に引用された再実行コマンドは同じ recipe と正規化済み filter を保持する。Recipe 実行は既定で `--audit-scope source` になり、recipe の本番コード向け path / exclusion metadata を適用したうえで、通常の search filter と snippet control を選択された各 query に適用する。`--limit` / `--top` は child query ごとの上限になる。text、`--json` / `--format json`、`--format compact`、`--format sarif`、`--format issue-drafts` に対応し、issue draft には再実行コマンドを含める。 | | `--include-query ` / `--exclude-query ` | `search --recipe ` | recipe 内の child query を名前で含める、または除外する。繰り返し指定とカンマ区切りに対応し、名前は `cdidx search --list-recipes` で確認できる。 | | `--cursor ` | `search --recipe `、`outline`、`unused` | 選択した recipe child query、outline 結果、unused-symbol page の次ページを取得する。直前の JSON または compact output が返す `next_cursor` を指定し、outline cursor は `outline:` 形式を使う。 | | `--audit-scope ` | `search`, `unused` | audit path scope を選ぶ。Recipe search の `source` は recipe の本番コード向け path / exclusion metadata を適用する。Ad hoc / named-query search の `source` は user path がない場合に `src/**` を追加し、既定の docs/tests/changelog exclusion、`--exclude-tests`、コメント / CLI ヘルプ文言 origin の既定除外を適用する。`all` は他の filter で除外しない限り、すべての indexed path を意図的に検索する。JSON 出力には該当する場合、有効な scope、path filter、exclusion が含まれる。 | diff --git a/changelog.d/unreleased/4862.fixed.md b/changelog.d/unreleased/4862.fixed.md new file mode 100644 index 000000000..f64770910 --- /dev/null +++ b/changelog.d/unreleased/4862.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 4862 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - src/CodeIndex/Cli/SearchAuditRecipes.cs + - tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **Unknown audit-query hints now stay within the active recipe (#4862)** — recipe child-query validation uses bounded deterministic similarity over canonical names and current/deprecated aliases from the selected recipe, emits canonical suggestions, and builds a safely quoted replay that preserves the active recipe and normalized filters instead of recommending `risky-code/raw-diagnostic-echo`. + +## 日本語 + +- **未知の audit query のヒントを active recipe 内に限定しました (#4862)** — recipe の child-query 検証は、選択済み recipe の canonical 名と現行 / deprecated alias に対して上限付きの決定的な類似度判定を行い、canonical な候補と、active recipe および正規化済み filter を保持した安全に引用済みの再実行コマンドを返します。無関係な `risky-code/raw-diagnostic-echo` は推奨しません。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index ff8f2a16f..8ef98850f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -231,7 +231,7 @@ private static void WriteGraphLiveness( private static bool TryResolveSearchRecipeSelection( QueryCommandOptions options, out SearchRecipeSelection selection, - out string? error) + out SearchRecipeSelectionError? error) { selection = default!; error = null; @@ -243,12 +243,16 @@ private static bool TryResolveSearchRecipeSelection( { if (slash == 0 || slash == recipeSelector.Length - 1 || slash != recipeSelector.LastIndexOf('/')) { - error = "--recipe child selection must use recipe/query form."; + error = new( + "--recipe child selection must use recipe/query form.", + "Use `cdidx search --recipe /`, with exactly one non-empty recipe and query name."); return false; } if (options.IncludeRecipeQueries.Count > 0 || options.ExcludeRecipeQueries.Count > 0) { - error = "--recipe recipe/query cannot be combined with --include-query or --exclude-query."; + error = new( + "--recipe recipe/query cannot be combined with --include-query or --exclude-query.", + "Use either `--recipe /` or a recipe name with `--include-query` / `--exclude-query`."); return false; } @@ -259,41 +263,61 @@ private static bool TryResolveSearchRecipeSelection( if (!SearchAuditRecipes.TryGet(recipeName, out var recipe)) { var available = string.Join(", ", SearchAuditRecipes.All.Select(r => r.Name)); - var suggestions = BuildRecipeSelectorSuggestions(recipeSelector); + var suggestions = ConsoleUi.FindClosestMatches(recipeName, SearchAuditRecipes.All.Select(candidate => candidate.Name)); var suggestionText = suggestions.Count > 0 - ? $" Did you mean: {string.Join(", ", suggestions)}?" + ? $" Did you mean: {string.Join(", ", suggestions.Select(candidate => $"'{candidate}'"))}?" : string.Empty; - error = $"unknown search recipe '{recipeName}'. Available recipes: {available}.{suggestionText}"; + var hint = suggestions.Count > 0 + ? $"Retry with `{BuildSearchRecipeSelectionReplayCommand(suggestions[0], options)}`." + : "Run `cdidx search --list-recipes` to choose an available recipe."; + error = new( + $"unknown search recipe '{recipeName}'. Available recipes: {available}.{suggestionText}", + hint); return false; } - var queryByName = recipe.Queries.ToDictionary(query => query.Name, StringComparer.OrdinalIgnoreCase); + var queryBySelector = BuildRecipeQuerySelectorMap(recipe); var availableQueries = string.Join(", ", recipe.Queries.Select(query => query.Name)); - if (!TryValidateRecipeQuerySelectors(queryByName, availableQueries, recipe.Name, options.IncludeRecipeQueries, "--include-query", out error) || - !TryValidateRecipeQuerySelectors(queryByName, availableQueries, recipe.Name, options.ExcludeRecipeQueries, "--exclude-query", out error)) + if (!TryValidateRecipeQuerySelectors( + queryBySelector, + availableQueries, + recipe.Name, + options.IncludeRecipeQueries, + "--include-query", + out var invalidSelector, + out var selectorError) + || !TryValidateRecipeQuerySelectors( + queryBySelector, + availableQueries, + recipe.Name, + options.ExcludeRecipeQueries, + "--exclude-query", + out invalidSelector, + out selectorError)) { + error = BuildUnknownRecipeQueryError(recipe, invalidSelector!, selectorError!, options); return false; } - if (directQueryName != null && !queryByName.ContainsKey(directQueryName)) + if (directQueryName != null && !queryBySelector.ContainsKey(directQueryName)) { - var suggestions = BuildRecipeSelectorSuggestions(directQueryName); - var suggestionText = suggestions.Count > 0 - ? $" Suggestions across all recipes: {string.Join(", ", suggestions)}." - : string.Empty; - error = $"unknown recipe query '{directQueryName}' for recipe '{recipe.Name}'. Available queries: {availableQueries}.{suggestionText}"; + error = BuildUnknownRecipeQueryError( + recipe, + directQueryName, + $"unknown recipe query '{directQueryName}' for recipe '{recipe.Name}'. Available queries: {availableQueries}.", + options); return false; } var selected = new List(); if (directQueryName != null) { - selected.Add(queryByName[directQueryName]); + selected.Add(queryBySelector[directQueryName]); } else if (options.IncludeRecipeQueries.Count > 0) { foreach (var queryName in options.IncludeRecipeQueries) { - var query = queryByName[queryName]; + var query = queryBySelector[queryName]; if (!selected.Any(existing => string.Equals(existing.Name, query.Name, StringComparison.OrdinalIgnoreCase))) selected.Add(query); } @@ -305,7 +329,9 @@ private static bool TryResolveSearchRecipeSelection( if (options.ExcludeRecipeQueries.Count > 0) { - var excludeSet = options.ExcludeRecipeQueries.ToHashSet(StringComparer.OrdinalIgnoreCase); + var excludeSet = options.ExcludeRecipeQueries + .Select(selector => queryBySelector[selector].Name) + .ToHashSet(StringComparer.OrdinalIgnoreCase); selected = selected .Where(query => !excludeSet.Contains(query.Name)) .ToList(); @@ -313,7 +339,9 @@ private static bool TryResolveSearchRecipeSelection( if (selected.Count == 0) { - error = $"recipe query selection for '{recipe.Name}' is empty after applying --include-query/--exclude-query."; + error = new( + $"recipe query selection for '{recipe.Name}' is empty after applying --include-query/--exclude-query.", + $"Retry with `{BuildSearchRecipeSelectionReplayCommand(recipe.Name, options)}` to run the complete active recipe."); return false; } @@ -322,63 +350,97 @@ private static bool TryResolveSearchRecipeSelection( } private static bool TryValidateRecipeQuerySelectors( - IReadOnlyDictionary queryByName, + IReadOnlyDictionary queryBySelector, string availableQueries, string recipeName, IReadOnlyList selectors, string optionName, + out string? invalidSelector, out string? error) { foreach (var selector in selectors) { - if (!queryByName.ContainsKey(selector)) + if (!queryBySelector.ContainsKey(selector)) { + invalidSelector = selector; error = $"unknown recipe query '{selector}' for recipe '{recipeName}' in {optionName}. Available queries: {availableQueries}."; return false; } } + invalidSelector = null; error = null; return true; } - private static List BuildRecipeSelectorSuggestions(string rawSelector) + private static Dictionary BuildRecipeQuerySelectorMap(SearchAuditRecipe recipe) { - var tokens = NormalizeDiscoveryTokens(rawSelector); - if (tokens.Count == 0) - return []; + var queryBySelector = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var query in recipe.Queries) + queryBySelector.TryAdd(query.Name, query); + foreach (var query in recipe.Queries) + { + foreach (var alias in query.Aliases.Concat(query.DeprecatedAliases)) + queryBySelector.TryAdd(alias, query); + } + return queryBySelector; + } - return SearchAuditRecipes.All - .SelectMany(recipe => recipe.Queries.Select(query => new - { - Selector = $"{recipe.Name}/{query.Name}", - Score = ScoreRecipeSelectorSuggestion(tokens, recipe, query), - })) - .Where(item => item.Score > 0) - .OrderByDescending(item => item.Score) - .ThenBy(item => item.Selector, StringComparer.Ordinal) + private static SearchRecipeSelectionError BuildUnknownRecipeQueryError( + SearchAuditRecipe recipe, + string rawSelector, + string message, + QueryCommandOptions options) + { + var suggestions = BuildSearchRecipeQuerySuggestions(recipe, rawSelector); + var suggestionText = suggestions.Count > 0 + ? $" Did you mean: {string.Join(", ", suggestions.Select(candidate => $"'{candidate}'"))}?" + : string.Empty; + var replaySelector = suggestions.Count > 0 + ? $"{recipe.Name}/{suggestions[0]}" + : recipe.Name; + var hint = recipe.Queries.Count > 0 + ? $"Retry with `{BuildSearchRecipeSelectionReplayCommand(replaySelector, options)}`." + : $"Recipe '{recipe.Name}' has no runnable queries; run `cdidx search --list-recipes` to choose another recipe."; + return new(message + suggestionText, hint); + } + + internal static IReadOnlyList BuildSearchRecipeQuerySuggestions( + SearchAuditRecipe recipe, + string rawSelector) + { + var canonicalBySelector = BuildRecipeQuerySelectorMap(recipe) + .ToDictionary( + pair => pair.Key, + pair => pair.Value.Name, + StringComparer.OrdinalIgnoreCase); + return ConsoleUi.FindClosestMatches(rawSelector, canonicalBySelector.Keys, maxResults: 12) + .Select(selector => canonicalBySelector[selector]) + .Distinct(StringComparer.OrdinalIgnoreCase) .Take(3) - .Select(item => item.Selector) .ToList(); } - private static int ScoreRecipeSelectorSuggestion(IReadOnlyList tokens, SearchAuditRecipe recipe, SearchAuditRecipeQuery query) + private static string BuildSearchRecipeSelectionReplayCommand(string recipeSelector, QueryCommandOptions options) { - var haystack = NormalizeDiscoveryText(string.Join(' ', BuildRecipeQuerySearchFields(recipe, query))); - var score = 0; - foreach (var token in tokens) + var args = new List { - if (haystack.Contains(token, StringComparison.Ordinal)) - score += token == "sql" && haystack.Contains("sqlite", StringComparison.Ordinal) ? 80 : 25; - } - - var normalizedSelector = NormalizeDiscoveryText($"{recipe.Name} {query.Name}"); - var normalizedRaw = string.Join(' ', tokens); - if (normalizedSelector.Contains(normalizedRaw, StringComparison.Ordinal)) - score += 100; - return score; + "cdidx", + "search", + "--recipe", + recipeSelector, + "--format", + OutputFormatCompact, + }; + AddReplayValueOption(args, "--limit", options.Limit.ToString(CultureInfo.InvariantCulture)); + AddSearchRecipeCompactReplayOptions(args, options, includeRecipeQuerySelectors: false); + return string.Join(" ", args.Select(QuoteReplayShellArg)); } + private sealed record SearchRecipeSelectionError( + string Message, + string Hint); + private static bool SearchRecipeMatchesFilter(SearchAuditRecipe recipe, string filter) => DiscoveryFilterMatches(filter, recipe.Name, @@ -401,6 +463,10 @@ private static IEnumerable BuildRecipeQuerySearchFields(SearchAuditRecip } yield return query.Name; + foreach (var alias in query.Aliases) + yield return alias; + foreach (var alias in query.DeprecatedAliases) + yield return alias; yield return query.Query; yield return query.Description; yield return query.FalsePositiveGuidance; @@ -481,9 +547,9 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp if (!TryResolveSearchRecipeSelection(options, out var selection, out var selectionError)) { WriteUsageError( - selectionError!, + selectionError!.Message, GetUsageLineOrThrow("search"), - "Use `cdidx search --recipe risky-code/raw-diagnostic-echo`, or `--include-query` / `--exclude-query` with a recipe name."); + selectionError.Hint); return CommandExitCodes.UsageError; } var recipe = selection.Recipe; @@ -928,12 +994,19 @@ private static void AddSearchRecipeCompactReplayOptions(List args, Query { if (options.DbPathExplicit) AddReplayValueOption(args, "--db", options.DbPath); + if (string.Equals(options.DataDirSource, DbPathResolver.DataDirSourceFlag, StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(options.DataDir)) + AddReplayValueOption(args, "--data-dir", options.DataDir); if (options.SourceOnly) args.Add("--source-only"); else if (options.AuditScopeExplicit) AddReplayValueOption(args, "--audit-scope", options.AuditScope); if (!string.IsNullOrWhiteSpace(options.Lang)) AddReplayValueOption(args, "--lang", options.Lang); + if (!string.IsNullOrWhiteSpace(options.SolutionFilter)) + AddReplayValueOption(args, "--solution", options.SolutionFilter); + foreach (var projectFilter in options.ProjectFilters) + AddReplayValueOption(args, "--project", projectFilter); foreach (var pathPattern in options.PathPatterns) AddReplayValueOption(args, "--path", pathPattern); foreach (var excludePath in options.ExcludePaths) @@ -995,9 +1068,9 @@ private static int RunSearchRecipeAggregation(QueryCommandOptions options, JsonS if (!TryResolveSearchRecipeSelection(options, out var selection, out var selectionError)) { WriteUsageError( - selectionError!, + selectionError!.Message, GetUsageLineOrThrow("search"), - "Use `cdidx search --recipe risky-code/raw-diagnostic-echo`, or `--include-query` / `--exclude-query` with a recipe name."); + selectionError.Hint); return CommandExitCodes.UsageError; } @@ -1156,9 +1229,9 @@ private static int RunSearchRecipeIssueDrafts( if (!TryResolveSearchRecipeSelection(options, out var selection, out var selectionError)) { WriteUsageError( - selectionError!, + selectionError!.Message, GetUsageLineOrThrow("search"), - "Use `cdidx search --recipe risky-code/raw-diagnostic-echo`, or `--include-query` / `--exclude-query` with a recipe name."); + selectionError.Hint); return CommandExitCodes.UsageError; } var recipe = selection.Recipe; @@ -1222,9 +1295,9 @@ private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSeriali if (!TryResolveSearchRecipeSelection(options, out var selection, out var selectionError)) { WriteUsageError( - selectionError!, + selectionError!.Message, GetUsageLineOrThrow("search"), - "Use `cdidx search --recipe risky-code/raw-diagnostic-echo`, or `--include-query` / `--exclude-query` with a recipe name."); + selectionError.Hint); return CommandExitCodes.UsageError; } @@ -3382,6 +3455,8 @@ private static string BuildAdHocSearchReplayCommand(QueryCommandOptions options, SearchRecipeLimitSemantics, (queries ?? recipe.Queries).Select(query => new SearchRecipeQueryListItemJsonResult( query.Name, + [.. query.Aliases], + [.. query.DeprecatedAliases], query.Query, query.Description, query.RecommendedLabels, diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 62885edef..9b8c7e78b 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -21,6 +21,7 @@ internal static class SearchAuditRecipes private const int MaxExternalRecipesPerFile = 32; private const int MaxExternalQueriesPerRecipe = 32; private const int MaxExternalNameLength = 80; + private const int MaxExternalQueryAliasCount = 16; private const int MaxExternalDescriptionLength = 512; private const int MaxExternalFalsePositiveGuidanceLength = 512; private const int MaxExternalLabelCount = 16; @@ -3878,6 +3879,24 @@ private static bool TryParseRecipeQuery( } var labels = ReadLabels(obj, sourceLabel, recipeName, name, diagnostics); + var aliases = ReadQuerySelectorAliases( + obj, + "aliases", + "aliases", + "aliases", + sourceLabel, + recipeName, + name, + diagnostics); + var deprecatedAliases = ReadQuerySelectorAliases( + obj, + "deprecatedAliases", + "deprecated_aliases", + "deprecated aliases", + sourceLabel, + recipeName, + name, + diagnostics); var falsePositiveGuidance = TryReadString(obj["falsePositiveGuidance"] ?? obj["false_positive_guidance"], out var guidance) && !string.IsNullOrWhiteSpace(guidance) ? guidance.Trim() @@ -3891,6 +3910,8 @@ private static bool TryParseRecipeQuery( query = new SearchAuditRecipeQuery(name, queryText, description, labels, falsePositiveGuidance, exactSubstring) { Severity = severity, + Aliases = aliases, + DeprecatedAliases = deprecatedAliases, PathPatterns = pathPatterns, ExcludePaths = excludePaths }; @@ -4077,6 +4098,50 @@ private static List ReadLabels( return labels; } + private static List ReadQuerySelectorAliases( + JsonObject obj, + string camelCasePropertyName, + string snakeCasePropertyName, + string fieldDescription, + string sourceLabel, + string recipeName, + string queryName, + List diagnostics) + { + var aliasesNode = obj[camelCasePropertyName] ?? obj[snakeCasePropertyName]; + if (aliasesNode is null) + return []; + if (aliasesNode is not JsonArray aliasArray) + { + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' query '{queryName}' {fieldDescription} must be an array."); + return []; + } + + var aliases = new List(); + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < aliasArray.Count && i < MaxExternalQueryAliasCount; i++) + { + if (!TryReadString(aliasArray[i], out var alias) || string.IsNullOrWhiteSpace(alias)) + { + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' query '{queryName}' {fieldDescription} item #{i + 1} must be a non-empty string."); + continue; + } + + alias = alias.Trim(); + if (alias.Length > MaxExternalNameLength) + { + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' query '{queryName}' {fieldDescription} item #{i + 1} exceeds {MaxExternalNameLength} characters."); + continue; + } + if (!string.Equals(alias, queryName, StringComparison.OrdinalIgnoreCase) && seen.Add(alias)) + aliases.Add(alias); + } + + if (aliasArray.Count > MaxExternalQueryAliasCount) + AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{recipeName}' query '{queryName}' has more than {MaxExternalQueryAliasCount} {fieldDescription}; extra entries are ignored."); + return aliases; + } + private static void AddDiagnostic(List diagnostics, string message) { if (diagnostics.Count >= MaxRecipeDiagnosticCount) @@ -4156,6 +4221,8 @@ internal sealed record SearchAuditRecipeQuery( bool ExactSubstring = true) { public string Severity { get; init; } = SearchAuditRecipes.DefaultQuerySeverity; + public List Aliases { get; init; } = []; + public List DeprecatedAliases { get; init; } = []; public List RiskEvidence { get; init; } = []; public List GuardFilters { get; init; } = []; public List RejectFileQueries { get; init; } = []; @@ -4226,6 +4293,8 @@ internal sealed record SearchRecipeLimitSemanticsJsonResult( internal sealed record SearchRecipeQueryListItemJsonResult( [property: JsonPropertyName("name")] string Name, + [property: JsonPropertyName("aliases")] List Aliases, + [property: JsonPropertyName("deprecated_aliases")] List DeprecatedAliases, [property: JsonPropertyName("query")] string Query, [property: JsonPropertyName("description")] string Description, [property: JsonPropertyName("recommended_labels")] List RecommendedLabels, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index f8ff79ed9..dc95024e7 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -3669,22 +3669,75 @@ public void RunSearch_UnknownRecipeErrorListsBuiltInNames_Issue3692() Assert.Contains(recipeName, stderr); } - [Fact] - public void RunSearch_UnknownRecipeQuerySuggestsAcrossRecipeGroups_Issue3975() + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862(bool json) { using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); env.Set(SearchAuditRecipes.RecipePathsEnvironmentVariable, null); + var args = new List + { + "--recipe", + "xml-parser-security/xml-readr-settings", + "--path", + "src folder/**", + "--limit", + "7", + }; + if (json) + args.Add("--json"); + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( - ["--recipe", "risky-code/raw-sql", "--json"], + [.. args], _jsonOptions)); Assert.Equal(CommandExitCodes.UsageError, exitCode); Assert.Equal(string.Empty, stdout); - Assert.Contains("unknown recipe query 'raw-sql' for recipe 'risky-code'", stderr); - Assert.Contains("Suggestions across all recipes:", stderr); - Assert.Contains("dogfood-risk-patterns/raw-sql-command-text", stderr); - Assert.Contains("sqlite-query-policy-surfaces/sqlite-policy-add-with-value", stderr); + Assert.Contains("unknown recipe query 'xml-readr-settings' for recipe 'xml-parser-security'", stderr); + Assert.Contains("Did you mean: 'xml-reader-settings'?", stderr); + Assert.Contains( + "cdidx search --recipe xml-parser-security/xml-reader-settings --format compact --limit 7 --path 'src folder/**'", + stderr); + Assert.DoesNotContain("risky-code/raw-diagnostic-echo", stderr); + Assert.DoesNotContain("Suggestions across all recipes", stderr); + } + + [Fact] + public void RunSearch_RecipeQuerySuggestionHandlesZeroOneAndManyQueries_Issue4862() + { + static SearchAuditRecipeQuery Query( + string name, + List? aliases = null, + List? deprecatedAliases = null) + => new(name, "Needle", "Find a marker.", [], "Review surrounding context.") + { + Aliases = aliases ?? [], + DeprecatedAliases = deprecatedAliases ?? [], + }; + + var emptyRecipe = new SearchAuditRecipe("empty-recipe", "No queries.", []); + var singleRecipe = new SearchAuditRecipe( + "single-recipe", + "One query.", + [Query("canonical-query", ["short-query"], ["renamed-query"])]); + var manyRecipe = new SearchAuditRecipe( + "many-recipe", + "Several queries.", + [ + Query("risk-query-one"), + Query("risk-query-two"), + Query("risk-query-six"), + ]); + + Assert.Empty(QueryCommandRunner.BuildSearchRecipeQuerySuggestions(emptyRecipe, "missing")); + Assert.Equal( + ["canonical-query"], + QueryCommandRunner.BuildSearchRecipeQuerySuggestions(singleRecipe, "renamd-query")); + var manySuggestions = QueryCommandRunner.BuildSearchRecipeQuerySuggestions(manyRecipe, "risk-query-x"); + Assert.Equal(3, manySuggestions.Count); + Assert.All(manySuggestions, suggestion => Assert.Contains(manyRecipe.Queries, query => query.Name == suggestion)); } [Fact] @@ -4511,6 +4564,108 @@ public void RunSearch_ExternalRecipeQueryScopeAndSeverityApply_Issue3826() } } + [Fact] + public void RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueries_Issue4862() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_search_recipe_aliases_4862"); + try + { + var dbPath = TestProjectHelper.CreateProjectDb(projectRoot); + var quotedDbDirectory = Path.Combine(projectRoot, "db folder"); + Directory.CreateDirectory(quotedDbDirectory); + var quotedDbPath = Path.Combine(quotedDbDirectory, "index.db"); + File.Copy(dbPath, quotedDbPath); + var recipePath = Path.Combine(projectRoot, "query-alias-recipes.json"); + File.WriteAllText( + recipePath, + """ + [ + { + "name": "local-selector-audit", + "description": "Exercise active-recipe query aliases.", + "queries": [ + { + "name": "current-query", + "aliases": ["short-query"], + "deprecated_aliases": ["old-query"], + "query": "CurrentNeedle", + "description": "Find the current marker." + }, + { + "name": "neighbor-query", + "query": "NeighborNeedle", + "description": "Find a neighboring marker." + }, + { + "name": "other-query", + "query": "OtherNeedle", + "description": "Find another marker." + } + ] + } + ] + """); + TestProjectHelper.InsertIndexedFile( + dbPath, + "src/app.cs", + "csharp", + "public sealed class App { private const string Value = \"CurrentNeedle\"; }\n"); + + using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); + env.Set(SearchAuditRecipes.RecipePathsEnvironmentVariable, recipePath); + + var (listExitCode, listStdout, listStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--list-recipes", "--json"], + _jsonOptions)); + var (aliasExitCode, aliasStdout, aliasStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "local-selector-audit/short-query", "--db", dbPath, "--json", "--limit", "5"], + _jsonOptions)); + var (deprecatedExitCode, deprecatedStdout, deprecatedStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "local-selector-audit", "--include-query", "old-query", "--db", dbPath, "--json", "--limit", "5"], + _jsonOptions)); + var (typoExitCode, typoStdout, typoStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "local-selector-audit/short-qurey", "--db", quotedDbPath, "--json"], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.Success, listExitCode); + Assert.Equal(string.Empty, listStderr); + using (var listDocument = ParseJsonOutput(listStdout)) + { + var query = listDocument.RootElement + .GetProperty("recipes") + .EnumerateArray() + .Single(recipe => recipe.GetProperty("name").GetString() == "local-selector-audit") + .GetProperty("queries") + .EnumerateArray() + .Single(item => item.GetProperty("name").GetString() == "current-query"); + Assert.Equal(["short-query"], query.GetProperty("aliases").EnumerateArray().Select(item => item.GetString()).ToArray()); + Assert.Equal(["old-query"], query.GetProperty("deprecated_aliases").EnumerateArray().Select(item => item.GetString()).ToArray()); + } + + Assert.Equal(CommandExitCodes.Success, aliasExitCode); + Assert.Equal(string.Empty, aliasStderr); + using (var aliasDocument = ParseJsonOutput(aliasStdout)) + Assert.Equal("current-query", Assert.Single(aliasDocument.RootElement.GetProperty("queries").EnumerateArray()).GetProperty("name").GetString()); + + Assert.Equal(CommandExitCodes.Success, deprecatedExitCode); + Assert.Equal(string.Empty, deprecatedStderr); + using (var deprecatedDocument = ParseJsonOutput(deprecatedStdout)) + Assert.Equal("current-query", Assert.Single(deprecatedDocument.RootElement.GetProperty("queries").EnumerateArray()).GetProperty("name").GetString()); + + Assert.Equal(CommandExitCodes.UsageError, typoExitCode); + Assert.Equal(string.Empty, typoStdout); + Assert.Contains("Did you mean: 'current-query'?", typoStderr); + Assert.Contains("--recipe local-selector-audit/current-query", typoStderr); + Assert.Contains($"--db '{quotedDbPath}'", typoStderr); + Assert.DoesNotContain("short-query --format", typoStderr); + Assert.DoesNotContain("risky-code/raw-diagnostic-echo", typoStderr); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunSearch_ExternalRecipeSourceReadIsBounded_Issues3826_3674() { From 5b5572583ac751ec130647da975e0b7897876732 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 21:33:43 +0900 Subject: [PATCH 2/3] Preserve audit selector retry semantics (#4862) --- TESTING_GUIDE.md | 2 + USER_GUIDE.md | 6 +- changelog.d/unreleased/4862.fixed.md | 4 +- .../Cli/QueryCommandRunner.SearchRecipes.cs | 101 ++++++++++++++++-- src/CodeIndex/Cli/SearchAuditRecipes.cs | 89 +++++++++++++++ .../QueryCommandRunnerSearchTests.cs | 65 ++++++++++- 6 files changed, 254 insertions(+), 13 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 90f801a77..9cbd16a38 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -514,6 +514,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding keeps the negative-cursor exception narrow by proving a recognized short option after `--cursor` remains an option and still produces the missing-cursor-value diagnostic. - `QueryCommandRunnerTests.RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862`, `RunSearch_RecipeQuerySuggestionHandlesZeroOneAndManyQueries_Issue4862`, and `RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueries_Issue4862` keep human/JSON usage errors, canonical names, current/deprecated aliases, deterministic typo correction, active-recipe isolation, normalized filter preservation, and shell-safe replay quoting in one recipe-selection contract. Suggestions and replay selectors must remain canonical names from the active recipe even when an alias is the nearest match. + Include/exclude typo recovery must preserve selector semantics, while aliases that collide with canonical names or multiple query owners must be removed with bounded diagnostics. - `QueryCommandRunnerTests.RunReferences_ExactJson_StylesheetAndSqlFixturesShareIndexedWorkspace` keeps SCSS variable/mixin/extend references beside SQL multiline MERGE hints, non-ASCII identifiers, quoted-string masking, and temporary-table body boundaries in one multi-file workspace with one CLI index. Use language-specific queries and unique per-file sentinels so count, kind, and line assertions remain independently diagnostic. - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` @@ -1418,6 +1419,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" は負の cursor に対する例外を狭く保ち、`--cursor` の直後にある既知の short option は cursor 値として消費されず、従来どおり cursor 値欠如の診断を返すことを検証します。 - `QueryCommandRunnerTests.RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862`、`RunSearch_RecipeQuerySuggestionHandlesZeroOneAndManyQueries_Issue4862`、`RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueries_Issue4862` は human / JSON usage error、canonical 名、現行 / deprecated alias、決定的な typo correction、active recipe への限定、正規化済み filter の保持、shell-safe な replay quoting を1つの recipe-selection 契約として維持します。alias が最も近い候補でも、suggestion と replay selector は active recipe の canonical 名でなければなりません。 + include / exclude の typo recovery は selector の意味を保持し、canonical 名または複数の query owner と衝突する alias は上限付き diagnostic とともに除去しなければなりません。 - `QueryCommandRunnerTests.RunReferences_ExactJson_StylesheetAndSqlFixturesShareIndexedWorkspace` は SCSS の variable / mixin / extend 参照と、SQL の multiline MERGE hint、非 ASCII identifier、quoted-string masking、temporary-table body boundary を、CLI index 1回の multi-file workspace で共有します。言語別 query と file ごとの固有 sentinel を使い、件数・kind・line assertion の診断性を独立に保ってください。 - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index d94ee0ac5..51d08f557 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1470,7 +1470,8 @@ recipe-level `default_scope`, `default_path_patterns`, and External queries may also declare `aliases` and `deprecated_aliases`; both forms select the canonical query name, appear in full recipe discovery JSON, and participate in active-recipe typo correction without becoming the replay -selector. +selector. Aliases that collide with a canonical name or span multiple queries +are ignored and reported through bounded recipe-source diagnostics. For triage automation, `--format issue-drafts` emits draft issue objects with titles, labels, evidence paths, severity/confidence/evidence-count triage metadata, Markdown bodies, and duplicate-preflight metadata. `--open-issues ` accepts an open-issue JSON list such as @@ -4667,7 +4668,8 @@ recipe array または `{ "recipes": [...] }` を受け付け、不正な source とは独立して query ごとの対象を狭められます。 外部 query は `aliases` と `deprecated_aliases` も宣言できます。どちらも canonical query 名へ 解決され、完全な recipe discovery JSON に表示され、active recipe 内の typo correction 候補に -使われますが、再実行 selector には canonical query 名が使われます。 +使われますが、再実行 selector には canonical query 名が使われます。canonical 名と衝突する alias、 +または複数 query にまたがる alias は無視され、上限付きの recipe-source diagnostic で報告されます。 triage automation では `--format issue-drafts` を使うと、title、label、evidence path、 severity / confidence / evidence-count の triage metadata、Markdown body、 duplicate-preflight metadata を持つ issue draft object を出力します。 diff --git a/changelog.d/unreleased/4862.fixed.md b/changelog.d/unreleased/4862.fixed.md index f64770910..7d940b8a6 100644 --- a/changelog.d/unreleased/4862.fixed.md +++ b/changelog.d/unreleased/4862.fixed.md @@ -12,8 +12,8 @@ affected: ## English -- **Unknown audit-query hints now stay within the active recipe (#4862)** — recipe child-query validation uses bounded deterministic similarity over canonical names and current/deprecated aliases from the selected recipe, emits canonical suggestions, and builds a safely quoted replay that preserves the active recipe and normalized filters instead of recommending `risky-code/raw-diagnostic-echo`. +- **Unknown audit-query hints now stay within the active recipe (#4862)** — recipe child-query validation uses bounded deterministic similarity over canonical names and current/deprecated aliases from the selected recipe, emits canonical suggestions, and builds a safely quoted replay that preserves the active recipe, include/exclude selector semantics, and normalized filters instead of recommending `risky-code/raw-diagnostic-echo`. Ambiguous aliases are ignored with bounded diagnostics. ## 日本語 -- **未知の audit query のヒントを active recipe 内に限定しました (#4862)** — recipe の child-query 検証は、選択済み recipe の canonical 名と現行 / deprecated alias に対して上限付きの決定的な類似度判定を行い、canonical な候補と、active recipe および正規化済み filter を保持した安全に引用済みの再実行コマンドを返します。無関係な `risky-code/raw-diagnostic-echo` は推奨しません。 +- **未知の audit query のヒントを active recipe 内に限定しました (#4862)** — recipe の child-query 検証は、選択済み recipe の canonical 名と現行 / deprecated alias に対して上限付きの決定的な類似度判定を行い、canonical な候補と、active recipe、include / exclude selector の意味、正規化済み filter を保持した安全に引用済みの再実行コマンドを返します。曖昧な alias は上限付き diagnostic とともに無視し、無関係な `risky-code/raw-diagnostic-echo` は推奨しません。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 8ef98850f..a35583ffc 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -285,8 +285,17 @@ private static bool TryResolveSearchRecipeSelection( options.IncludeRecipeQueries, "--include-query", out var invalidSelector, - out var selectorError) - || !TryValidateRecipeQuerySelectors( + out var selectorError)) + { + error = BuildUnknownRecipeQueryError( + recipe, + invalidSelector!, + selectorError!, + options, + SearchRecipeQuerySelectorMode.Include); + return false; + } + if (!TryValidateRecipeQuerySelectors( queryBySelector, availableQueries, recipe.Name, @@ -295,7 +304,12 @@ private static bool TryResolveSearchRecipeSelection( out invalidSelector, out selectorError)) { - error = BuildUnknownRecipeQueryError(recipe, invalidSelector!, selectorError!, options); + error = BuildUnknownRecipeQueryError( + recipe, + invalidSelector!, + selectorError!, + options, + SearchRecipeQuerySelectorMode.Exclude); return false; } if (directQueryName != null && !queryBySelector.ContainsKey(directQueryName)) @@ -304,7 +318,8 @@ private static bool TryResolveSearchRecipeSelection( recipe, directQueryName, $"unknown recipe query '{directQueryName}' for recipe '{recipe.Name}'. Available queries: {availableQueries}.", - options); + options, + SearchRecipeQuerySelectorMode.Direct); return false; } @@ -390,17 +405,25 @@ private static SearchRecipeSelectionError BuildUnknownRecipeQueryError( SearchAuditRecipe recipe, string rawSelector, string message, - QueryCommandOptions options) + QueryCommandOptions options, + SearchRecipeQuerySelectorMode selectorMode) { var suggestions = BuildSearchRecipeQuerySuggestions(recipe, rawSelector); var suggestionText = suggestions.Count > 0 ? $" Did you mean: {string.Join(", ", suggestions.Select(candidate => $"'{candidate}'"))}?" : string.Empty; - var replaySelector = suggestions.Count > 0 + var suggestedQueryName = suggestions.FirstOrDefault(); + var replaySelector = selectorMode == SearchRecipeQuerySelectorMode.Direct && suggestedQueryName != null ? $"{recipe.Name}/{suggestions[0]}" : recipe.Name; var hint = recipe.Queries.Count > 0 - ? $"Retry with `{BuildSearchRecipeSelectionReplayCommand(replaySelector, options)}`." + ? $"Retry with `{BuildSearchRecipeSelectionReplayCommand( + replaySelector, + options, + recipe, + selectorMode, + rawSelector, + suggestedQueryName)}`." : $"Recipe '{recipe.Name}' has no runnable queries; run `cdidx search --list-recipes` to choose another recipe."; return new(message + suggestionText, hint); } @@ -421,7 +444,13 @@ internal static IReadOnlyList BuildSearchRecipeQuerySuggestions( .ToList(); } - private static string BuildSearchRecipeSelectionReplayCommand(string recipeSelector, QueryCommandOptions options) + private static string BuildSearchRecipeSelectionReplayCommand( + string recipeSelector, + QueryCommandOptions options, + SearchAuditRecipe? recipe = null, + SearchRecipeQuerySelectorMode selectorMode = SearchRecipeQuerySelectorMode.Direct, + string? invalidSelector = null, + string? suggestedQueryName = null) { var args = new List { @@ -434,9 +463,65 @@ private static string BuildSearchRecipeSelectionReplayCommand(string recipeSelec }; AddReplayValueOption(args, "--limit", options.Limit.ToString(CultureInfo.InvariantCulture)); AddSearchRecipeCompactReplayOptions(args, options, includeRecipeQuerySelectors: false); + if (recipe != null && selectorMode != SearchRecipeQuerySelectorMode.Direct) + { + AddNormalizedRecipeQueryReplaySelectors( + args, + recipe, + options, + selectorMode, + invalidSelector!, + suggestedQueryName); + } return string.Join(" ", args.Select(QuoteReplayShellArg)); } + private static void AddNormalizedRecipeQueryReplaySelectors( + List args, + SearchAuditRecipe recipe, + QueryCommandOptions options, + SearchRecipeQuerySelectorMode invalidSelectorMode, + string invalidSelector, + string? suggestedQueryName) + { + var queryBySelector = BuildRecipeQuerySelectorMap(recipe); + AddSelectors("--include-query", options.IncludeRecipeQueries, SearchRecipeQuerySelectorMode.Include); + AddSelectors("--exclude-query", options.ExcludeRecipeQueries, SearchRecipeQuerySelectorMode.Exclude); + + void AddSelectors( + string optionName, + IReadOnlyList selectors, + SearchRecipeQuerySelectorMode selectorMode) + { + var emitted = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var selector in selectors) + { + string? canonicalName; + if (selectorMode == invalidSelectorMode + && string.Equals(selector, invalidSelector, StringComparison.OrdinalIgnoreCase)) + { + canonicalName = suggestedQueryName; + } + else + { + canonicalName = queryBySelector.TryGetValue(selector, out var query) + ? query.Name + : null; + } + + if (canonicalName != null && emitted.Add(canonicalName)) + AddReplayValueOption(args, optionName, canonicalName); + } + } + } + + private enum SearchRecipeQuerySelectorMode + { + Direct, + Include, + Exclude, + } + private sealed record SearchRecipeSelectionError( string Message, string Hint); diff --git a/src/CodeIndex/Cli/SearchAuditRecipes.cs b/src/CodeIndex/Cli/SearchAuditRecipes.cs index 9b8c7e78b..9d866dca6 100644 --- a/src/CodeIndex/Cli/SearchAuditRecipes.cs +++ b/src/CodeIndex/Cli/SearchAuditRecipes.cs @@ -3843,6 +3843,8 @@ private static bool TryParseRecipe( AddDiagnostic(diagnostics, $"{sourceLabel} recipe '{name}' has no valid queries and was ignored."); return false; } + if (!TryNormalizeRecipeQuerySelectors(queries, sourceLabel, name, diagnostics)) + return false; recipe = new SearchAuditRecipe(name, description, queries) { @@ -4142,6 +4144,93 @@ private static List ReadQuerySelectorAliases( return aliases; } + private static bool TryNormalizeRecipeQuerySelectors( + IReadOnlyList queries, + string sourceLabel, + string recipeName, + List diagnostics) + { + var canonicalOwners = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var query in queries) + { + if (canonicalOwners.TryAdd(query.Name, query)) + continue; + + AddDiagnostic( + diagnostics, + $"{sourceLabel} recipe '{recipeName}' defines duplicate canonical query name '{query.Name}' and was ignored."); + return false; + } + + var aliasClaims = new Dictionary>(StringComparer.OrdinalIgnoreCase); + foreach (var query in queries) + { + AddClaims(query, query.Aliases, deprecated: false); + AddClaims(query, query.DeprecatedAliases, deprecated: true); + } + + foreach (var (selector, claims) in aliasClaims) + { + if (canonicalOwners.TryGetValue(selector, out var canonicalOwner)) + { + foreach (var claim in claims) + RemoveSelector(claim, selector); + AddDiagnostic( + diagnostics, + $"{sourceLabel} recipe '{recipeName}' alias '{selector}' conflicts with canonical query '{canonicalOwner.Name}' and was ignored."); + continue; + } + + var distinctOwners = claims + .Select(claim => claim.Query) + .Distinct() + .ToList(); + if (distinctOwners.Count > 1) + { + foreach (var claim in claims) + RemoveSelector(claim, selector); + AddDiagnostic( + diagnostics, + $"{sourceLabel} recipe '{recipeName}' alias '{selector}' is shared by multiple queries and was ignored."); + continue; + } + + if (claims.Any(claim => !claim.Deprecated) && claims.Any(claim => claim.Deprecated)) + { + foreach (var claim in claims.Where(claim => claim.Deprecated)) + RemoveSelector(claim, selector); + AddDiagnostic( + diagnostics, + $"{sourceLabel} recipe '{recipeName}' query '{distinctOwners[0].Name}' declares alias '{selector}' as both current and deprecated; the deprecated entry was ignored."); + } + } + + return true; + + void AddClaims(SearchAuditRecipeQuery query, IEnumerable selectors, bool deprecated) + { + foreach (var selector in selectors) + { + if (!aliasClaims.TryGetValue(selector, out var claims)) + { + claims = []; + aliasClaims.Add(selector, claims); + } + claims.Add((query, deprecated)); + } + } + + static void RemoveSelector( + (SearchAuditRecipeQuery Query, bool Deprecated) claim, + string selector) + { + var target = claim.Deprecated + ? claim.Query.DeprecatedAliases + : claim.Query.Aliases; + target.RemoveAll(value => string.Equals(value, selector, StringComparison.OrdinalIgnoreCase)); + } + } + private static void AddDiagnostic(List diagnostics, string message) { if (diagnostics.Count >= MaxRecipeDiagnosticCount) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index dc95024e7..29a97210e 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -3692,6 +3692,39 @@ public void RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862(b var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( [.. args], _jsonOptions)); + var includeArgs = new List + { + "--recipe", + "xml-parser-security", + "--include-query", + "dtd-processing", + "--include-query", + "xml-readr-settings", + "--exclude-query", + "xml-resolver", + "--limit", + "7", + }; + var excludeArgs = new List + { + "--recipe", + "xml-parser-security", + "--exclude-query", + "xml-resovler", + "--limit", + "7", + }; + if (json) + { + includeArgs.Add("--json"); + excludeArgs.Add("--json"); + } + var (includeExitCode, includeStdout, includeStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [.. includeArgs], + _jsonOptions)); + var (excludeExitCode, excludeStdout, excludeStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + [.. excludeArgs], + _jsonOptions)); Assert.Equal(CommandExitCodes.UsageError, exitCode); Assert.Equal(string.Empty, stdout); @@ -3702,6 +3735,21 @@ public void RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862(b stderr); Assert.DoesNotContain("risky-code/raw-diagnostic-echo", stderr); Assert.DoesNotContain("Suggestions across all recipes", stderr); + + Assert.Equal(CommandExitCodes.UsageError, includeExitCode); + Assert.Equal(string.Empty, includeStdout); + Assert.Contains( + "cdidx search --recipe xml-parser-security --format compact --limit 7" + + " --include-query dtd-processing --include-query xml-reader-settings --exclude-query xml-resolver", + includeStderr); + Assert.DoesNotContain("--recipe xml-parser-security/xml-reader-settings", includeStderr); + + Assert.Equal(CommandExitCodes.UsageError, excludeExitCode); + Assert.Equal(string.Empty, excludeStdout); + Assert.Contains( + "cdidx search --recipe xml-parser-security --format compact --limit 7 --exclude-query xml-resolver", + excludeStderr); + Assert.DoesNotContain("--recipe xml-parser-security/xml-resolver", excludeStderr); } [Fact] @@ -4586,18 +4634,20 @@ public void RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueri "queries": [ { "name": "current-query", - "aliases": ["short-query"], + "aliases": ["short-query", "neighbor-query", "shared-query"], "deprecated_aliases": ["old-query"], "query": "CurrentNeedle", "description": "Find the current marker." }, { "name": "neighbor-query", + "aliases": ["shared-query"], "query": "NeighborNeedle", "description": "Find a neighboring marker." }, { "name": "other-query", + "deprecated_aliases": ["current-query"], "query": "OtherNeedle", "description": "Find another marker." } @@ -4613,6 +4663,7 @@ public void RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueri using var env = EnvironmentVariableScope.Capture(SearchAuditRecipes.RecipePathsEnvironmentVariable); env.Set(SearchAuditRecipes.RecipePathsEnvironmentVariable, recipePath); + var registry = SearchAuditRecipes.Load(); var (listExitCode, listStdout, listStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( ["--list-recipes", "--json"], @@ -4626,6 +4677,13 @@ public void RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueri var (typoExitCode, typoStdout, typoStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( ["--recipe", "local-selector-audit/short-qurey", "--db", quotedDbPath, "--json"], _jsonOptions)); + var (collisionExitCode, collisionStdout, collisionStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "local-selector-audit/shared-query", "--db", dbPath, "--json"], + _jsonOptions)); + + Assert.Contains(registry.Diagnostics, diagnostic => diagnostic.Contains("alias 'neighbor-query' conflicts with canonical query", StringComparison.Ordinal)); + Assert.Contains(registry.Diagnostics, diagnostic => diagnostic.Contains("alias 'shared-query' is shared by multiple queries", StringComparison.Ordinal)); + Assert.Contains(registry.Diagnostics, diagnostic => diagnostic.Contains("alias 'current-query' conflicts with canonical query", StringComparison.Ordinal)); Assert.Equal(CommandExitCodes.Success, listExitCode); Assert.Equal(string.Empty, listStderr); @@ -4659,6 +4717,11 @@ public void RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueri Assert.Contains($"--db '{quotedDbPath}'", typoStderr); Assert.DoesNotContain("short-query --format", typoStderr); Assert.DoesNotContain("risky-code/raw-diagnostic-echo", typoStderr); + + Assert.Equal(CommandExitCodes.UsageError, collisionExitCode); + Assert.Equal(string.Empty, collisionStdout); + Assert.Contains("unknown recipe query 'shared-query'", collisionStderr); + Assert.Contains("Did you mean: 'current-query'?", collisionStderr); } finally { From 95eee0e26bce5df72bc85c33f49bbe90b5900b24 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 22:02:01 +0900 Subject: [PATCH 3/3] Preserve safe audit retry filters (#4862) --- TESTING_GUIDE.md | 4 +-- USER_GUIDE.md | 7 ++-- changelog.d/unreleased/4862.fixed.md | 4 +-- .../Cli/QueryCommandRunner.SearchRecipes.cs | 36 ++++++++++++++----- .../QueryCommandRunnerSearchTests.cs | 26 ++++++++++++-- 5 files changed, 60 insertions(+), 17 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 9cbd16a38..696decc39 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -514,7 +514,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding keeps the negative-cursor exception narrow by proving a recognized short option after `--cursor` remains an option and still produces the missing-cursor-value diagnostic. - `QueryCommandRunnerTests.RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862`, `RunSearch_RecipeQuerySuggestionHandlesZeroOneAndManyQueries_Issue4862`, and `RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueries_Issue4862` keep human/JSON usage errors, canonical names, current/deprecated aliases, deterministic typo correction, active-recipe isolation, normalized filter preservation, and shell-safe replay quoting in one recipe-selection contract. Suggestions and replay selectors must remain canonical names from the active recipe even when an alias is the nearest match. - Include/exclude typo recovery must preserve selector semantics, while aliases that collide with canonical names or multiple query owners must be removed with bounded diagnostics. + Include/exclude typo recovery must preserve selector semantics, unresolved repeated selectors, and raw FTS mode. Missing close matches and recipe-name corrections with child selectors must not offer a replay that broadens the selection, while aliases that collide with canonical names or multiple query owners must be removed with bounded diagnostics. - `QueryCommandRunnerTests.RunReferences_ExactJson_StylesheetAndSqlFixturesShareIndexedWorkspace` keeps SCSS variable/mixin/extend references beside SQL multiline MERGE hints, non-ASCII identifiers, quoted-string masking, and temporary-table body boundaries in one multi-file workspace with one CLI index. Use language-specific queries and unique per-file sentinels so count, kind, and line assertions remain independently diagnostic. - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` @@ -1419,7 +1419,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" は負の cursor に対する例外を狭く保ち、`--cursor` の直後にある既知の short option は cursor 値として消費されず、従来どおり cursor 値欠如の診断を返すことを検証します。 - `QueryCommandRunnerTests.RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862`、`RunSearch_RecipeQuerySuggestionHandlesZeroOneAndManyQueries_Issue4862`、`RunSearch_ExternalRecipeAliasesResolveAndSuggestCanonicalActiveQueries_Issue4862` は human / JSON usage error、canonical 名、現行 / deprecated alias、決定的な typo correction、active recipe への限定、正規化済み filter の保持、shell-safe な replay quoting を1つの recipe-selection 契約として維持します。alias が最も近い候補でも、suggestion と replay selector は active recipe の canonical 名でなければなりません。 - include / exclude の typo recovery は selector の意味を保持し、canonical 名または複数の query owner と衝突する alias は上限付き diagnostic とともに除去しなければなりません。 + include / exclude の typo recovery は selector の意味、未解決の繰り返し selector、raw FTS mode を保持します。近い候補がない場合、および child selector を伴う recipe 名訂正では selection を広げる replay を提示せず、canonical 名または複数の query owner と衝突する alias は上限付き diagnostic とともに除去しなければなりません。 - `QueryCommandRunnerTests.RunReferences_ExactJson_StylesheetAndSqlFixturesShareIndexedWorkspace` は SCSS の variable / mixin / extend 参照と、SQL の multiline MERGE hint、非 ASCII identifier、quoted-string masking、temporary-table body boundary を、CLI index 1回の multi-file workspace で共有します。言語別 query と file ごとの固有 sentinel を使い、件数・kind・line assertion の診断性を独立に保ってください。 - `QueryCommandRunnerTests.RunReferences_ExactJson_CSharpQueryBoundariesAndVisualBasicQuerySyntaxShareIndexedWorkspace` diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 51d08f557..143f33800 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1455,7 +1455,9 @@ Recipe JSON and compact output apply `--limit` per query, include a `summary` Unknown child-query diagnostics compare only the active recipe's canonical query names and aliases. Their replay command keeps that recipe and normalized search filters, quotes shell-sensitive values, and never substitutes a query -from another recipe. +from another recipe. A replay is emitted only when a close canonical match +exists; otherwise the diagnostic keeps the available-query list without +offering a command that would broaden the selection. The MCP `search` tool exposes the same recipe surface with `{"listRecipes":true}` for discovery and `{"recipe":"risky-code"}` for execution. MCP recipe runs apply the same default source scope as the CLI; pass @@ -4655,7 +4657,8 @@ list metadata ごとに grouped されるため usage error で拒否します `--recipe / --cursor ` として再実行してください。 未知の child query の診断は、active recipe 内の canonical query 名と alias だけを比較します。 再実行コマンドは同じ recipe と正規化済み search filter を保持し、shell で意味を持つ値を引用し、 -別 recipe の query へ置き換えることはありません。 +別 recipe の query へ置き換えることはありません。近い canonical 候補がある場合だけ再実行コマンドを +返し、候補がない場合は selection を広げるコマンドを提示せず、利用可能な query の一覧を維持します。 MCP `search` tool では `{"listRecipes":true}` で recipe を発見し、 `{"recipe":"risky-code"}` で実行できます。MCP の recipe run も CLI と同じ既定の source scope を適用します。docs、tests、changelog、recipe definitions を意図的に audit する場合は diff --git a/changelog.d/unreleased/4862.fixed.md b/changelog.d/unreleased/4862.fixed.md index 7d940b8a6..a09b543f6 100644 --- a/changelog.d/unreleased/4862.fixed.md +++ b/changelog.d/unreleased/4862.fixed.md @@ -12,8 +12,8 @@ affected: ## English -- **Unknown audit-query hints now stay within the active recipe (#4862)** — recipe child-query validation uses bounded deterministic similarity over canonical names and current/deprecated aliases from the selected recipe, emits canonical suggestions, and builds a safely quoted replay that preserves the active recipe, include/exclude selector semantics, and normalized filters instead of recommending `risky-code/raw-diagnostic-echo`. Ambiguous aliases are ignored with bounded diagnostics. +- **Unknown audit-query hints now stay within the active recipe (#4862)** — recipe child-query validation uses bounded deterministic similarity over canonical names and current/deprecated aliases from the selected recipe, emits canonical suggestions, and builds a safely quoted replay that preserves the active recipe, include/exclude selector semantics, unresolved repeated selectors, raw FTS mode, and normalized filters instead of recommending `risky-code/raw-diagnostic-echo`. When no close canonical match exists, the diagnostic avoids a replay that would broaden the selection. Ambiguous aliases are ignored with bounded diagnostics. ## 日本語 -- **未知の audit query のヒントを active recipe 内に限定しました (#4862)** — recipe の child-query 検証は、選択済み recipe の canonical 名と現行 / deprecated alias に対して上限付きの決定的な類似度判定を行い、canonical な候補と、active recipe、include / exclude selector の意味、正規化済み filter を保持した安全に引用済みの再実行コマンドを返します。曖昧な alias は上限付き diagnostic とともに無視し、無関係な `risky-code/raw-diagnostic-echo` は推奨しません。 +- **未知の audit query のヒントを active recipe 内に限定しました (#4862)** — recipe の child-query 検証は、選択済み recipe の canonical 名と現行 / deprecated alias に対して上限付きの決定的な類似度判定を行い、canonical な候補と、active recipe、include / exclude selector の意味、未解決の繰り返し selector、raw FTS mode、正規化済み filter を保持した安全に引用済みの再実行コマンドを返します。近い canonical 候補がない場合は selection を広げる再実行コマンドを提示しません。曖昧な alias は上限付き diagnostic とともに無視し、無関係な `risky-code/raw-diagnostic-echo` は推奨しません。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index a35583ffc..6a018837f 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -267,8 +267,14 @@ private static bool TryResolveSearchRecipeSelection( var suggestionText = suggestions.Count > 0 ? $" Did you mean: {string.Join(", ", suggestions.Select(candidate => $"'{candidate}'"))}?" : string.Empty; - var hint = suggestions.Count > 0 + var canReplaySuggestedRecipe = suggestions.Count > 0 + && directQueryName == null + && options.IncludeRecipeQueries.Count == 0 + && options.ExcludeRecipeQueries.Count == 0; + var hint = canReplaySuggestedRecipe ? $"Retry with `{BuildSearchRecipeSelectionReplayCommand(suggestions[0], options)}`." + : suggestions.Count > 0 + ? "Correct the recipe name while retaining the requested query selectors, or run `cdidx search --list-recipes` to inspect the available recipes." : "Run `cdidx search --list-recipes` to choose an available recipe."; error = new( $"unknown search recipe '{recipeName}'. Available recipes: {available}.{suggestionText}", @@ -413,18 +419,28 @@ private static SearchRecipeSelectionError BuildUnknownRecipeQueryError( ? $" Did you mean: {string.Join(", ", suggestions.Select(candidate => $"'{candidate}'"))}?" : string.Empty; var suggestedQueryName = suggestions.FirstOrDefault(); - var replaySelector = selectorMode == SearchRecipeQuerySelectorMode.Direct && suggestedQueryName != null - ? $"{recipe.Name}/{suggestions[0]}" - : recipe.Name; - var hint = recipe.Queries.Count > 0 - ? $"Retry with `{BuildSearchRecipeSelectionReplayCommand( + string hint; + if (suggestedQueryName != null) + { + var replaySelector = selectorMode == SearchRecipeQuerySelectorMode.Direct + ? $"{recipe.Name}/{suggestedQueryName}" + : recipe.Name; + hint = $"Retry with `{BuildSearchRecipeSelectionReplayCommand( replaySelector, options, recipe, selectorMode, rawSelector, - suggestedQueryName)}`." - : $"Recipe '{recipe.Name}' has no runnable queries; run `cdidx search --list-recipes` to choose another recipe."; + suggestedQueryName)}`."; + } + else if (recipe.Queries.Count > 0) + { + hint = $"Choose one of the available queries listed above for recipe '{recipe.Name}'; no retry command was generated because no close match was found."; + } + else + { + hint = $"Recipe '{recipe.Name}' has no runnable queries; run `cdidx search --list-recipes` to choose another recipe."; + } return new(message + suggestionText, hint); } @@ -506,7 +522,7 @@ void AddSelectors( { canonicalName = queryBySelector.TryGetValue(selector, out var query) ? query.Name - : null; + : selector; } if (canonicalName != null && emitted.Add(canonicalName)) @@ -1106,6 +1122,8 @@ private static void AddSearchRecipeCompactReplayOptions(List args, Query args.Add("--no-dedup"); if (options.NoVisibilityRank) args.Add("--no-visibility-rank"); + if (options.RawFts) + args.Add("--fts"); if (options.Exact) args.Add("--exact"); if (options.ExactSubstring) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs index 29a97210e..80477fa6b 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchTests.cs @@ -3683,6 +3683,7 @@ public void RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862(b "xml-parser-security/xml-readr-settings", "--path", "src folder/**", + "--fts", "--limit", "7", }; @@ -3700,6 +3701,8 @@ public void RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862(b "dtd-processing", "--include-query", "xml-readr-settings", + "--include-query", + "completely-unrelated-child", "--exclude-query", "xml-resolver", "--limit", @@ -3725,13 +3728,19 @@ public void RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862(b var (excludeExitCode, excludeStdout, excludeStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( [.. excludeArgs], _jsonOptions)); + var (noSuggestionExitCode, noSuggestionStdout, noSuggestionStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "xml-parser-security/completely-unrelated-child"], + _jsonOptions)); + var (recipeTypoExitCode, recipeTypoStdout, recipeTypoStderr) = CaptureConsole(() => QueryCommandRunner.RunSearch( + ["--recipe", "xml-parser-securit/xml-reader-settings"], + _jsonOptions)); Assert.Equal(CommandExitCodes.UsageError, exitCode); Assert.Equal(string.Empty, stdout); Assert.Contains("unknown recipe query 'xml-readr-settings' for recipe 'xml-parser-security'", stderr); Assert.Contains("Did you mean: 'xml-reader-settings'?", stderr); Assert.Contains( - "cdidx search --recipe xml-parser-security/xml-reader-settings --format compact --limit 7 --path 'src folder/**'", + "cdidx search --recipe xml-parser-security/xml-reader-settings --format compact --limit 7 --path 'src folder/**' --fts", stderr); Assert.DoesNotContain("risky-code/raw-diagnostic-echo", stderr); Assert.DoesNotContain("Suggestions across all recipes", stderr); @@ -3740,7 +3749,8 @@ public void RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862(b Assert.Equal(string.Empty, includeStdout); Assert.Contains( "cdidx search --recipe xml-parser-security --format compact --limit 7" - + " --include-query dtd-processing --include-query xml-reader-settings --exclude-query xml-resolver", + + " --include-query dtd-processing --include-query xml-reader-settings" + + " --include-query completely-unrelated-child --exclude-query xml-resolver", includeStderr); Assert.DoesNotContain("--recipe xml-parser-security/xml-reader-settings", includeStderr); @@ -3750,6 +3760,18 @@ public void RunSearch_UnknownRecipeQuerySuggestsOnlyFromActiveRecipe_Issue4862(b "cdidx search --recipe xml-parser-security --format compact --limit 7 --exclude-query xml-resolver", excludeStderr); Assert.DoesNotContain("--recipe xml-parser-security/xml-resolver", excludeStderr); + + Assert.Equal(CommandExitCodes.UsageError, noSuggestionExitCode); + Assert.Equal(string.Empty, noSuggestionStdout); + Assert.Contains("unknown recipe query 'completely-unrelated-child'", noSuggestionStderr); + Assert.Contains("no retry command was generated because no close match was found", noSuggestionStderr); + Assert.DoesNotContain("Retry with `", noSuggestionStderr); + + Assert.Equal(CommandExitCodes.UsageError, recipeTypoExitCode); + Assert.Equal(string.Empty, recipeTypoStdout); + Assert.Contains("Did you mean: 'xml-parser-security'?", recipeTypoStderr); + Assert.Contains("retaining the requested query selectors", recipeTypoStderr); + Assert.DoesNotContain("Retry with `", recipeTypoStderr); } [Fact]