Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions changelog.d/unreleased/2063.fixed.md
Original file line number Diff line number Diff line change
@@ -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 行に潰さなくなりました。
34 changes: 23 additions & 11 deletions src/CodeIndex/Database/DbReader.GraphQueries.cs
Original file line number Diff line number Diff line change
Expand Up @@ -819,7 +819,7 @@ private List<CallerResult> 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
Expand All @@ -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);
Expand All @@ -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<string, int>(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
Expand Down Expand Up @@ -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<string>(StringComparer.OrdinalIgnoreCase);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 1 addition & 1 deletion src/CodeIndex/Mcp/McpToolDefinitions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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",
Expand Down
2 changes: 1 addition & 1 deletion src/CodeIndex/Mcp/McpToolHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
Expand Down
3 changes: 3 additions & 0 deletions src/CodeIndex/Models/QueryResults.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> ReferenceKinds { get; set; } = Array.Empty<string>();
public IReadOnlyDictionary<string, int> ReferenceKindCounts { get; set; } = new Dictionary<string, int>();
// 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
Expand Down
38 changes: 38 additions & 0 deletions tests/CodeIndex.Tests/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>() == "src/App.cs"
&& caller!["callerName"]!.GetValue<string>() == "Boot")
.ToList();

Assert.Equal(2, callers.Count);
Assert.Equal(new[] { "call", "subscribe" }, callers.Select(caller => caller!["referenceKind"]!.GetValue<string>()).Order().ToArray());
Assert.All(callers, caller => Assert.Equal(1, caller!["referenceCount"]!.GetValue<int>()));
}

[Fact]
public void ToolsList_ImpactAnalysisMaxHopsSchemaDocumentsCap()
{
Expand Down
Loading