diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 124036272a..bfc80e10c6 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1163,6 +1163,10 @@ Different graph entry points walk different `reference_kind` subsets by design. Practical consequence: `impact ` on a class-like symbol returns the heuristic file-dependency-hint fallback (with metadata edges) when no member-level callers exist, whereas `callers ` returns the call-graph subset (without metadata). Both are correct under their own contracts; counts will not match. To reconcile, run `references --kind attribute` (or `annotation`) to surface the metadata-only edges that the call-graph commands intentionally drop. +`impact --json` and MCP `impact_analysis` expose zero-result diagnostics as structured routing fields. `zero_result_reason` remains the compact terminal reason; `impact_failure_chain` lists failed preconditions or traversal states in order, using values such as `definition_not_found`, `callable_filter_fails`, `multiple_definitions`, `multiple_definition_files`, `graph_unavailable`, `depth_requested_zero`, and `no_callers`. `suggestion_type` classifies the prose `suggestion` as `resolution`, `traversal`, or `precondition`. CLI `impact --strict` exits with `FeatureUnavailable` when the chain contains a resolution or precondition failure, but still treats a genuine `no_callers` traversal result as success. + +`definition --json` and MCP `definition` results may include `disambiguator` for C# definitions when existing symbol metadata can distinguish otherwise identical names. Current values include `overload(...)` for method signatures, `partial-class` / `partial-struct` / `partial-interface`, and `extension-method-on()`. Languages without overload or receiver metadata omit the field. + ## Cloud Claude Code bootstrap (no .NET SDK) > **Maintainers / authorized operators only** — see [MAINTAINERS.md](MAINTAINERS.md). End users can skip this section. diff --git a/README.md b/README.md index db96cfaf72..ebf6615f98 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,7 @@ downgrading `cdidx`. | Area | What cdidx provides | |---|---| | Search surfaces | CLI-first output for humans and machines; full-text, symbol, reference, caller/callee, dependency, map, inspect, and excerpt commands. `search`, `definition`, `references`, `callers`, `callees`, `find`, and `validate` support `--format count|compact|csv|tsv|lsp|qf|sarif` for token-budgeted agents, scripts, editors, and CI reports. | +| Definition and impact diagnostics | `definition --json` includes C# `disambiguator` hints for overloads, partial types, and extension receivers when indexed metadata can distinguish them. `impact --json` and MCP `impact_analysis` include `impact_failure_chain` and `suggestion_type` for zero-result routing; `impact --strict` exits non-zero when resolution or graph preconditions are unmet. | | Ranking and filters | Public/exported symbol matches rank ahead of protected, internal, and private matches. Use `--no-visibility-rank` for legacy order, and `--visibility` / `--exclude-visibility` with `symbols`, `definition`, `unused`, and `hotspots`. Query defaults can be adjusted with `CDIDX_DEFAULT_LIMIT`, `CDIDX_DEFAULT_SNIPPET_LINES`, and `CDIDX_DEFAULT_MAX_LINE_WIDTH`; explicit CLI flags still win. | | Project scoping | `.sln` / `.csproj`-aware --project <name|path> filters for indexing and queries, plus `--solution ` when a workspace has multiple solution files. | | MCP integration | MCP server support for AI clients such as Claude Code, Cursor, and Windsurf, including tools, indexed-file resources, starter prompts, schema constraints for local argument validation, `mimeType` on text content blocks, logging, a structured `ping` health result, HTTP `GET /healthz`, opt-in HTTP `/events` keep-alive notifications, a compatibility server-side `notifications/initialized` ready signal on stdio or HTTP `/events` streams, and `Language support:` descriptions sourced from the same registries as `cdidx languages`. Tool schemas reject unknown arguments with `-32602`, advertise `x-stability`, and use snake_case structured JSON keys to match the CLI JSON contract. | @@ -386,6 +387,7 @@ upgrade / downgrade 後はインストール済み補完 script を再生成し | 分野 | 内容 | |---|---| | 検索面 | CLI-first の人間向け / 機械処理向け出力。全文検索、シンボル、参照、caller/callee、依存関係、map、inspect、excerpt コマンドを提供します。 | +| definition / impact 診断 | `definition --json` は C# overload、partial type、extension receiver を区別できる場合に `disambiguator` を返します。`impact --json` と MCP `impact_analysis` は 0 件時の経路判断用に `impact_failure_chain` と `suggestion_type` を返し、`impact --strict` は解決または graph の前提条件が満たされない場合に非 0 で終了します。 | | 順位と filter | public/exported なシンボル一致を protected、internal、private より優先します。従来順は `--no-visibility-rank`、可視性の include / exclude は `symbols`、`definition`、`unused`、`hotspots` の `--visibility` / `--exclude-visibility` で指定できます。query 既定値は `CDIDX_DEFAULT_LIMIT`、`CDIDX_DEFAULT_SNIPPET_LINES`、`CDIDX_DEFAULT_MAX_LINE_WIDTH` で調整でき、明示 CLI flag が常に優先されます。 | | project scope | `.sln` / `.csproj` を使った --project <name|path> filter で index と query を .NET project 配下へ絞り込めます。workspace に solution が複数ある場合は `--solution ` を指定します。 | | MCP 連携 | Claude Code、Cursor、Windsurf などの AI クライアント向け MCP server。tools、インデックス済みファイル resources、starter prompts、ローカル引数検証用の schema constraints、text content block の `mimeType`、logging、構造化された `ping` health result、HTTP `GET /healthz`、opt-in の HTTP `/events` keep-alive notification、stdio または HTTP `/events` stream 上の互換性用 server-side `notifications/initialized` ready signal、`cdidx languages` と同じ言語レジストリ由来の `Language support:` 説明を提供します。Tool schema は未知の引数を `-32602` で拒否し、`x-stability` を公開し、CLI JSON contract と一致する snake_case の structured JSON key を使います。 | diff --git a/changelog.d/unreleased/1638.changed.md b/changelog.d/unreleased/1638.changed.md new file mode 100644 index 0000000000..568fd74008 --- /dev/null +++ b/changelog.d/unreleased/1638.changed.md @@ -0,0 +1,17 @@ +--- +category: changed +issues: + - 1638 +affected: + - src/CodeIndex/Database/DbSymbolReader.cs + - src/CodeIndex/Models/QueryResults.cs + - tests/CodeIndex.Tests/DbReaderTests.cs +--- + +## English + +- **Definition JSON now includes C# disambiguator hints (#1638)** — `definition` results include overload, partial type, and extension receiver hints when indexed C# metadata can distinguish otherwise identical names. + +## 日本語 + +- **definition JSON が C# disambiguator hint を返すようになりました (#1638)** — `definition` は、インデックス済み C# metadata で同名定義を区別できる場合に overload、partial type、extension receiver の hint を返します。 diff --git a/changelog.d/unreleased/2002.changed.md b/changelog.d/unreleased/2002.changed.md new file mode 100644 index 0000000000..1960dc12f0 --- /dev/null +++ b/changelog.d/unreleased/2002.changed.md @@ -0,0 +1,17 @@ +--- +category: changed +issues: + - 2002 +affected: + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs +--- + +## English + +- **Impact zero-result JSON now exposes structured failure routing (#2002)** — `impact` now emits `impact_failure_chain` and `suggestion_type` for zero results, and `impact --strict` exits non-zero when resolution or graph preconditions fail. + +## 日本語 + +- **impact の 0 件 JSON が構造化された失敗経路を返すようになりました (#2002)** — `impact` は 0 件時に `impact_failure_chain` と `suggestion_type` を返し、`impact --strict` は解決または graph の前提条件失敗で非 0 終了します。 diff --git a/src/CodeIndex/Cli/CliFlagSchema.cs b/src/CodeIndex/Cli/CliFlagSchema.cs index 533debb8a5..f75b395825 100644 --- a/src/CodeIndex/Cli/CliFlagSchema.cs +++ b/src/CodeIndex/Cli/CliFlagSchema.cs @@ -231,6 +231,7 @@ private static IReadOnlyList BuildAll() new() { Name = "--raw-kinds", Description = "Show raw reference kinds instead of logical graph kinds", Commands = Set(RawKindsCommands) }, new() { Name = "--count", Description = "Count only", Commands = Set(CountCommands) }, new() { Name = "--strict-not-found", Description = "Return exit code 2 when a valid query has zero rows", Commands = Set(StrictNotFoundCommands) }, + new() { Name = "--strict", Description = "Return exit code 4 when impact preconditions are unmet", Commands = Set("impact") }, new() { Name = "--since", ValuePlaceholder = "", Description = "Filter by modified-since timestamp", Commands = Set(SinceCommands) }, new() { Name = "--bytes", Description = "Show raw byte counts in human output", Commands = Set(ByteFormatCommands) }, new() { Name = "--min-entrypoint-confidence", ValuePlaceholder = "<0.0..1.0>", Description = "Map: omit entrypoint candidates below this confidence", Commands = Set(EntrypointConfidenceCommands) }, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index b939afffa8..35ba2fdce8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -173,6 +173,7 @@ private sealed record StatusReadinessField( "--body", "--count", "--strict-not-found", + "--strict", "--no-dedup", "--no-visibility-rank", "--exact", @@ -3220,6 +3221,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) zeroPayload["definitions"] = JsonSerializer.SerializeToNode(analysis.Definitions, CliJsonSerializerContextFactory.Create(jsonOptions).ListSymbolResult); if (analysis.ZeroResultReason != null) zeroPayload["zero_result_reason"] = analysis.ZeroResultReason; + AddImpactFailureJsonFields(zeroPayload, analysis, jsonOptions); if (analysis.Suggestion != null) zeroPayload["suggestion"] = analysis.Suggestion; AddSqlGraphContractJsonFields(zeroPayload, sqlGraphSignal); @@ -3233,7 +3235,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) WriteImpactResolutionHint(analysis); WriteGraphSupportHint(options.Lang); } - return CommandExitCodes.Success; + return StrictImpactExitCode(options, analysis, CommandExitCodes.Success); } if (options.CountOnly) @@ -3263,6 +3265,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) AddImpactTerminationJsonFields(payload, analysis, jsonOptions); if (analysis.ZeroResultReason != null) payload["zero_result_reason"] = analysis.ZeroResultReason; + AddImpactFailureJsonFields(payload, analysis, jsonOptions); if (analysis.Suggestion != null) payload["suggestion"] = analysis.Suggestion; if (!analysis.GraphTableAvailable) @@ -3314,6 +3317,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) zeroPayload["definitions"] = JsonSerializer.SerializeToNode(analysis.Definitions, CliJsonSerializerContextFactory.Create(jsonOptions).ListSymbolResult); if (analysis.ZeroResultReason != null) zeroPayload["zero_result_reason"] = analysis.ZeroResultReason; + AddImpactFailureJsonFields(zeroPayload, analysis, jsonOptions); if (analysis.Suggestion != null) zeroPayload["suggestion"] = analysis.Suggestion; AddSqlGraphContractJsonFields(zeroPayload, sqlGraphSignal); @@ -3330,7 +3334,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) WriteGraphSupportHint(options.Lang); WriteDegradedGraphZeroResult(reader, "callers", json: false, graphAvailable: reader._hasReferencesTable, jsonOptions); } - return ZeroResultExitCode(options); + return StrictImpactExitCode(options, analysis, ZeroResultExitCode(options)); } if (options.CountOnly) @@ -3397,6 +3401,7 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) payload["truncated_reason"] = analysis.TruncatedReason; if (analysis.Suggestion != null) payload["suggestion"] = analysis.Suggestion; + AddImpactFailureJsonFields(payload, analysis, jsonOptions); AddSqlGraphContractJsonFields(payload, sqlGraphSignal); AddImpactOptionWarnings(payload, options); Console.WriteLine(payload.ToJsonString(jsonOptions)); @@ -3445,10 +3450,27 @@ public static int RunImpact(string[] cmdArgs, JsonSerializerOptions jsonOptions) else Console.Error.WriteLine($"\n({confirmedCount} callers across {confirmedFileCount} files, max depth {maxDepth}{truncNote})"); } - return CommandExitCodes.Success; + return StrictImpactExitCode(options, analysis, CommandExitCodes.Success); }); } + private static void AddImpactFailureJsonFields(JsonObject payload, ImpactAnalysisResult analysis, JsonSerializerOptions jsonOptions) + { + if (analysis.ImpactFailureChain is { Count: > 0 }) + payload["impact_failure_chain"] = JsonSerializer.SerializeToNode(analysis.ImpactFailureChain, CliJsonSerializerContextFactory.Create(jsonOptions).ListString); + if (analysis.SuggestionType != null) + payload["suggestion_type"] = analysis.SuggestionType; + } + + private static int StrictImpactExitCode(QueryCommandOptions options, ImpactAnalysisResult analysis, int defaultExitCode) + { + if (!options.Strict || analysis.ImpactFailureChain is not { Count: > 0 }) + return defaultExitCode; + return analysis.ImpactFailureChain.Any(code => code != "no_callers") + ? CommandExitCodes.FeatureUnavailable + : defaultExitCode; + } + private static void AddImpactTerminationJsonFields(JsonObject payload, ImpactAnalysisResult analysis, JsonSerializerOptions jsonOptions) { payload["termination_reason"] = analysis.TerminationReason; @@ -4610,6 +4632,7 @@ public static QueryCommandOptions ParseArgs( bool limitExplicit = false; bool snippetLinesExplicit = false; bool maxLineWidthExplicit = false; + bool strict = false; var rankMode = ReferenceRankMode.Weighted; var extraNames = new List(); bool impactDeprecatedDepthUsed = false; @@ -4859,6 +4882,9 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) case "--strict-not-found": strictNotFound = true; break; + case "--strict": + strict = true; + break; case "--by-bucket": break; case "--all": @@ -5277,6 +5303,7 @@ void WarnIfDuplicateSingleValueOption(string canonicalName, string newValue) IncludeGenerated = includeGenerated, CountOnly = countOnly, StrictNotFound = strictNotFound, + Strict = strict, Since = since, NoDedup = noDedup, NoVisibilityRank = noVisibilityRank, @@ -7906,6 +7933,7 @@ public sealed class QueryCommandOptions public bool IncludeGenerated { get; init; } public bool CountOnly { get; init; } public bool StrictNotFound { get; init; } + public bool Strict { get; init; } public DateTime? Since { get; init; } public bool NoDedup { get; init; } public bool NoVisibilityRank { get; init; } diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index ff810d1f88..05c962befa 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -1415,7 +1415,11 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i CycleDetected = false, Cycles = null, GraphTableAvailable = _hasReferencesTable, - ZeroResultReason = definitions.Count == 0 ? "no_matching_definition" : "depth_zero", + ZeroResultReason = definitions.Count == 0 ? "no_matching_definition" : "depth_requested_zero", + ImpactFailureChain = definitions.Count == 0 + ? ["definition_not_found", "depth_requested_zero"] + : ["depth_requested_zero"], + SuggestionType = definitions.Count == 0 ? "resolution" : "precondition", Suggestion = definitions.Count == 0 ? "Try `cdidx definition ` to confirm the indexed name." : "Use `cdidx impact --max-hops 1` or higher to traverse callers.", @@ -1427,24 +1431,38 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i var impactMode = "callers"; var fileImpacts = new List(); string? zeroResultReason = null; + List? impactFailureChain = null; + string? suggestionType = null; string? suggestion = null; var heuristic = false; if (callers.Count == 0) { impactMode = "none"; + impactFailureChain = []; - if (_hasReferencesTable) + if (!_hasReferencesTable) + { + zeroResultReason = "graph_unavailable"; + impactFailureChain.Add("graph_unavailable"); + suggestionType = "precondition"; + suggestion = "Re-index with the current `cdidx` so symbol reference graph data is available."; + } + else { if (definitions.Count > 0 && definitions.All(d => IsNonCallableImpactKind(d.Kind))) { zeroResultReason = "non_callable_symbol_kind"; + impactFailureChain.Add("callable_filter_fails"); + suggestionType = "resolution"; suggestion = "Try `cdidx definition ` and then run `impact` on a specific callable member instead."; } else if (hasMultipleFallbackDefinitions) { zeroResultReason = hasMultipleFallbackDefinitionFiles ? "multiple_definition_files" : "multiple_definitions"; - suggestion = BuildImpactSuggestion(fallbackDefinitionPaths, hasClassLikeDefinitions, hasMultipleDefinitions: true, hasMultipleDefinitionFiles: hasMultipleFallbackDefinitionFiles); + impactFailureChain.Add(zeroResultReason); + suggestionType = "resolution"; + suggestion = BuildImpactSuggestion(fallbackDefinitionPaths, hasClassLikeDefinitions, hasMultipleDefinitions: true, hasMultipleDefinitionFiles: hasMultipleFallbackDefinitionFiles, lang); } else if (fallbackDefinitions.Count == 1) { @@ -1471,19 +1489,30 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i else { zeroResultReason = "class_symbol_no_symbol_callers"; - suggestion = BuildImpactSuggestion(definitionPaths, hasClassLikeDefinitions, hasMultipleDefinitions: false, hasMultipleDefinitionFiles: false); + impactFailureChain.Add("no_callers"); + suggestionType = "traversal"; + suggestion = BuildImpactSuggestion(definitionPaths, hasClassLikeDefinitions, hasMultipleDefinitions: false, hasMultipleDefinitionFiles: false, lang); } } else if (hasMultipleDefinitions) { zeroResultReason = definitionPaths.Count > 1 ? "multiple_definition_files" : "multiple_definitions"; - suggestion = BuildImpactSuggestion(definitionPaths, hasClassLikeDefinitions, hasMultipleDefinitions: true, hasMultipleDefinitionFiles: definitionPaths.Count > 1); + impactFailureChain.Add(zeroResultReason); + suggestionType = "resolution"; + suggestion = BuildImpactSuggestion(definitionPaths, hasClassLikeDefinitions, hasMultipleDefinitions: true, hasMultipleDefinitionFiles: definitionPaths.Count > 1, lang); } else if (definitions.Count == 0) { zeroResultReason = "no_matching_definition"; + impactFailureChain.Add("definition_not_found"); + suggestionType = "resolution"; suggestion = "Try `cdidx definition ` to confirm the indexed name."; } + else + { + impactFailureChain.Add("no_callers"); + suggestionType = "traversal"; + } } } @@ -1510,6 +1539,8 @@ public ImpactAnalysisResult AnalyzeImpact(string symbolName, int maxDepth = 5, i Cycles = cycles.Count > 0 ? cycles : null, GraphTableAvailable = _hasReferencesTable, ZeroResultReason = zeroResultReason, + ImpactFailureChain = impactFailureChain is { Count: > 0 } ? impactFailureChain : null, + SuggestionType = suggestionType, Suggestion = suggestion, }; } @@ -2055,20 +2086,24 @@ private static bool IsPreciseImpactFallbackKind(string? kind) return kind is "class" or "struct" or "interface"; } - private static string BuildImpactSuggestion(IReadOnlyList definitionPaths, bool hasClassLikeDefinitions, bool hasMultipleDefinitions, bool hasMultipleDefinitionFiles) + private static string BuildImpactSuggestion(IReadOnlyList definitionPaths, bool hasClassLikeDefinitions, bool hasMultipleDefinitions, bool hasMultipleDefinitionFiles, string? lang) { + var langHint = lang == null + ? " Use `--lang ` if the same name exists in multiple languages." + : string.Empty; + if (hasClassLikeDefinitions) { if (hasMultipleDefinitionFiles) - return "Try `cdidx deps --path --reverse` for each definition file or query a member symbol instead."; + return "Try `cdidx deps --path --reverse` for each definition file or query a member symbol instead." + langHint; if (hasMultipleDefinitions) - return "Try a fully qualified or member symbol query, or inspect the overlapping definitions with `cdidx definition --body`."; + return "Try a fully qualified or member symbol query, or inspect the overlapping definitions with `cdidx definition --body`." + langHint; if (definitionPaths.Count > 0) return $"Try `cdidx deps --path {definitionPaths[0]} --reverse` or query a member symbol instead."; } if (hasMultipleDefinitions) - return "Try a more specific symbol name or inspect each definition file with `cdidx definition --body`."; + return "Try a more specific symbol name or inspect each definition file with `cdidx definition --body`." + langHint; return "Try `cdidx definition ` to confirm the indexed symbol and then query a more specific callable member."; } diff --git a/src/CodeIndex/Database/DbSymbolReader.cs b/src/CodeIndex/Database/DbSymbolReader.cs index 22319c74e7..04c9a4dc7d 100644 --- a/src/CodeIndex/Database/DbSymbolReader.cs +++ b/src/CodeIndex/Database/DbSymbolReader.cs @@ -646,6 +646,7 @@ public List GetDefinitions(string query, int limit = 20, strin ContainerName = symbol.ContainerName, Visibility = symbol.Visibility, ReturnType = symbol.ReturnType, + Disambiguator = BuildDefinitionDisambiguator(symbol), Content = definitionExcerpt.Content, BodyContent = bodyContent, Complexity = bodyContent != null ? SymbolExtractor.EstimateComplexity(bodyContent) : null, @@ -655,6 +656,91 @@ public List GetDefinitions(string query, int limit = 20, strin return results; } + private static string? BuildDefinitionDisambiguator(SymbolResult symbol) + { + if (!string.Equals(symbol.Lang, "csharp", StringComparison.OrdinalIgnoreCase)) + return null; + + var signature = symbol.Signature; + if (string.IsNullOrWhiteSpace(signature)) + return null; + + if (signature.Contains(" partial ", StringComparison.Ordinal) + || signature.Contains("partial class ", StringComparison.Ordinal) + || signature.Contains("partial struct ", StringComparison.Ordinal) + || signature.Contains("partial interface ", StringComparison.Ordinal)) + return "partial-" + (symbol.Kind ?? "definition"); + + if (signature.Contains("(this ", StringComparison.Ordinal) + || signature.Contains(", this ", StringComparison.Ordinal)) + { + var receiver = ExtractExtensionReceiver(signature); + return receiver == null ? "extension-method" : $"extension-method-on({receiver})"; + } + + if (symbol.Kind == "function") + { + var parameters = ExtractParameterTypeList(signature); + if (parameters != null) + return $"overload({parameters})"; + } + + return null; + } + + private static string? ExtractExtensionReceiver(string signature) + { + var parameters = ExtractParameters(signature); + if (parameters == null) + return null; + + var firstParameter = parameters.Split(',', 2, StringSplitOptions.TrimEntries)[0]; + const string ThisPrefix = "this "; + if (!firstParameter.StartsWith(ThisPrefix, StringComparison.Ordinal)) + return null; + + var withoutThis = firstParameter[ThisPrefix.Length..].Trim(); + var parts = withoutThis.Split(' ', StringSplitOptions.RemoveEmptyEntries); + return parts.Length > 0 ? parts[0] : null; + } + + private static string? ExtractParameterTypeList(string signature) + { + var parameters = ExtractParameters(signature); + if (parameters == null) + return null; + if (string.IsNullOrWhiteSpace(parameters)) + return ""; + + var types = parameters + .Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(ExtractParameterType) + .Where(type => !string.IsNullOrWhiteSpace(type)) + .ToList(); + return types.Count > 0 ? string.Join(", ", types) : null; + } + + private static string? ExtractParameters(string signature) + { + var open = signature.IndexOf('('); + var close = signature.LastIndexOf(')'); + if (open < 0 || close <= open) + return null; + return signature.Substring(open + 1, close - open - 1).Trim(); + } + + private static string ExtractParameterType(string parameter) + { + var tokens = parameter.Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (tokens.Length == 0) + return string.Empty; + var start = tokens[0] is "this" or "ref" or "out" or "in" or "params" ? 1 : 0; + if (start >= tokens.Length) + return string.Empty; + var end = Math.Max(start + 1, tokens.Length - 1); + return string.Join(" ", tokens[start..end]); + } + public QueryCountResult CountDefinitionsTotal(string query, string? kind = null, string? lang = null, IReadOnlyList? pathPatterns = null, IReadOnlyList? excludePathPatterns = null, bool excludeTests = false, DateTime? since = null, bool exact = false, IReadOnlyList? visibilityFilters = null, IReadOnlyList? excludeVisibilityFilters = null) { if (HasVisibilityFilters(visibilityFilters, excludeVisibilityFilters)) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 3c07ae5c59..49e09f0386 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2711,6 +2711,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) ["top_files"] = topFiles, ["results"] = new JsonArray(), }; + AddImpactFailureFields(countOnlyPayload, analysis); AddSqlGraphContractSignal(countOnlyPayload, sqlGraphSignal); return CreateToolResult(id, $"Counted {ConsoleUi.Counted(count, "impact result")}.", countOnlyPayload); } @@ -2767,6 +2768,7 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) payload["warnings"] = warnings; if (analysis.ZeroResultReason != null) payload["zero_result_reason"] = analysis.ZeroResultReason; + AddImpactFailureFields(payload, analysis); if (analysis.Suggestion != null) payload["suggestion"] = analysis.Suggestion; @@ -2813,6 +2815,20 @@ private JsonNode ExecuteImpactAnalysis(JsonNode? id, JsonNode? args) }); } + private static void AddImpactFailureFields(JsonObject payload, ImpactAnalysisResult analysis) + { + if (analysis.ImpactFailureChain is { Count: > 0 }) + { + var chain = new JsonArray(); + foreach (var code in analysis.ImpactFailureChain) + chain.Add(JsonValue.Create(code)); + payload["impact_failure_chain"] = chain; + } + + if (analysis.SuggestionType != null) + payload["suggestion_type"] = analysis.SuggestionType; + } + private JsonNode ExecuteValidate(JsonNode? id, JsonNode? args) { var kind = args?["kind"]?.GetValue()?.ToLowerInvariant(); diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index de7b56144a..8f3a82beb5 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -161,6 +161,8 @@ public class DefinitionResult : SymbolResult public string? Uri { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public LspRange? Range { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Disambiguator { get; set; } public string Content { get; set; } = string.Empty; public string? BodyContent { get; set; } public int? Complexity { get; set; } @@ -411,6 +413,10 @@ public class ImpactAnalysisResult [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? ZeroResultReason { get; set; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ImpactFailureChain { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? SuggestionType { get; set; } + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public string? Suggestion { get; set; } } diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 31e3a9d96f..801e34095a 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -2343,6 +2343,35 @@ public void GetDefinitions_ReturnsDefinitionContentAndOptionalBody() Assert.Contains("return True", definition.BodyContent); } + [Fact] + public void GetDefinitions_CSharpAddsDefinitionDisambiguators() + { + InsertIndexedFile("src/disambiguators.cs", "csharp", + """ + public partial class Widget + { + public void Convert(int value) { } + public void Convert(string value) { } + public static void Touch(this string value) { } + } + + public partial class Widget + { + } + """); + + var overloads = _reader.GetDefinitions("Convert", limit: 10, lang: "csharp", exact: true) + .OrderBy(result => result.Line) + .ToList(); + Assert.Equal(["overload(int)", "overload(string)"], overloads.Select(result => result.Disambiguator).ToArray()); + + var partials = _reader.GetDefinitions("Widget", limit: 10, lang: "csharp", exact: true); + Assert.All(partials, result => Assert.Equal("partial-class", result.Disambiguator)); + + var extension = Assert.Single(_reader.GetDefinitions("Touch", limit: 10, lang: "csharp", exact: true)); + Assert.Equal("extension-method-on(string)", extension.Disambiguator); + } + [Fact] public void SearchSymbols_MultipleNamesAreOrJoined() { @@ -5242,7 +5271,9 @@ public void Leaf() { } Assert.Empty(analysis.Callers); Assert.Empty(analysis.FileImpacts); Assert.Equal("none", analysis.ImpactMode); - Assert.Equal("depth_zero", analysis.ZeroResultReason); + Assert.Equal("depth_requested_zero", analysis.ZeroResultReason); + Assert.Equal(["depth_requested_zero"], analysis.ImpactFailureChain); + Assert.Equal("precondition", analysis.SuggestionType); Assert.Contains("--max-hops 1", analysis.Suggestion, StringComparison.Ordinal); } @@ -9559,6 +9590,8 @@ public class @class Assert.Equal("@missing", miss.ResolvedName); Assert.Equal(0, miss.DefinitionCount); Assert.Equal("no_matching_definition", miss.ZeroResultReason); + Assert.Equal(["definition_not_found"], miss.ImpactFailureChain); + Assert.Equal("resolution", miss.SuggestionType); } [Fact] @@ -11343,7 +11376,9 @@ public static void Leaf() { } Assert.False(analysis.Truncated); Assert.Null(analysis.TruncatedReason); Assert.Equal(ImpactTerminationReasons.Completed, analysis.TerminationReason); - Assert.Equal("depth_zero", analysis.ZeroResultReason); + Assert.Equal("depth_requested_zero", analysis.ZeroResultReason); + Assert.Equal(["depth_requested_zero"], analysis.ImpactFailureChain); + Assert.Equal("precondition", analysis.SuggestionType); Assert.False(analysis.CycleDetected); Assert.Null(analysis.Cycles); } diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index bd028c52d8..51d6d52cfd 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -4341,7 +4341,10 @@ public void ToolsCall_ImpactAnalysis_DepthZeroReturnsResolvedSymbolWithoutCaller Assert.Equal("none", structured["impact_mode"]!.GetValue()); Assert.Equal(1, structured["definition_count"]!.GetValue()); Assert.Empty(structured["callers"]!.AsArray()); - Assert.Equal("depth_zero", structured["zero_result_reason"]!.GetValue()); + Assert.Equal("depth_requested_zero", structured["zero_result_reason"]!.GetValue()); + Assert.Equal("precondition", structured["suggestion_type"]!.GetValue()); + var failureChain = Assert.IsType(structured["impact_failure_chain"]); + Assert.Equal("depth_requested_zero", Assert.Single(failureChain)!.GetValue()); Assert.Equal("Use `cdidx impact --max-hops 1` or higher to traverse callers.", structured["suggestion"]!.GetValue()); } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs index 7b8da58d61..5cdb77fcad 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerTests.cs @@ -13127,6 +13127,9 @@ public void RunImpact_ZeroJson_EmitsEnvelopeAndFreshness() Assert.Equal(3, json.GetProperty("max_depth").GetInt32()); Assert.False(json.GetProperty("truncated").GetBoolean()); Assert.True(json.GetProperty("graph_table_available").GetBoolean()); + Assert.Equal("no_matching_definition", json.GetProperty("zero_result_reason").GetString()); + Assert.Equal("resolution", json.GetProperty("suggestion_type").GetString()); + Assert.Equal("definition_not_found", Assert.Single(json.GetProperty("impact_failure_chain").EnumerateArray()).GetString()); } finally { @@ -13156,7 +13159,9 @@ public void RunImpact_ZeroDepthJson_ResolvesSymbolWithoutTraversingCallers() Assert.Equal(0, json.GetProperty("actual_depth").GetInt32()); Assert.Equal(0, json.GetProperty("count").GetInt32()); Assert.Equal(1, json.GetProperty("definition_count").GetInt32()); - Assert.Equal("depth_zero", json.GetProperty("zero_result_reason").GetString()); + Assert.Equal("depth_requested_zero", json.GetProperty("zero_result_reason").GetString()); + Assert.Equal("precondition", json.GetProperty("suggestion_type").GetString()); + Assert.Equal("depth_requested_zero", Assert.Single(json.GetProperty("impact_failure_chain").EnumerateArray()).GetString()); Assert.Equal("Use `cdidx impact --max-hops 1` or higher to traverse callers.", json.GetProperty("suggestion").GetString()); Assert.Empty(json.GetProperty("callers").EnumerateArray()); } @@ -13166,6 +13171,31 @@ public void RunImpact_ZeroDepthJson_ResolvesSymbolWithoutTraversingCallers() } } + [Fact] + public void RunImpact_StrictReturnsFeatureUnavailableForResolutionFailure() + { + var projectRoot = TestProjectHelper.CreateTempProject("cdidx_impact_strict_resolution_failure"); + try + { + var dbPath = CreateIndexedDbWithSingleFile(projectRoot, markGraphReady: true); + var (exitCode, stdout, stderr) = CaptureConsole(() => QueryCommandRunner.RunImpact( + ["DefinitelyMissingSymbol", "--db", dbPath, "--json", "--strict"], + _jsonOptions)); + + using var document = ParseJsonOutput(stdout); + var json = document.RootElement; + + Assert.Equal(CommandExitCodes.FeatureUnavailable, exitCode); + Assert.Equal(string.Empty, stderr); + Assert.Equal("no_matching_definition", json.GetProperty("zero_result_reason").GetString()); + Assert.Equal("definition_not_found", Assert.Single(json.GetProperty("impact_failure_chain").EnumerateArray()).GetString()); + } + finally + { + TestProjectHelper.DeleteDirectory(projectRoot); + } + } + [Fact] public void RunFiles_ZeroJson_OnEmptyIndex_EmitsNullIndexedAt() {