From ff516b2e5fa2012d1da717f4763e00104216a221 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 18:36:20 +0900 Subject: [PATCH 1/2] Fix impact traversal edge-kind deduplication (#2063) --- changelog.d/unreleased/2063.fixed.md | 19 ++++++++++ .../Database/DbReader.GraphQueries.cs | 34 +++++++++++------ src/CodeIndex/Mcp/McpToolDefinitions.cs | 2 +- src/CodeIndex/Mcp/McpToolHandlers.cs | 2 +- src/CodeIndex/Models/QueryResults.cs | 3 ++ tests/CodeIndex.Tests/McpServerTests.cs | 38 +++++++++++++++++++ 6 files changed, 85 insertions(+), 13 deletions(-) create mode 100644 changelog.d/unreleased/2063.fixed.md diff --git a/changelog.d/unreleased/2063.fixed.md b/changelog.d/unreleased/2063.fixed.md new file mode 100644 index 0000000000..5287c02b44 --- /dev/null +++ b/changelog.d/unreleased/2063.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 2063 +affected: + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - src/CodeIndex/Mcp/McpToolDefinitions.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - src/CodeIndex/Models/QueryResults.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **Impact analysis now keeps same-caller edges distinct by reference kind (#2063)** — `impact_analysis` and transitive caller traversal no longer collapse a `call` and a `subscribe` edge from the same caller to the same target into one visited row. + +## 日本語 + +- **Impact analysis が同じ caller の edge を reference kind 別に保持するようになりました (#2063)** — `impact_analysis` と推移 caller traversal は、同じ caller から同じ target への `call` と `subscribe` edge を visited 判定で 1 行に潰さなくなりました。 diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 176edc88c8..62818c6455 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -819,7 +819,7 @@ private List GetCallersExact(string symbolName, int limit, int off var callerContainerPredicate = BuildCallerContainerPredicate("f", "r"); var sql = $@" WITH logical_references AS ( - SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.line, + SELECT f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.line, MAX({selfReferenceSql}) AS is_self_reference, MAX({mutualRecursionSql}) AS is_mutual_recursion FROM symbol_references r @@ -833,15 +833,15 @@ AND r.reference_kind IN {CallGraphReferenceKindsSql} sql += " AND f.lang = @lang"; AppendPathFilters(ref sql, pathPatterns, excludePathPatterns, excludeTests); sql += @" - GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.file_id, r.line, r.column_number + GROUP BY f.path, f.lang, r.container_kind, r.container_name, r.symbol_name, r.reference_kind, r.file_id, r.line, r.column_number ) SELECT path, lang, " + BuildCallerKindProjectionSql("r") + @" AS container_kind, " + BuildCallerNameProjectionSql("r") + @" AS container_name, symbol_name, - MIN(line) AS first_line, COUNT(*) AS reference_count, + reference_kind, MIN(line) AS first_line, COUNT(*) AS reference_count, MAX(is_self_reference) AS is_self_reference, MAX(is_mutual_recursion) AS is_mutual_recursion FROM logical_references r - GROUP BY path, lang, container_kind, container_name, symbol_name"; - sql += $" ORDER BY {GetPathBucketOrderSql("r.path")}, reference_count DESC, r.path, COALESCE(r.container_name, ''), COALESCE(r.container_kind, ''), r.symbol_name, first_line LIMIT @limit OFFSET @offset"; + GROUP BY path, lang, container_kind, container_name, symbol_name, reference_kind"; + sql += $" ORDER BY {GetPathBucketOrderSql("r.path")}, reference_count DESC, r.path, COALESCE(r.container_name, ''), COALESCE(r.container_kind, ''), r.symbol_name, reference_kind, first_line LIMIT @limit OFFSET @offset"; cmd.CommandText = sql; cmd.Parameters.AddWithValue("@symbolName", symbolName); @@ -865,15 +865,24 @@ FROM logical_references r CallerKind = GetNullableString(reader, 2), CallerName = GetNullableString(reader, 3), CalleeName = reader.GetString(4), - FirstLine = reader.GetInt32(5), - ReferenceCount = reader.GetInt32(6), - HasSelfReference = reader.GetInt32(7) != 0, - HasMutualRecursion = reader.GetInt32(8) != 0, + ReferenceKind = reader.GetString(5), + ReferenceKinds = [reader.GetString(5)], + ReferenceKindCounts = new Dictionary(StringComparer.Ordinal) + { + [reader.GetString(5)] = reader.GetInt32(7), + }, + FirstLine = reader.GetInt32(6), + ReferenceCount = reader.GetInt32(7), + HasSelfReference = reader.GetInt32(8) != 0, + HasMutualRecursion = reader.GetInt32(9) != 0, }); } return results; } + private static string BuildImpactVisitedKey(CallerResult caller, string callerName) + => $"{caller.Path}:{callerName}:{caller.ReferenceKind}"; + // Per-result cap on the number of distinct shortest paths surfaced by impact --with-paths. // Each call chain row may carry multiple converging paths from the resolved root through // distinct intermediates; the cap keeps JSON output bounded for diamond-heavy graphs and @@ -983,7 +992,7 @@ FROM logical_references r if (string.Equals(callerName, resolvedName, StringComparison.OrdinalIgnoreCase) && (rootDefinitionPaths.Count == 0 || rootDefinitionPaths.Contains(caller.Path))) continue; - var key = $"{caller.Path}:{callerName}"; + var key = BuildImpactVisitedKey(caller, callerName); if (!cycleParentsByName.TryGetValue(callerName, out var cycleParentSet)) { cycleParentSet = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -1017,6 +1026,9 @@ FROM logical_references r Depth = depth + 1, FirstLine = caller.FirstLine, ReferenceCount = caller.ReferenceCount, + ReferenceKind = caller.ReferenceKind, + ReferenceKinds = caller.ReferenceKinds, + ReferenceKindCounts = caller.ReferenceKindCounts, }); if (withPaths) @@ -1158,7 +1170,7 @@ private bool InspectBoundaryCallers( } cycleParentSet.Add(symbolName); - var key = $"{caller.Path}:{callerName}"; + var key = BuildImpactVisitedKey(caller, callerName); if (!visited.Contains(key)) return true; } diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 6c5971a79d..83f44fe3c2 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -265,7 +265,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "impact_analysis", - "Compute the transitive caller chain for a symbol. The symbol-level BFS walks only call-graph kinds (`call`, `instantiate`, `subscribe`) and excludes metadata-only edges (`attribute`, `annotation`, `type_reference`) so metadata cycles do not inflate caller counts. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, may return heuristic file-level dependency hints instead; those file hints can include metadata edges, so check `impact_mode`, `heuristic`, and `file_impacts`. When `truncated` is true, inspect `truncated_reason` (`user_limit` means raising `limit` returns more; `safety_cap` means the graph is likely pathological and raising `limit` will not help). Pass `withPaths: true` when you need the call chain via specific intermediates — each caller then carries a `paths` array of shortest routes (issue #1536). / シンボルの推移的呼び出しチェーンを算出。symbol-level BFS は call graph 種別(`call`、`instantiate`、`subscribe`)のみを辿り、metadata-only edge(`attribute`、`annotation`、`type_reference`)を除外するため、metadata cycle で caller 件数が膨らまない。scoped query が単一の class / struct / interface に解決されても symbol-level caller が無い場合は、代わりに heuristic な file-level dependency hint を返すことがある。この file hint は metadata edge を含み得るため、`impact_mode`・`heuristic`・`file_impacts` を確認すること。`truncated` が真のときは `truncated_reason` を見て、`user_limit` なら `limit` を増やせば残りも取得可能、`safety_cap` ならグラフが病的で `limit` を増やしても解消しないことを区別すること。中間シンボル経由の経路が必要な場合は `withPaths: true` を渡すと、各 caller に経路配列 `paths` が付く(issue #1536)。", + "Compute the transitive caller chain for a symbol. The symbol-level BFS walks only call-graph kinds (`call`, `instantiate`, `subscribe`) and excludes metadata-only edges (`attribute`, `annotation`, `type_reference`) so metadata cycles do not inflate caller counts. Multiple edge kinds from the same caller to the same target are counted and returned separately, with `reference_kind`, `reference_kinds`, and `reference_kind_counts` on each caller row. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, may return heuristic file-level dependency hints instead; those file hints can include metadata edges, so check `impact_mode`, `heuristic`, and `file_impacts`. When `truncated` is true, inspect `truncated_reason` (`user_limit` means raising `limit` returns more; `safety_cap` means the graph is likely pathological and raising `limit` will not help). Pass `withPaths: true` when you need the call chain via specific intermediates — each caller then carries a `paths` array of shortest routes (issue #1536). / シンボルの推移的呼び出しチェーンを算出。symbol-level BFS は call graph 種別(`call`、`instantiate`、`subscribe`)のみを辿り、metadata-only edge(`attribute`、`annotation`、`type_reference`)を除外するため、metadata cycle で caller 件数が膨らまない。同じ caller から同じ target への複数 edge kind は別々に数えて返し、各 caller 行に `reference_kind`、`reference_kinds`、`reference_kind_counts` が付く。scoped query が単一の class / struct / interface に解決されても symbol-level caller が無い場合は、代わりに heuristic な file-level dependency hint を返すことがある。この file hint は metadata edge を含み得るため、`impact_mode`・`heuristic`・`file_impacts` を確認すること。`truncated` が真のときは `truncated_reason` を見て、`user_limit` なら `limit` を増やせば残りも取得可能、`safety_cap` ならグラフが病的で `limit` を増やしても解消しないことを区別すること。中間シンボル経由の経路が必要な場合は `withPaths: true` を渡すと、各 caller に経路配列 `paths` が付く(issue #1536)。", new JsonObject { ["type"] = "object", diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index cce9b46760..79e63379f3 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -117,7 +117,7 @@ bool All(params string[] names) parts.Add("Use 'symbol_hotspots' to find the most-referenced symbols — central, high-impact code that changes may affect widely."); if (On("impact_analysis")) - parts.Add("Use 'impact_analysis' to compute transitive callers of a symbol. Pass maxHops=0 when you only want symbol resolution without traversing callers. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, it may instead return heuristic file-level dependency hints; always inspect 'impact_mode', 'heuristic', and 'file_impacts'."); + parts.Add("Use 'impact_analysis' to compute transitive callers of a symbol. Pass maxHops=0 when you only want symbol resolution without traversing callers. Caller rows are edge-kind aware: the same caller can appear once for 'call' and once for 'subscribe'. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, it may instead return heuristic file-level dependency hints; always inspect 'impact_mode', 'heuristic', and 'file_impacts'."); if (On("suggest_improvement")) parts.Add("Use 'suggest_improvement' to report gaps or errors you notice (e.g. missing language support, poor ranking, crashes) — never include source code, only describe the issue in natural language."); diff --git a/src/CodeIndex/Models/QueryResults.cs b/src/CodeIndex/Models/QueryResults.cs index df5f5a8561..6902f31ad0 100644 --- a/src/CodeIndex/Models/QueryResults.cs +++ b/src/CodeIndex/Models/QueryResults.cs @@ -261,6 +261,9 @@ public class ImpactResult public int Depth { get; set; } public int FirstLine { get; set; } public int ReferenceCount { get; set; } + public string ReferenceKind { get; set; } = string.Empty; + public IReadOnlyList ReferenceKinds { get; set; } = Array.Empty(); + public IReadOnlyDictionary ReferenceKindCounts { get; set; } = new Dictionary(); // Optional list of distinct shortest call paths from the resolved root symbol // through any intermediates to this caller. Each inner list is ordered // [resolvedRoot, intermediate..., thisCallerName]. Populated only when the diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 1aadf51546..265711bb14 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -3513,6 +3513,44 @@ public void ToolsCall_ImpactAnalysis_DeprecatedMaxDepthSurfacesWarning() Assert.Contains("maxDepth is deprecated", warning); } + [Fact] + public void ToolsCall_ImpactAnalysis_ReturnsSameCallerPerReferenceKind() + { + InsertIndexedFile("src/EventHub.cs", "csharp", + """ + public class EventHub + { + public event System.Action? Changed; + public void Changed() { } + } + """); + InsertIndexedFile("src/App.cs", "csharp", + """ + public class App + { + public void Boot(EventHub hub) + { + hub.Changed += OnChanged; + hub.Changed(); + } + + private void OnChanged() { } + } + """); + + var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"impact_analysis","arguments":{"query":"Changed","limit":10}}}""")!; + var response = _server.HandleMessage(request)!; + var structured = response["result"]!["structuredContent"]!; + var callers = structured["callers"]!.AsArray() + .Where(caller => caller!["path"]!.GetValue() == "src/App.cs" + && caller!["callerName"]!.GetValue() == "Boot") + .ToList(); + + Assert.Equal(2, callers.Count); + Assert.Equal(new[] { "call", "subscribe" }, callers.Select(caller => caller!["referenceKind"]!.GetValue()).Order().ToArray()); + Assert.All(callers, caller => Assert.Equal(1, caller!["referenceCount"]!.GetValue())); + } + [Fact] public void ToolsList_ImpactAnalysisMaxHopsSchemaDocumentsCap() { From e29a0a054dc46feb3dd8fc92904172b1d5b74f28 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 19:05:57 +0900 Subject: [PATCH 2/2] Fix impact analysis MCP field docs (#2063) --- src/CodeIndex/Mcp/McpToolDefinitions.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/CodeIndex/Mcp/McpToolDefinitions.cs b/src/CodeIndex/Mcp/McpToolDefinitions.cs index 83f44fe3c2..4272845acd 100644 --- a/src/CodeIndex/Mcp/McpToolDefinitions.cs +++ b/src/CodeIndex/Mcp/McpToolDefinitions.cs @@ -265,7 +265,7 @@ private JsonNode HandleToolsList(JsonNode? id) ReadOnlyAnnotations()), CreateToolDefinition( "impact_analysis", - "Compute the transitive caller chain for a symbol. The symbol-level BFS walks only call-graph kinds (`call`, `instantiate`, `subscribe`) and excludes metadata-only edges (`attribute`, `annotation`, `type_reference`) so metadata cycles do not inflate caller counts. Multiple edge kinds from the same caller to the same target are counted and returned separately, with `reference_kind`, `reference_kinds`, and `reference_kind_counts` on each caller row. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, may return heuristic file-level dependency hints instead; those file hints can include metadata edges, so check `impact_mode`, `heuristic`, and `file_impacts`. When `truncated` is true, inspect `truncated_reason` (`user_limit` means raising `limit` returns more; `safety_cap` means the graph is likely pathological and raising `limit` will not help). Pass `withPaths: true` when you need the call chain via specific intermediates — each caller then carries a `paths` array of shortest routes (issue #1536). / シンボルの推移的呼び出しチェーンを算出。symbol-level BFS は call graph 種別(`call`、`instantiate`、`subscribe`)のみを辿り、metadata-only edge(`attribute`、`annotation`、`type_reference`)を除外するため、metadata cycle で caller 件数が膨らまない。同じ caller から同じ target への複数 edge kind は別々に数えて返し、各 caller 行に `reference_kind`、`reference_kinds`、`reference_kind_counts` が付く。scoped query が単一の class / struct / interface に解決されても symbol-level caller が無い場合は、代わりに heuristic な file-level dependency hint を返すことがある。この file hint は metadata edge を含み得るため、`impact_mode`・`heuristic`・`file_impacts` を確認すること。`truncated` が真のときは `truncated_reason` を見て、`user_limit` なら `limit` を増やせば残りも取得可能、`safety_cap` ならグラフが病的で `limit` を増やしても解消しないことを区別すること。中間シンボル経由の経路が必要な場合は `withPaths: true` を渡すと、各 caller に経路配列 `paths` が付く(issue #1536)。", + "Compute the transitive caller chain for a symbol. The symbol-level BFS walks only call-graph kinds (`call`, `instantiate`, `subscribe`) and excludes metadata-only edges (`attribute`, `annotation`, `type_reference`) so metadata cycles do not inflate caller counts. Multiple edge kinds from the same caller to the same target are counted and returned separately, with `referenceKind`, `referenceKinds`, and `referenceKindCounts` on each caller row. When a scoped query resolves to a single class / struct / interface but no symbol-level callers exist, may return heuristic file-level dependency hints instead; those file hints can include metadata edges, so check `impact_mode`, `heuristic`, and `file_impacts`. When `truncated` is true, inspect `truncated_reason` (`user_limit` means raising `limit` returns more; `safety_cap` means the graph is likely pathological and raising `limit` will not help). Pass `withPaths: true` when you need the call chain via specific intermediates — each caller then carries a `paths` array of shortest routes (issue #1536). / シンボルの推移的呼び出しチェーンを算出。symbol-level BFS は call graph 種別(`call`、`instantiate`、`subscribe`)のみを辿り、metadata-only edge(`attribute`、`annotation`、`type_reference`)を除外するため、metadata cycle で caller 件数が膨らまない。同じ caller から同じ target への複数 edge kind は別々に数えて返し、各 caller 行に `referenceKind`、`referenceKinds`、`referenceKindCounts` が付く。scoped query が単一の class / struct / interface に解決されても symbol-level caller が無い場合は、代わりに heuristic な file-level dependency hint を返すことがある。この file hint は metadata edge を含み得るため、`impact_mode`・`heuristic`・`file_impacts` を確認すること。`truncated` が真のときは `truncated_reason` を見て、`user_limit` なら `limit` を増やせば残りも取得可能、`safety_cap` ならグラフが病的で `limit` を増やしても解消しないことを区別すること。中間シンボル経由の経路が必要な場合は `withPaths: true` を渡すと、各 caller に経路配列 `paths` が付く(issue #1536)。", new JsonObject { ["type"] = "object",