diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 7aec5917fd..ecd1e44960 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -429,6 +429,7 @@ content. ```bash cdidx unused --lang csharp --exclude-tests cdidx unused --kind function --path src/ --limit 50 +cdidx unused --bucket likely_unused_private --min-confidence medium cdidx unused --json --count cdidx unused --json --by-bucket ``` @@ -438,8 +439,10 @@ confidence. JSON output includes `summary.by_bucket`, `summary.by_confidence`, and `bucket_taxonomy` for the `likely_unused_private`, `maybe_unused_nonpublic`, `public_or_exported_no_refs`, and `reflection_or_config_suspect` buckets; `--by-bucket` also groups returned -symbols under those bucket keys. Public APIs, framework entrypoints, generated -hooks, reflection, and configuration-based usage can be false positives. C# +symbols under those bucket keys. Use `--bucket ` to return only one +bucket, and `--min-confidence ` to omit lower-confidence classes. +Public APIs, framework entrypoints, generated hooks, reflection, and +configuration-based usage can be false positives. C# `nameof(...)`, `typeof(...)`, and direct reflection member-name literals such as `GetMethod("Foo")` are indexed, but dynamically constructed names still require manual review. @@ -2625,6 +2628,7 @@ object ではなく bare issue array を期待する場合は `--json=array` を ```bash cdidx unused --lang csharp --exclude-tests cdidx unused --kind function --path src/ --limit 50 +cdidx unused --bucket likely_unused_private --min-confidence medium cdidx unused --json --count cdidx unused --json --by-bucket ``` @@ -2633,8 +2637,9 @@ cdidx unused --json --by-bucket 分類します。JSON 出力には `likely_unused_private`、`maybe_unused_nonpublic`、 `public_or_exported_no_refs`、`reflection_or_config_suspect` bucket 用の `summary.by_bucket`、`summary.by_confidence`、`bucket_taxonomy` が含まれます。 -`--by-bucket` は返却された symbols も bucket key ごとに grouped します。Public API、 -framework entrypoint、generated hook、reflection、config 経由の使用は false positive +`--by-bucket` は返却された symbols も bucket key ごとに grouped します。 +`--bucket ` で単一 bucket だけを返し、`--min-confidence ` で +より低い confidence class を除外できます。Public API、framework entrypoint、generated hook、reflection、config 経由の使用は false positive になりえます。C# の `nameof(...)`、`typeof(...)`、`GetMethod("Foo")` のような 直接的な reflection member-name literal は indexed されますが、動的に組み立てられる 名前は手動確認が必要です。 diff --git a/changelog.d/unreleased/3001.fixed.md b/changelog.d/unreleased/3001.fixed.md new file mode 100644 index 0000000000..fc632c573a --- /dev/null +++ b/changelog.d/unreleased/3001.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 3001 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Database/DbSymbolReader.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - USER_GUIDE.md +--- + +## English + +- **`unused` can now filter by bucket and confidence (#3001)** - CLI users can pass `--bucket ` or `--min-confidence `, and MCP `unused_symbols` accepts matching `bucket` and `minConfidence` arguments for focused dead-code audits. + +## 日本語 + +- **`unused` が bucket と confidence で絞り込めるようになりました (#3001)** - CLI では `--bucket ` または `--min-confidence ` を指定でき、MCP `unused_symbols` も対応する `bucket` / `minConfidence` 引数で dead-code audit の対象を絞り込めます。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index b25addc18b..513143b734 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -119,6 +119,7 @@ internal static class CliFlagSchema private static readonly string[] RawKindsCommands = ["callers", "callees"]; private static readonly string[] RankByCommands = ["callers", "callees"]; private static readonly string[] ByBucketCommands = ["unused"]; + private static readonly string[] UnusedFilterCommands = ["unused"]; private static readonly string[] AllResultCommands = ["goto"]; private static readonly string[] SinceCommands = ["search", "definition", "symbols", "files"]; @@ -235,6 +236,8 @@ private static IReadOnlyList BuildAll() new() { Name = "--visibility", ValuePlaceholder = "", Description = "Filter by symbol visibility", Commands = Set(VisibilityCommands) }, new() { Name = "--exclude-visibility", ValuePlaceholder = "", Description = "Exclude symbol visibility", Commands = Set(VisibilityCommands) }, new() { Name = "--by-bucket", Description = "Unused: include per-bucket grouped result arrays in JSON output", Commands = Set(ByBucketCommands) }, + new() { Name = "--bucket", ValuePlaceholder = "", Description = "Unused: return only one confidence bucket", Commands = Set(UnusedFilterCommands) }, + new() { Name = "--min-confidence", ValuePlaceholder = "", Description = "Unused: return symbols at or above this confidence", Commands = Set(UnusedFilterCommands) }, new() { Name = "--all", Description = "goto: return all matching LSP locations instead of requiring a single target", Commands = Set(AllResultCommands) }, new() { Name = "--rank-by", ValuePlaceholder = "", Description = "Rank callers/callees by weighted structural score, raw count, or kind bucket", Commands = Set(RankByCommands) }, new() { Name = "--raw-kinds", Description = "Show raw reference kinds instead of logical graph kinds", Commands = Set(RawKindsCommands) }, diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 193ba4221d..c3b1098f4c 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -102,7 +102,7 @@ private static readonly (string Command, string Usage)[] CommandUsageLines = ("validate", "cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]"), ("impact", "cdidx impact |--query |-- [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--body] [--snippet-lines ] [--max-line-width ] [--max-hops ] [--count] [--with-paths]"), ("deps", "cdidx deps [--db ] [--json] [--format ] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--reverse] [--cycles]"), - ("unused", "cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]"), + ("unused", "cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--bucket ] [--min-confidence ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]"), ("hotspots", "cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]"), ("suggestions", "cdidx suggestions [id] [--db ] [--json] [--status ] [--language ] [--category ] [--since ] [--agent ] [--format ] [--open-issues ]"), ("export", "cdidx export [--db ] [--json]"), diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index a1207f2aca..4204b6353a 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -84,6 +84,8 @@ public static class QueryCommandRunner "--top", "--lang", "--kind", + "--bucket", + "--min-confidence", "--severity", "--visibility", "--exclude-visibility", @@ -4655,6 +4657,8 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.UsageError; if (TryWriteInvalidKindFilterError(options, "unused", KnownSymbolKindFilters)) return CommandExitCodes.InvalidArgument; + if (TryWriteInvalidUnusedFilterError(options)) + return CommandExitCodes.InvalidArgument; if (TryWriteUnexpectedPositionals("unused", options)) return CommandExitCodes.UsageError; @@ -4672,7 +4676,16 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) reader.ScopeMayIncludeSqlSymbols(options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests)); if (options.CountOnly) { - var countSummary = reader.CountUnusedSymbols(options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, visibilityFilters: options.VisibilityFilters, excludeVisibilityFilters: options.ExcludeVisibilityFilters); + var countSummary = reader.CountUnusedSymbols( + options.Kind, + options.Lang, + options.PathPatterns, + options.ExcludePaths, + options.ExcludeTests, + visibilityFilters: options.VisibilityFilters, + excludeVisibilityFilters: options.ExcludeVisibilityFilters, + bucketFilter: options.UnusedBucket, + minConfidence: options.MinUnusedConfidence); var effectiveSqlGraphSignal = countSummary.Count == 0 ? zeroResultSqlGraphSignal : NarrowSqlGraphContractSignal( @@ -4704,7 +4717,17 @@ public static int RunUnused(string[] cmdArgs, JsonSerializerOptions jsonOptions) return CommandExitCodes.Success; } - var results = reader.GetUnusedSymbols(options.Limit, options.Kind, options.Lang, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, visibilityFilters: options.VisibilityFilters, excludeVisibilityFilters: options.ExcludeVisibilityFilters); + var results = reader.GetUnusedSymbols( + options.Limit, + options.Kind, + options.Lang, + options.PathPatterns, + options.ExcludePaths, + options.ExcludeTests, + visibilityFilters: options.VisibilityFilters, + excludeVisibilityFilters: options.ExcludeVisibilityFilters, + bucketFilter: options.UnusedBucket, + minConfidence: options.MinUnusedConfidence); var sqlGraphSignal = results.Count == 0 ? zeroResultSqlGraphSignal : NarrowSqlGraphContractSignalByLanguages( @@ -5159,6 +5182,8 @@ public static QueryCommandOptions ParseArgs( int limit = ResolveDefaultPositiveInt(DefaultLimitEnvironmentVariable, DefaultQueryLimit, "--limit", out var defaultLimitError); string? lang = null; string? kind = null; + string? unusedBucket = null; + string? minUnusedConfidence = null; string? severity = null; string? query = null; bool rawFts = false; @@ -5514,6 +5539,24 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) else AddParseError(kindError!); break; + case "--bucket": + if (TryReadStringOptionValue(args, ref i, "--bucket", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var unusedBucketValue, out var unusedBucketError)) + { + WarnIfDuplicateSingleValueOption("--bucket", unusedBucketValue!); + unusedBucket = unusedBucketValue?.ToLowerInvariant(); + } + else + AddParseError(unusedBucketError!); + break; + case "--min-confidence": + if (TryReadStringOptionValue(args, ref i, "--min-confidence", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var minUnusedConfidenceValue, out var minUnusedConfidenceError)) + { + WarnIfDuplicateSingleValueOption("--min-confidence", minUnusedConfidenceValue!); + minUnusedConfidence = minUnusedConfidenceValue?.ToLowerInvariant(); + } + else + AddParseError(minUnusedConfidenceError!); + break; case "--severity": if (TryReadStringOptionValue(args, ref i, "--severity", inlineValue, allowSeparatedDashPrefixedLiteralValue: false, out var severityValue, out var severityError)) { @@ -5986,6 +6029,8 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) Limit = limit, Lang = lang, Kind = kind, + UnusedBucket = unusedBucket, + MinUnusedConfidence = minUnusedConfidence, Severity = severity, Query = query, RawFts = rawFts, @@ -6917,6 +6962,35 @@ private static bool TryWriteInvalidKindFilterError(QueryCommandOptions options, return false; } + internal static bool IsKnownUnusedBucket(string value) + => OrderedUnusedBuckets.Contains(value, StringComparer.Ordinal); + + internal static bool IsKnownUnusedConfidence(string value) + => value is "medium" or "low"; + + private static bool TryWriteInvalidUnusedFilterError(QueryCommandOptions options) + { + if (options.UnusedBucket != null && !IsKnownUnusedBucket(options.UnusedBucket)) + { + CommandErrorWriter.Write( + $"invalid --bucket value `{options.UnusedBucket}`.", + $"use one of: {string.Join(", ", OrderedUnusedBuckets)}.", + GetUsageLineOrThrow("unused")); + return true; + } + + if (options.MinUnusedConfidence != null && !IsKnownUnusedConfidence(options.MinUnusedConfidence)) + { + CommandErrorWriter.Write( + $"invalid --min-confidence value `{options.MinUnusedConfidence}`.", + "use one of: medium, low.", + GetUsageLineOrThrow("unused")); + return true; + } + + return false; + } + private static bool TryWriteUnsupportedOptionError(string commandName, string[] cmdArgs, IEnumerable supportedOptions, string? queryLiteral = null) { var supported = supportedOptions.ToHashSet(StringComparer.Ordinal); @@ -7228,6 +7302,10 @@ private static IEnumerable BuildQueryContextParts(QueryCommandOptions op yield return $"lang: {options.Lang}"; if (options.Kind != null) yield return $"kind: {options.Kind}"; + if (options.UnusedBucket != null) + yield return $"bucket: {options.UnusedBucket}"; + if (options.MinUnusedConfidence != null) + yield return $"min-confidence: {options.MinUnusedConfidence}"; if (options.RankMode != ReferenceRankMode.Weighted) yield return $"rank-by: {FormatReferenceRankMode(options.RankMode)}"; if (options.ExcludeTests) @@ -7268,6 +7346,10 @@ private static JsonObject BuildQueryContextJson(QueryCommandOptions options, Jso query["lang"] = options.Lang; if (options.Kind != null) query["kind"] = options.Kind; + if (options.UnusedBucket != null) + query["bucket"] = options.UnusedBucket; + if (options.MinUnusedConfidence != null) + query["min_confidence"] = options.MinUnusedConfidence; if (options.RankMode != ReferenceRankMode.Weighted) query["rank_by"] = FormatReferenceRankMode(options.RankMode); if (options.ExcludeTests) @@ -8438,6 +8520,8 @@ private static void WriteSqlGraphContractWarningIfNeeded(bool json, SqlGraphCont ["--lang"] = "pass a language identifier, e.g. `--lang csharp`. Run `cdidx languages` for the supported set.", ["--query"] = "pass a search literal, e.g. `--query \"authenticate\"`. Use the `--query` form when the literal starts with `-`.", ["--kind"] = "pass a kind identifier, e.g. `--kind function`. definition/symbols/hotspots/unused take a symbol kind; references/callers/callees take a reference kind such as `call`, `instantiate`, or `subscribe`. Run the command's `--help` for the kind list.", + ["--bucket"] = "pass one unused-symbol bucket: likely_unused_private, maybe_unused_nonpublic, public_or_exported_no_refs, or reflection_or_config_suspect.", + ["--min-confidence"] = "pass one unused-symbol confidence threshold: medium or low.", ["--visibility"] = "pass one or more of public, protected, internal, private, e.g. `--visibility public,internal`.", ["--exclude-visibility"] = "pass one or more of public, protected, internal, private to exclude, e.g. `--exclude-visibility private`.", ["--rank-by"] = "pass `weighted`, `count`, or `kind` (callers/callees only).", @@ -8791,6 +8875,8 @@ public sealed class QueryCommandOptions public int Limit { get; init; } = 20; public string? Lang { get; init; } public string? Kind { get; init; } + public string? UnusedBucket { get; init; } + public string? MinUnusedConfidence { get; init; } public string? Severity { get; init; } public List VisibilityFilters { get; init; } = []; public List ExcludeVisibilityFilters { get; init; } = []; diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index d72f0543a3..3b6e0dab0f 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -2788,7 +2788,17 @@ FROM symbols s_peer )"; } - public List GetUnusedSymbols(int limit, string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + public List GetUnusedSymbols( + int limit, + string? kind, + string? lang, + IReadOnlyList? pathPatterns, + IReadOnlyList? excludePathPatterns, + bool excludeTests, + IReadOnlyList? visibilityFilters = null, + IReadOnlyList? excludeVisibilityFilters = null, + string? bucketFilter = null, + string? minConfidence = null) { // Without symbol_references (legacy read-only DB), every symbol would appear unused, // which is a meaningless signal. Return empty rather than drowning the caller in noise. @@ -2801,8 +2811,10 @@ public List GetUnusedSymbols(int limit, string? kind, string // (unsupported languages have no references indexed, so all symbols appear unused) // グラフ対応言語に制限して偽陽性を防ぐ // (未対応言語は参照がインデックスされないため全シンボルが未使用に見える) + if (HasEffectiveUnusedFilter(bucketFilter, minConfidence)) + return GetFilteredUnusedSymbols(limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters, bucketFilter, minConfidence); if (!ScopeMayIncludeSqlSymbols(kind, lang, pathPatterns, excludePathPatterns, excludeTests)) - return GetUnusedSymbolsWithoutSqlResolver(limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + return GetUnusedSymbolsWithoutSqlResolver(limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters, bucketFilter, minConfidence); var targetCount = Math.Max(limit, 1); var privateLike = FetchUnusedCandidates(targetCount, 0, 0, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); @@ -2842,11 +2854,52 @@ public List GetUnusedSymbols(int limit, string? kind, string merged.AddRange(maybeNonPublic); merged.AddRange(publicOrExported); merged.AddRange(reflectionOrConfig); + return DiversifyUnusedResults(FilterUnusedResults(merged, bucketFilter, minConfidence), limit); + } + + private List GetFilteredUnusedSymbols(int limit, string? kind, string? lang, + IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, + IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters, + string? bucketFilter, string? minConfidence) + { + if (!ScopeMayIncludeSqlSymbols(kind, lang, pathPatterns, excludePathPatterns, excludeTests)) + return GetFilteredUnusedSymbolsWithoutSqlResolver(limit, kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters, bucketFilter, minConfidence); + + var targetCount = Math.Max(limit, 1); + var targetBuckets = GetTargetUnusedBuckets(bucketFilter, minConfidence); + if (targetBuckets.Count == 0) + return []; + + var resultsByBucket = CreateUnusedBucketResultLists(); + const int batchSize = UnusedPublicOverfetchMaximum; + foreach (var provisionalBucket in GetRelevantUnusedProvisionalBuckets(targetBuckets)) + { + var offset = 0; + while (!AllTargetUnusedBucketsFilled(resultsByBucket, targetBuckets, targetCount)) + { + var batch = FetchUnusedCandidates(batchSize, provisionalBucket, offset, kind, lang, + pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + if (batch.Count == 0) + break; + + offset += batch.Count; + foreach (var result in batch) + AddFilteredUnusedResult(resultsByBucket, targetBuckets, targetCount, result, bucketFilter, minConfidence); + + if (batch.Count < batchSize) + break; + } + } + + var merged = OrderedUnusedBuckets + .SelectMany(bucket => resultsByBucket[bucket]) + .ToList(); return DiversifyUnusedResults(merged, limit); } private List GetUnusedSymbolsWithoutSqlResolver(int limit, string? kind, string? lang, - IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null, + string? bucketFilter = null, string? minConfidence = null) { var referencedNames = LoadReferencedSymbolNames(); var targetCount = Math.Max(limit, 1); @@ -2876,6 +2929,53 @@ private List GetUnusedSymbolsWithoutSqlResolver(int limit, s merged.AddRange(maybeNonPublic); merged.AddRange(publicOrExported); merged.AddRange(reflectionOrConfig); + return DiversifyUnusedResults(FilterUnusedResults(merged, bucketFilter, minConfidence), limit); + } + + private List GetFilteredUnusedSymbolsWithoutSqlResolver(int limit, string? kind, string? lang, + IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, + IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters, + string? bucketFilter, string? minConfidence) + { + var targetCount = Math.Max(limit, 1); + var targetBuckets = GetTargetUnusedBuckets(bucketFilter, minConfidence); + if (targetBuckets.Count == 0) + return []; + + var referencedNames = LoadReferencedSymbolNames(); + var chunksByFileId = new Dictionary>(); + var resultsByBucket = CreateUnusedBucketResultLists(); + const int batchSize = UnusedPublicOverfetchMaximum; + foreach (var provisionalBucket in GetRelevantUnusedProvisionalBuckets(targetBuckets)) + { + var offset = 0; + while (!AllTargetUnusedBucketsFilled(resultsByBucket, targetBuckets, targetCount)) + { + var batch = FetchUnusedCandidateSymbols(batchSize, offset, provisionalBucket, kind, lang, + pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters).ToList(); + if (batch.Count == 0) + break; + + offset += batch.Count; + foreach (var candidate in batch) + { + if (referencedNames.Contains(candidate.Name)) + continue; + if (HasSameFilePrivateUse(candidate, chunksByFileId)) + continue; + + var result = CreateUnusedSymbolResult(candidate); + AddFilteredUnusedResult(resultsByBucket, targetBuckets, targetCount, result, bucketFilter, minConfidence); + } + + if (batch.Count < batchSize) + break; + } + } + + var merged = OrderedUnusedBuckets + .SelectMany(bucket => resultsByBucket[bucket]) + .ToList(); return DiversifyUnusedResults(merged, limit); } @@ -3352,12 +3452,102 @@ private static List DiversifyUnusedResults(List? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) + private static List FilterUnusedResults(IEnumerable results, string? bucketFilter, string? minConfidence) + => results + .Where(result => MatchesUnusedFilters(result, bucketFilter, minConfidence)) + .ToList(); + + private static bool HasEffectiveUnusedFilter(string? bucketFilter, string? minConfidence) + => bucketFilter != null || string.Equals(minConfidence, "medium", StringComparison.Ordinal); + + private static Dictionary> CreateUnusedBucketResultLists() + => OrderedUnusedBuckets.ToDictionary( + bucket => bucket, + _ => new List(), + StringComparer.Ordinal); + + private static HashSet GetTargetUnusedBuckets(string? bucketFilter, string? minConfidence) + { + var minConfidenceRank = minConfidence == null ? int.MinValue : GetUnusedConfidenceRank(minConfidence); + return OrderedUnusedBuckets + .Where(bucket => bucketFilter == null || string.Equals(bucket, bucketFilter, StringComparison.Ordinal)) + .Where(bucket => GetUnusedConfidenceRank(GetUnusedBucketConfidence(bucket)) >= minConfidenceRank) + .ToHashSet(StringComparer.Ordinal); + } + + private static IEnumerable GetRelevantUnusedProvisionalBuckets(IReadOnlySet targetBuckets) + { + if (targetBuckets.Contains(UnusedBucketLikelyPrivate)) + yield return 0; + if (targetBuckets.Contains(UnusedBucketMaybeNonPublic)) + yield return 1; + if (targetBuckets.Contains(UnusedBucketPublicOrExported) || targetBuckets.Contains(UnusedBucketReflectionOrConfig)) + yield return 2; + if (targetBuckets.Contains(UnusedBucketReflectionOrConfig)) + yield return 3; + } + + private static bool AllTargetUnusedBucketsFilled( + IReadOnlyDictionary> resultsByBucket, + HashSet targetBuckets, + int targetCount) + { + foreach (var bucket in targetBuckets) + { + if (resultsByBucket[bucket].Count < targetCount) + return false; + } + + return true; + } + + private static void AddFilteredUnusedResult( + Dictionary> resultsByBucket, + HashSet targetBuckets, + int targetCount, + UnusedSymbolResult result, + string? bucketFilter, + string? minConfidence) + { + if (!MatchesUnusedFilters(result, bucketFilter, minConfidence)) + return; + if (!targetBuckets.Contains(result.UnusedBucket)) + return; + + var bucketResults = resultsByBucket[result.UnusedBucket]; + if (bucketResults.Count < targetCount) + bucketResults.Add(result); + } + + private static bool MatchesUnusedFilters(UnusedSymbolResult result, string? bucketFilter, string? minConfidence) + { + if (bucketFilter != null && !string.Equals(result.UnusedBucket, bucketFilter, StringComparison.Ordinal)) + return false; + + if (minConfidence != null && GetUnusedConfidenceRank(result.UnusedConfidence) < GetUnusedConfidenceRank(minConfidence)) + return false; + + return true; + } + + private static string GetUnusedBucketConfidence(string bucket) + => string.Equals(bucket, UnusedBucketLikelyPrivate, StringComparison.Ordinal) ? "medium" : "low"; + + private static int GetUnusedConfidenceRank(string confidence) => confidence switch + { + "medium" => 1, + "low" => 0, + _ => -1, + }; + + public QueryCountResult CountUnusedSymbols(string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null, string? bucketFilter = null, string? minConfidence = null) { if (!_hasReferencesTable) return new QueryCountResult(0, 0); if (lang != null && !ReferenceExtractor.SupportsLanguage(lang)) return new QueryCountResult(0, 0); + if (bucketFilter != null || minConfidence != null) + return CountFilteredUnusedSymbols(kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters, bucketFilter, minConfidence); if (!ScopeMayIncludeSqlSymbols(kind, lang, pathPatterns, excludePathPatterns, excludeTests)) return CountUnusedSymbolsWithoutSqlResolver(kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); @@ -3435,6 +3625,92 @@ AND sql_reference_matches_target_at(sr.symbol_name, " + contextSql + @", sr.cont reader.FieldCount > 2 && !reader.IsDBNull(2) && Convert.ToInt32(reader.GetValue(2)) != 0); } + private QueryCountResult CountFilteredUnusedSymbols(string? kind, string? lang, + IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, + IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters, + string? bucketFilter, string? minConfidence) + { + if (!ScopeMayIncludeSqlSymbols(kind, lang, pathPatterns, excludePathPatterns, excludeTests)) + return CountFilteredUnusedSymbolsWithoutSqlResolver(kind, lang, pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters, bucketFilter, minConfidence); + + var count = 0; + var paths = new HashSet(StringComparer.Ordinal); + var includesSql = false; + const int batchSize = UnusedPublicOverfetchMaximum; + for (var bucket = 0; bucket <= 3; bucket++) + { + var offset = 0; + while (true) + { + var batch = FetchUnusedCandidates(batchSize, bucket, offset, kind, lang, + pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters); + if (batch.Count == 0) + break; + + offset += batch.Count; + foreach (var result in batch) + { + if (!MatchesUnusedFilters(result, bucketFilter, minConfidence)) + continue; + + count++; + paths.Add(result.Path); + if (IsSqlLanguage(result.Lang)) + includesSql = true; + } + + if (batch.Count < batchSize) + break; + } + } + + return new QueryCountResult(count, paths.Count, includesSql); + } + + private QueryCountResult CountFilteredUnusedSymbolsWithoutSqlResolver(string? kind, string? lang, + IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, + IReadOnlyList? visibilityFilters, IReadOnlyList? excludeVisibilityFilters, + string? bucketFilter, string? minConfidence) + { + var referencedNames = LoadReferencedSymbolNames(); + var count = 0; + var paths = new HashSet(StringComparer.Ordinal); + var chunksByFileId = new Dictionary>(); + const int batchSize = UnusedPublicOverfetchMaximum; + for (var bucket = 0; bucket <= 3; bucket++) + { + var offset = 0; + while (true) + { + var batch = FetchUnusedCandidateSymbols(batchSize, offset, bucket, kind, lang, + pathPatterns, excludePathPatterns, excludeTests, visibilityFilters, excludeVisibilityFilters).ToList(); + if (batch.Count == 0) + break; + + offset += batch.Count; + foreach (var candidate in batch) + { + if (referencedNames.Contains(candidate.Name)) + continue; + if (HasSameFilePrivateUse(candidate, chunksByFileId)) + continue; + + var result = CreateUnusedSymbolResult(candidate); + if (!MatchesUnusedFilters(result, bucketFilter, minConfidence)) + continue; + + count++; + paths.Add(candidate.Path); + } + + if (batch.Count < batchSize) + break; + } + } + + return new QueryCountResult(count, paths.Count); + } + private QueryCountResult CountUnusedSymbolsWithoutSqlResolver(string? kind, string? lang, IReadOnlyList? pathPatterns, IReadOnlyList? excludePathPatterns, bool excludeTests, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) { diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index c52f12c519..b903870463 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -467,11 +467,11 @@ private JsonNode HandleToolsList(JsonNode? id) "unused_symbols", "Find symbols that are defined but never referenced in the indexed codebase. " + "Useful for dead code detection. Results include confidence buckets so private hits rank ahead of public/exported suspects, and the lowest-confidence bucket is reserved for config-bound properties or C#-style attribute-adjacent reflection surfaces. Only meaningful for languages with reference extraction support. " - + "Structured output includes `summary.by_bucket`, `summary.by_confidence`, and `bucket_taxonomy`; bucket values are `likely_unused_private`, `maybe_unused_nonpublic`, `public_or_exported_no_refs`, and `reflection_or_config_suspect`. " + + "Structured output includes `summary.by_bucket`, `summary.by_confidence`, and `bucket_taxonomy`; bucket values are `likely_unused_private`, `maybe_unused_nonpublic`, `public_or_exported_no_refs`, and `reflection_or_config_suspect`. Use `bucket` or `minConfidence` to audit a single bucket or confidence class. " + "C# nameof/typeof and direct reflection member-name literals such as GetMethod(\"Foo\") are indexed as references; dynamically constructed reflection names can still require manual review. " + "/ インデックス済みコードベースで定義されているが一度も参照されていないシンボルを検索する。" + "デッドコード検出に有用。private 候補を public/exported suspect より前に返し、最低信頼 bucket は config-bound な property または C# 風 attribute 隣接の reflection surface 用に使う。参照抽出対応言語でのみ意味がある。" - + "構造化出力には `summary.by_bucket`、`summary.by_confidence`、`bucket_taxonomy` が含まれ、bucket 値は `likely_unused_private`、`maybe_unused_nonpublic`、`public_or_exported_no_refs`、`reflection_or_config_suspect`。" + + "構造化出力には `summary.by_bucket`、`summary.by_confidence`、`bucket_taxonomy` が含まれ、bucket 値は `likely_unused_private`、`maybe_unused_nonpublic`、`public_or_exported_no_refs`、`reflection_or_config_suspect`。`bucket` または `minConfidence` で単一 bucket や confidence class を監査できる。" + "C# の nameof/typeof と GetMethod(\"Foo\") のような直接の reflection member-name literal は参照として index されるが、動的に組み立てた reflection 名は手動確認が必要な場合がある。", new JsonObject { @@ -481,6 +481,8 @@ private JsonNode HandleToolsList(JsonNode? id) ["kind"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by symbol kind (function, class, property, interface, enum, struct, event, delegate)" }, ["lang"] = new JsonObject { ["type"] = "string", ["description"] = "Filter by language (recommended: use a graph-supported language)" }, ["limit"] = new JsonObject { ["type"] = "integer", ["description"] = "Max results (default: 50)", ["default"] = QueryCommandRunner.DefaultImpactLimit }, + ["bucket"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("likely_unused_private", "maybe_unused_nonpublic", "public_or_exported_no_refs", "reflection_or_config_suspect"), ["description"] = "Return only one unused-symbol bucket." }, + ["minConfidence"] = new JsonObject { ["type"] = "string", ["enum"] = new JsonArray("medium", "low"), ["description"] = "Return symbols at or above this confidence threshold." }, ["path"] = new JsonObject { ["oneOf"] = new JsonArray { new JsonObject { ["type"] = "string" }, new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" } } }, ["description"] = "Restrict to paths containing this text. Accepts a single string or an array; multiple values are OR'd together." }, ["excludePaths"] = new JsonObject { ["type"] = "array", ["items"] = new JsonObject { ["type"] = "string" }, ["description"] = "Exclude paths containing any of these texts" }, ["excludeTests"] = new JsonObject { ["type"] = "boolean", ["description"] = "Exclude test files (default: false)", ["default"] = false } diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 994880f690..247ca68ab8 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -627,7 +627,7 @@ private static bool TryGetExpectedJsonType(string toolName, string argumentName, "project" or "requireBefore" or "requireAfter" or "rejectBefore" or "rejectAfter" => "string_or_array", "query" or "lang" or "kind" or "format" or "rankBy" or "since" or "cursor" or "solution" or "symbol" or "groupBy" or "category" or "language" or - "description" or "context" or "toolInvocationContext" or "db" => "string", + "bucket" or "minConfidence" or "description" or "context" or "toolInvocationContext" or "db" => "string", "queries" or "evidencePaths" or "evidence_paths" => "array", _ => string.Empty, }; @@ -699,7 +699,7 @@ private static string DescribeJsonType(JsonNode? node) "deps" => new HashSet(StringComparer.Ordinal) { "path", "reverse", "format", "cycles", "lang", "limit", "excludePaths", "excludeTests", "project", "solution" }, "impact_analysis" => new HashSet(StringComparer.Ordinal) { "query", "lang", "maxHops", "maxDepth", "limit", "path", "excludePaths", "excludeTests", "includeGenerated", "withPaths", "countOnly", "project", "solution" }, "validate" => new HashSet(StringComparer.Ordinal) { "kind", "path", "excludePaths", "excludeTests", "project", "solution" }, - "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "project", "solution" }, + "unused_symbols" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "path", "excludePaths", "excludeTests", "bucket", "minConfidence", "project", "solution" }, "symbol_hotspots" => new HashSet(StringComparer.Ordinal) { "kind", "lang", "limit", "groupBy", "path", "excludePaths", "excludeTests", "project", "solution" }, "index" => new HashSet(StringComparer.Ordinal) { "path", "rebuild", "maxFileBytes" }, "backfill_fold" => new HashSet(StringComparer.Ordinal) { "dry_run", "dryRun", "force" }, @@ -3427,9 +3427,15 @@ private JsonNode ExecuteUnusedSymbols(JsonNode? id, JsonNode? args) var limit = ClampLimit(args?["limit"]?.GetValue() ?? QueryCommandRunner.DefaultImpactLimit); var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); var lang = args?["lang"]?.GetValue()?.ToLowerInvariant(); + var bucket = args?["bucket"]?.GetValue()?.ToLowerInvariant(); + var minConfidence = args?["minConfidence"]?.GetValue()?.ToLowerInvariant(); var pathPatterns = ReadScopedPathList(args); var excludePaths = ReadStringList(args, "excludePaths"); var excludeTests = args?["excludeTests"]?.GetValue() ?? false; + if (bucket != null && !QueryCommandRunner.IsKnownUnusedBucket(bucket)) + return CreateToolErrorResponse(id, $"Invalid bucket '{bucket}'. Use one of: {string.Join(", ", QueryCommandRunner.OrderedUnusedBuckets)}."); + if (minConfidence != null && !QueryCommandRunner.IsKnownUnusedConfidence(minConfidence)) + return CreateToolErrorResponse(id, $"Invalid minConfidence '{minConfidence}'. Use one of: medium, low."); // Add graph-support metadata for AI trust decisions // AI の信頼判断のためにグラフ対応メタデータを追加 @@ -3438,7 +3444,15 @@ private JsonNode ExecuteUnusedSymbols(JsonNode? id, JsonNode? args) return WithDbReader(id, args, reader => { - var results = reader.GetUnusedSymbols(limit, kind, lang, pathPatterns, excludePaths, excludeTests); + var results = reader.GetUnusedSymbols( + limit, + kind, + lang, + pathPatterns, + excludePaths, + excludeTests, + bucketFilter: bucket, + minConfidence: minConfidence); var baseSqlGraphSignal = reader.GetSqlGraphContractSignal(lang, pathPatterns, excludePaths, excludeTests); var zeroResultSqlGraphSignal = QueryCommandRunner.NarrowSqlGraphContractSignal( baseSqlGraphSignal, diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 40ecf7486b..14f052a3fb 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -155,7 +155,7 @@ public void PrintUsage_WithoutBanner_HidesAsciiArtAndEasterEggFlags() Assert.Contains("cdidx validate [--db ] [--json[=array]] [--format ] [--verbose] [--limit |--top ] [--kind ] [--severity ] [--path ]", output); Assert.Contains("Note: if a query itself starts with '-', pass it with --query or -- ", output); Assert.DoesNotContain("cdidx validate [--db ] [--json] [--limit ] [--lang ]", output); - Assert.Contains("cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); + Assert.Contains("cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--bucket ] [--min-confidence ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); Assert.Contains("cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--group-by ] [--group-by-name]", output); Assert.Contains("--json Output as JSON (search/files stream ndjson by", output); Assert.Contains("search/files/validate accept --json=array for one array)", output); @@ -272,7 +272,7 @@ public void PrintUsage_QueryLinesMatchImplementedOptions() Assert.Contains("cdidx symbols [query|--query |-- ] [--name ] [--db ] [--json] [--verbose] [--limit |--top ] [--lang ] [--kind ] [--visibility ] [--exclude-visibility ] [--path ] [--exclude-path ] [--exclude-tests] [--exact|--exact-name] [--count] [--since ]", output); Assert.Contains("cdidx files [query|--query |-- ] [--db ] [--json[=ndjson|array]] [--verbose] [--limit |--top ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count] [--since ] [--bytes]", output); Assert.Contains("cdidx hotspots [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); - Assert.Contains("cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); + Assert.Contains("cdidx unused [--db ] [--json] [--verbose] [--limit |--top ] [--kind ] [--bucket ] [--min-confidence ] [--visibility ] [--exclude-visibility ] [--lang ] [--path ] [--exclude-path ] [--exclude-tests] [--count]", output); Assert.Contains("cdidx license", output); Assert.Contains("cdidx completions ", output); Assert.Contains("cdidx --completions ", output); diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 95eb0e1fef..d882df508a 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -9598,6 +9598,16 @@ public void ToolsCall_UnusedSymbols_IncludesConfidenceBuckets() Assert.Equal("UseIOptions", symbols[8]!["name"]!.GetValue()); Assert.Equal("public_or_exported_no_refs", symbols[8]!["unusedBucket"]!.GetValue()); Assert.Contains("returned buckets", response["result"]!["content"]![0]!["text"]!.GetValue()); + + var filteredRequest = JsonNode.Parse("""{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"unused_symbols","arguments":{"lang":"csharp","path":"unused_fixture.cs","bucket":"likely_unused_private","minConfidence":"medium"}}}""")!; + var filteredResponse = _server.HandleMessage(filteredRequest)!; + var filteredStructured = filteredResponse["result"]!["structuredContent"]!; + var filteredSymbols = filteredStructured["symbols"]!.AsArray(); + + Assert.False(filteredResponse["result"]!["isError"]?.GetValue() ?? false); + Assert.Equal(1, filteredStructured["count"]!.GetValue()); + Assert.Equal("Hidden", filteredSymbols[0]!["name"]!.GetValue()); + Assert.Equal("likely_unused_private", filteredSymbols[0]!["unusedBucket"]!.GetValue()); } [Fact] diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs index 75ed5faa50..ed8d427f86 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSymbolTests.cs @@ -691,6 +691,99 @@ public void RunUnused_WithJsonByBucketGroupsReturnedSymbolsByTaxonomyBucket() } } + [Fact] + public void RunUnused_WithJsonBucketFilterReturnsOnlyRequestedBucket() + { + var (projectRoot, dbPath) = CreateUnusedFixtureDb(); + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + ["--db", dbPath, "--json", "--lang", "csharp", "--bucket", "likely_unused_private"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + var symbols = json.GetProperty("symbols"); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(1, json.GetProperty("count").GetInt32()); + Assert.Equal(1, json.GetProperty("returned_bucket_counts").GetProperty("likely_unused_private").GetInt32()); + Assert.False(json.GetProperty("returned_bucket_counts").TryGetProperty("maybe_unused_nonpublic", out _)); + Assert.Equal("Hidden", symbols[0].GetProperty("name").GetString()); + Assert.Equal("likely_unused_private", symbols[0].GetProperty("unused_bucket").GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunUnused_WithJsonMinConfidenceFiltersLowerConfidenceBuckets() + { + var (projectRoot, dbPath) = CreateUnusedFixtureDb(); + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + ["--db", dbPath, "--json", "--lang", "csharp", "--min-confidence", "medium"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(1, json.GetProperty("count").GetInt32()); + Assert.Equal("Hidden", json.GetProperty("symbols")[0].GetProperty("name").GetString()); + Assert.Equal(1, json.GetProperty("summary").GetProperty("by_confidence").GetProperty("medium").GetInt32()); + Assert.False(json.GetProperty("summary").GetProperty("by_confidence").TryGetProperty("low", out _)); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunUnused_CountJsonWithBucketFilterCountsFilteredSymbols() + { + var (projectRoot, dbPath) = CreateUnusedFixtureDb(); + try + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + ["--db", dbPath, "--json", "--lang", "csharp", "--bucket", "public_or_exported_no_refs", "--count"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal(6, json.GetProperty("count").GetInt32()); + Assert.Equal(1, json.GetProperty("files").GetInt32()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + + [Theory] + [InlineData("--bucket", "missing_bucket", "invalid --bucket value")] + [InlineData("--min-confidence", "high", "invalid --min-confidence value")] + public void RunUnused_InvalidBucketOrConfidenceFails(string optionName, string value, string expectedError) + { + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunUnused( + [optionName, value], + _jsonOptions)); + + Assert.Equal(CommandExitCodes.InvalidArgument, exitCode); + Assert.Equal(string.Empty, stdout); + Assert.Contains(expectedError, stderr); + Assert.Contains($"Usage: {ConsoleUi.GetUsageLine("unused")}", stderr); + } + [Fact] public void RunUnused_WithJsonUsesReturnedBucketCountsForCurrentPage() {