From 5f8959be33fb4edf95976a3ffc8d44b6f8bc04d7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 18:50:53 +0900 Subject: [PATCH 1/2] Fix C# polymorphic impact traversal (#2060) --- changelog.d/unreleased/2060.fixed.md | 17 ++ .../Database/DbReader.CSharpResolution.cs | 200 ++++++++++++++++- .../Database/DbReader.GraphQueries.cs | 23 +- tests/CodeIndex.Tests/DbReaderTests.cs | 209 ++++++++++++++++++ 4 files changed, 434 insertions(+), 15 deletions(-) create mode 100644 changelog.d/unreleased/2060.fixed.md diff --git a/changelog.d/unreleased/2060.fixed.md b/changelog.d/unreleased/2060.fixed.md new file mode 100644 index 0000000000..949e08e619 --- /dev/null +++ b/changelog.d/unreleased/2060.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2060 +affected: + - src/CodeIndex/Database/DbReader.CSharpResolution.cs + - src/CodeIndex/Database/DbReader.GraphQueries.cs + - tests/CodeIndex.Tests/DbReaderTests.cs +--- + +## English + +- **C# caller and impact queries now follow polymorphic dispatch to concrete implementations (#2060)** — exact graph traversal expands implementation method queries through inherited abstract base methods and implemented interface methods, so callers of the static base/interface target are reachable from the concrete override. + +## 日本語 + +- **C# の callers / impact が polymorphic dispatch を具象実装まで追跡するようになりました (#2060)** — exact graph traversal で実装メソッドの検索時に継承元の abstract base method と実装 interface method へ展開し、静的な base / interface target を呼ぶ caller が具象 override から到達可能になります。 diff --git a/src/CodeIndex/Database/DbReader.CSharpResolution.cs b/src/CodeIndex/Database/DbReader.CSharpResolution.cs index 0f4726a62f..15aababe03 100644 --- a/src/CodeIndex/Database/DbReader.CSharpResolution.cs +++ b/src/CodeIndex/Database/DbReader.CSharpResolution.cs @@ -158,14 +158,167 @@ private HashSet GetInheritedCSharpContainingTypes(CSharpContainingTypeSc return inheritedContainingTypes; } - private void CollectInheritedCSharpContainingTypes(CSharpContainingTypeScope containingTypeScope, HashSet inheritedContainingTypes, HashSet visited) + private List GetCSharpPolymorphicDispatchSymbolNames(string symbolName) + { + var memberName = SqlNameResolver.GetLeafName(symbolName); + if (string.IsNullOrWhiteSpace(memberName)) + return []; + + var containingTypeNames = new List(); + var lastDot = symbolName.LastIndexOf('.'); + var hasExplicitContainingType = lastDot > 0; + if (hasExplicitContainingType) + { + var explicitContainingTypeName = symbolName[..lastDot]; + containingTypeNames.Add(explicitContainingTypeName); + } + else + { + using var cmd = _conn.CreateCommand(); + cmd.CommandText = @" + SELECT s.container_qualified_name + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE f.lang = 'csharp' + AND s.kind IN ('function', 'property') + AND s.container_qualified_name IS NOT NULL + AND s.container_qualified_name != '' + AND s.name = @memberName COLLATE NOCASE + GROUP BY s.container_qualified_name"; + cmd.Parameters.AddWithValue("@memberName", memberName); + + using var reader = cmd.ExecuteTrackedReader(); + while (reader.TrackedRead()) + containingTypeNames.Add(reader.GetString(0)); + } + + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var containingTypeName in containingTypeNames) + { + AddCSharpBaseListDispatchNames(containingTypeName, memberName, names); + } + + foreach (var containingTypeName in containingTypeNames) + { + var containingTypeScope = GetCSharpContainingTypeScope(containingTypeName); + if (containingTypeScope == null) + continue; + + foreach (var inheritedContainingType in GetInheritedCSharpContainingTypes(containingTypeScope)) + { + var inheritedMemberName = CombineDbQualifiedName(inheritedContainingType, memberName); + if (!string.IsNullOrWhiteSpace(inheritedMemberName)) + names.Add(inheritedMemberName); + if (!hasExplicitContainingType) + names.Add(memberName); + } + } + + return names.ToList(); + } + + private void AddCSharpBaseListDispatchNames(string containingTypeName, string memberName, HashSet names) { - var directBaseScope = ResolveDirectCSharpBaseContainingTypeScope(containingTypeScope); - if (directBaseScope == null || !visited.Add(directBaseScope.QualifiedName)) + var signature = GetCSharpContainingTypeScope(containingTypeName)?.Signature; + if (!string.IsNullOrWhiteSpace(signature)) + { + AddCSharpBaseListDispatchNamesFromSignature(containingTypeName, memberName, signature, names); return; + } - inheritedContainingTypes.Add(directBaseScope.QualifiedName); - CollectInheritedCSharpContainingTypes(directBaseScope, inheritedContainingTypes, visited); + var shortTypeName = GetLastQualifiedSegment(containingTypeName); + if (string.IsNullOrWhiteSpace(shortTypeName)) + return; + + using var cmd = _conn.CreateCommand(); + cmd.CommandText = @" + SELECT s.signature + FROM symbols s + JOIN files f ON s.file_id = f.id + WHERE f.lang = 'csharp' + AND s.kind IN ('class', 'struct', 'interface') + AND (s.name = @shortTypeName COLLATE NOCASE OR s.name = @containingTypeName COLLATE NOCASE) + LIMIT 1"; + cmd.Parameters.AddWithValue("@shortTypeName", shortTypeName); + cmd.Parameters.AddWithValue("@containingTypeName", containingTypeName); + + signature = cmd.ExecuteScalar() as string; + if (string.IsNullOrWhiteSpace(signature)) + return; + + AddCSharpBaseListDispatchNamesFromSignature(containingTypeName, memberName, signature, names); + } + + private static void AddCSharpBaseListDispatchNamesFromSignature(string containingTypeName, string memberName, string signature, HashSet names) + { + var namespacePrefix = string.Empty; + var lastDot = containingTypeName.LastIndexOf('.'); + if (lastDot > 0) + namespacePrefix = containingTypeName[..lastDot]; + + foreach (var baseTypeReference in ParseCSharpBaseTypeReferences(signature)) + { + var normalizedBase = NormalizeCSharpBaseTypeReference(baseTypeReference); + if (string.IsNullOrWhiteSpace(normalizedBase)) + continue; + + var inheritedMemberName = CombineDbQualifiedName(normalizedBase, memberName); + if (!string.IsNullOrWhiteSpace(inheritedMemberName)) + names.Add(inheritedMemberName); + if (!string.IsNullOrWhiteSpace(namespacePrefix) && !SqlNameResolver.HasQualifier(normalizedBase)) + { + inheritedMemberName = CombineDbQualifiedName(CombineDbQualifiedName(namespacePrefix, normalizedBase), memberName); + if (!string.IsNullOrWhiteSpace(inheritedMemberName)) + names.Add(inheritedMemberName); + } + } + } + + private void CollectInheritedCSharpContainingTypes(CSharpContainingTypeScope containingTypeScope, HashSet inheritedContainingTypes, HashSet visited) + { + foreach (var inheritedScope in ResolveDirectCSharpInheritedContainingTypeScopes(containingTypeScope)) + { + if (!visited.Add(inheritedScope.QualifiedName)) + continue; + + inheritedContainingTypes.Add(inheritedScope.QualifiedName); + CollectInheritedCSharpContainingTypes(inheritedScope, inheritedContainingTypes, visited); + } + } + + private List ResolveDirectCSharpInheritedContainingTypeScopes(CSharpContainingTypeScope containingTypeScope) + { + if (containingTypeScope.Kind is not ("class" or "struct" or "interface")) + return []; + + var baseTypeReferences = ParseCSharpBaseTypeReferences(containingTypeScope.Signature); + if (baseTypeReferences.Count == 0) + return []; + + var scopes = new List(); + foreach (var baseTypeReference in baseTypeReferences) + { + var inheritedQualifiedName = ResolveScopedCSharpContainingTypeQualifiedName( + containingTypeScope.Path, + containingTypeScope.DeclarationLine, + baseTypeReference); + if (string.IsNullOrWhiteSpace(inheritedQualifiedName)) + continue; + + var inheritedScope = GetCSharpContainingTypeScope(inheritedQualifiedName); + if (inheritedScope == null) + continue; + if (containingTypeScope.Kind == "class" && inheritedScope.Kind is not ("class" or "interface")) + continue; + if (containingTypeScope.Kind == "struct" && inheritedScope.Kind != "interface") + continue; + if (containingTypeScope.Kind == "interface" && inheritedScope.Kind != "interface") + continue; + + scopes.Add(inheritedScope); + } + + return scopes; } private CSharpContainingTypeScope? GetCSharpContainingTypeScope(string qualifiedName) @@ -651,9 +804,15 @@ AND s.kind IN ('class', 'struct', 'interface') } private static string? ParseCSharpBaseTypeReference(string? signature) + { + var references = ParseCSharpBaseTypeReferences(signature); + return references.Count == 0 ? null : references[0]; + } + + private static List ParseCSharpBaseTypeReferences(string? signature) { if (string.IsNullOrWhiteSpace(signature)) - return null; + return []; var text = signature.TrimEnd(); if (text.EndsWith("{", StringComparison.Ordinal)) @@ -661,15 +820,22 @@ AND s.kind IN ('class', 'struct', 'interface') var colonIndex = FindCSharpBaseListColonIndex(text); if (colonIndex < 0) - return null; + return []; var baseList = text[(colonIndex + 1)..]; var whereIndex = baseList.IndexOf(" where ", StringComparison.Ordinal); if (whereIndex >= 0) baseList = baseList[..whereIndex]; - var firstEntry = TakeFirstCSharpBaseListEntry(baseList).Trim(); - return firstEntry.Length == 0 ? null : firstEntry; + var entries = new List(); + foreach (var entry in EnumerateCSharpBaseListEntries(baseList)) + { + var trimmed = entry.Trim(); + if (trimmed.Length > 0) + entries.Add(trimmed); + } + + return entries; } private static int FindCSharpBaseListColonIndex(string signature) @@ -713,10 +879,19 @@ private static int FindCSharpBaseListColonIndex(string signature) } private static string TakeFirstCSharpBaseListEntry(string baseList) + { + foreach (var entry in EnumerateCSharpBaseListEntries(baseList)) + return entry; + + return baseList; + } + + private static IEnumerable EnumerateCSharpBaseListEntries(string baseList) { var angleDepth = 0; var parenDepth = 0; var squareDepth = 0; + var start = 0; for (var i = 0; i < baseList.Length; i++) { switch (baseList[i]) @@ -744,12 +919,15 @@ private static string TakeFirstCSharpBaseListEntry(string baseList) break; case ',': if (angleDepth == 0 && parenDepth == 0 && squareDepth == 0) - return baseList[..i]; + { + yield return baseList[start..i]; + start = i + 1; + } break; } } - return baseList; + yield return baseList[start..]; } private static string NormalizeCSharpBaseTypeReference(string typeReference) diff --git a/src/CodeIndex/Database/DbReader.GraphQueries.cs b/src/CodeIndex/Database/DbReader.GraphQueries.cs index 176edc88c8..37663d42b4 100644 --- a/src/CodeIndex/Database/DbReader.GraphQueries.cs +++ b/src/CodeIndex/Database/DbReader.GraphQueries.cs @@ -798,17 +798,25 @@ private List GetCallersExact(string symbolName, int limit, int off // caller 側も leaf `--exact` と同じく FoldReady なら folded equality、legacy DB では // `COLLATE NOCASE` fallback。definition と caller 行の casing 差もここで吸収する。 var allowSqlLeafFallback = !SqlNameResolver.HasQualifier(symbolName); + var polymorphicCSharpSymbolNames = lang is null or "csharp" + ? GetCSharpPolymorphicDispatchSymbolNames(symbolName) + : []; + var polymorphicNameCondition = polymorphicCSharpSymbolNames.Count == 0 + ? string.Empty + : _foldReady + ? " OR (f.lang = 'csharp' AND r.symbol_name_folded IN (" + string.Join(", ", polymorphicCSharpSymbolNames.Select((_, i) => $"@polymorphicSymbolNameFolded{i}")) + "))" + : " OR (f.lang = 'csharp' AND r.symbol_name COLLATE NOCASE IN (" + string.Join(", ", polymorphicCSharpSymbolNames.Select((_, i) => $"@polymorphicSymbolName{i}")) + "))"; var nameCondition = _foldReady ? allowSqlLeafFallback ? @" - AND (r.symbol_name_folded = @symbolNameFolded OR (f.lang = 'sql' AND r.symbol_name_folded = @symbolNameLeafFolded))" + AND (r.symbol_name_folded = @symbolNameFolded OR (f.lang = 'sql' AND r.symbol_name_folded = @symbolNameLeafFolded)" + polymorphicNameCondition + ")" : @" - AND (((f.lang = 'sql') AND sql_context_has_name_folded_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name_folded = @symbolNameFolded))" + AND (((f.lang = 'sql') AND sql_context_has_name_folded_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name_folded = @symbolNameFolded)" + polymorphicNameCondition + ")" : allowSqlLeafFallback ? @" - AND (r.symbol_name = @symbolName COLLATE NOCASE OR (f.lang = 'sql' AND r.symbol_name = sql_leaf_name(@symbolName) COLLATE NOCASE))" + AND (r.symbol_name = @symbolName COLLATE NOCASE OR (f.lang = 'sql' AND r.symbol_name = sql_leaf_name(@symbolName) COLLATE NOCASE)" + polymorphicNameCondition + ")" : @" - AND (((f.lang = 'sql') AND sql_context_has_name_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @symbolName COLLATE NOCASE))"; + AND (((f.lang = 'sql') AND sql_context_has_name_at(" + contextSql + @", @symbolName, r.column_number) = 1) OR ((f.lang != 'sql') AND r.symbol_name = @symbolName COLLATE NOCASE)" + polymorphicNameCondition + ")"; // impact BFS must share the call-graph contract with `callers`/`callees`/`hotspots`, // so event subscriptions (`Click += OnClick`) also participate in the transitive @@ -848,6 +856,13 @@ FROM logical_references r cmd.Parameters.AddWithValue("@symbolNameLeafFolded", NameFold.Fold(SqlNameResolver.GetLeafName(symbolName)) ?? SqlNameResolver.GetLeafName(symbolName)); if (_foldReady) cmd.Parameters.AddWithValue("@symbolNameFolded", NameFold.Fold(symbolName) ?? symbolName); + for (var i = 0; i < polymorphicCSharpSymbolNames.Count; i++) + { + if (_foldReady) + cmd.Parameters.AddWithValue($"@polymorphicSymbolNameFolded{i}", NameFold.Fold(polymorphicCSharpSymbolNames[i]) ?? polymorphicCSharpSymbolNames[i]); + else + cmd.Parameters.AddWithValue($"@polymorphicSymbolName{i}", polymorphicCSharpSymbolNames[i]); + } if (lang != null) cmd.Parameters.AddWithValue("@lang", lang); AddPathFilterParameters(cmd, pathPatterns, excludePathPatterns); diff --git a/tests/CodeIndex.Tests/DbReaderTests.cs b/tests/CodeIndex.Tests/DbReaderTests.cs index 9d3fff128f..eb783a00fa 100644 --- a/tests/CodeIndex.Tests/DbReaderTests.cs +++ b/tests/CodeIndex.Tests/DbReaderTests.cs @@ -405,6 +405,18 @@ private void InsertManualReferences(string path, string containerName, string ta _writer.InsertReferences(references); } + private void InsertManualReferences(string path, IReadOnlyList references) + { + using var cmd = _db.Connection.CreateCommand(); + cmd.CommandText = "SELECT id FROM files WHERE path = @path"; + cmd.Parameters.AddWithValue("@path", path); + var fileId = (long)cmd.ExecuteScalar()!; + foreach (var reference in references) + reference.FileId = fileId; + + _writer.InsertReferences(references); + } + private void InsertManualReference(string path, string lang, string? containerKind, string? containerName, string target, string kind) { var fileId = _writer.UpsertFile(new FileRecord @@ -2878,6 +2890,203 @@ void Run() Assert.Equal(1, caller.ReferenceCount); } + [Fact] + public void GetTransitiveCallers_CSharpExact_MapsInterfaceDispatchToConcreteImplementation() + { + InsertIndexedFile("src/PolymorphicDispatch.cs", "csharp", + """ + namespace Demo; + + public interface IWorker + { + void Execute(); + } + + public sealed class Worker : IWorker + { + public void Execute() { } + } + + public sealed class Coordinator + { + public void Run(IWorker worker) + { + worker.Execute(); + } + } + """); + InsertManualReferences("src/PolymorphicDispatch.cs", + [ + new ReferenceRecord + { + SymbolName = "Demo.IWorker.Execute", + ReferenceKind = "call", + Line = 16, + Column = 16, + Context = "worker.Execute();", + ContainerKind = "function", + ContainerName = "Run", + }, + ]); + + var impact = _reader.GetTransitiveCallers("Demo.Worker.Execute", maxDepth: 2, lang: "csharp", pathPatterns: ["PolymorphicDispatch.cs"]); + + var caller = Assert.Single(impact.Results); + Assert.Equal("Run", caller.CallerName); + Assert.Equal(1, caller.Depth); + } + + [Fact] + public void GetTransitiveCallers_CSharpExact_FollowsAbstractBaseDispatchToConcreteOverride() + { + InsertIndexedFile("src/AbstractDispatch.cs", "csharp", + """ + namespace Demo; + + public abstract class BaseJob + { + public abstract void Execute(); + } + + public sealed class Job : BaseJob + { + public override void Execute() { } + } + + public sealed class Scheduler + { + public void Schedule(BaseJob job) + { + job.Execute(); + } + } + """); + InsertManualReferences("src/AbstractDispatch.cs", + [ + new ReferenceRecord + { + SymbolName = "Demo.BaseJob.Execute", + ReferenceKind = "call", + Line = 16, + Column = 13, + Context = "job.Execute();", + ContainerKind = "function", + ContainerName = "Schedule", + }, + ]); + + var impact = _reader.GetTransitiveCallers("Demo.Job.Execute", maxDepth: 2, lang: "csharp", pathPatterns: ["AbstractDispatch.cs"]); + + var caller = Assert.Single(impact.Results); + Assert.Equal("Schedule", caller.CallerName); + Assert.Equal(1, caller.Depth); + } + + [Fact] + public void GetTransitiveCallers_CSharpExact_DoesNotMixUnrelatedSameNameHierarchies() + { + InsertIndexedFile("src/UnrelatedDispatch.cs", "csharp", + """ + namespace Demo; + + public interface IWorker + { + void Execute(); + } + + public sealed class Worker : IWorker + { + public void Execute() { } + } + + public interface IOtherWorker + { + void Execute(); + } + + public sealed class OtherWorker : IOtherWorker + { + public void Execute() { } + } + + public sealed class Coordinator + { + public void RunOther(IOtherWorker worker) + { + worker.Execute(); + } + } + """); + InsertManualReferences("src/UnrelatedDispatch.cs", + [ + new ReferenceRecord + { + SymbolName = "Demo.IOtherWorker.Execute", + ReferenceKind = "call", + Line = 24, + Column = 16, + Context = "worker.Execute();", + ContainerKind = "function", + ContainerName = "RunOther", + }, + ]); + + var impact = _reader.GetTransitiveCallers("Demo.Worker.Execute", maxDepth: 2, lang: "csharp", pathPatterns: ["UnrelatedDispatch.cs"]); + + Assert.Empty(impact.Results); + } + + [Fact] + public void GetTransitiveCallers_CSharpExact_DoesNotUseBaseListFromDuplicateShortTypeName() + { + InsertIndexedFile("src/DuplicateShortTypeDispatch.cs", "csharp", + """ + namespace Other; + + public interface IWorker + { + void Execute(); + } + + public sealed class Worker : IWorker + { + public void Execute() { } + } + + public sealed class Coordinator + { + public void Run(IWorker worker) + { + worker.Execute(); + } + } + + namespace Demo; + + public sealed class Worker + { + public void Execute() { } + } + """); + InsertManualReferences("src/DuplicateShortTypeDispatch.cs", + [ + new ReferenceRecord + { + SymbolName = "Other.IWorker.Execute", + ReferenceKind = "call", + Line = 16, + Column = 16, + Context = "worker.Execute();", + ContainerKind = "function", + ContainerName = "Run", + }, + ]); + + var impact = _reader.GetTransitiveCallers("Demo.Worker.Execute", maxDepth: 2, lang: "csharp", pathPatterns: ["DuplicateShortTypeDispatch.cs"]); + + Assert.Empty(impact.Results); + } + [Fact] public void GetCallees_ReturnsReferencedSymbolsForCaller() { From d8005690cc3ddcd83ac25712c0622f938154c964 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 24 May 2026 19:15:47 +0900 Subject: [PATCH 2/2] Preserve C# reference suppression after #2060 --- .../Database/DbReader.CSharpResolution.cs | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Database/DbReader.CSharpResolution.cs b/src/CodeIndex/Database/DbReader.CSharpResolution.cs index 15aababe03..8156c9de2d 100644 --- a/src/CodeIndex/Database/DbReader.CSharpResolution.cs +++ b/src/CodeIndex/Database/DbReader.CSharpResolution.cs @@ -158,6 +158,17 @@ private HashSet GetInheritedCSharpContainingTypes(CSharpContainingTypeSc return inheritedContainingTypes; } + private HashSet GetPolymorphicCSharpContainingTypes(CSharpContainingTypeScope containingTypeScope) + { + var inheritedContainingTypes = new HashSet(StringComparer.Ordinal); + var visited = new HashSet(StringComparer.Ordinal) + { + containingTypeScope.QualifiedName, + }; + CollectPolymorphicCSharpContainingTypes(containingTypeScope, inheritedContainingTypes, visited); + return inheritedContainingTypes; + } + private List GetCSharpPolymorphicDispatchSymbolNames(string symbolName) { var memberName = SqlNameResolver.GetLeafName(symbolName); @@ -204,7 +215,7 @@ AND s.container_qualified_name IS NOT NULL if (containingTypeScope == null) continue; - foreach (var inheritedContainingType in GetInheritedCSharpContainingTypes(containingTypeScope)) + foreach (var inheritedContainingType in GetPolymorphicCSharpContainingTypes(containingTypeScope)) { var inheritedMemberName = CombineDbQualifiedName(inheritedContainingType, memberName); if (!string.IsNullOrWhiteSpace(inheritedMemberName)) @@ -275,6 +286,16 @@ private static void AddCSharpBaseListDispatchNamesFromSignature(string containin } private void CollectInheritedCSharpContainingTypes(CSharpContainingTypeScope containingTypeScope, HashSet inheritedContainingTypes, HashSet visited) + { + var directBaseScope = ResolveDirectCSharpBaseContainingTypeScope(containingTypeScope); + if (directBaseScope == null || !visited.Add(directBaseScope.QualifiedName)) + return; + + inheritedContainingTypes.Add(directBaseScope.QualifiedName); + CollectInheritedCSharpContainingTypes(directBaseScope, inheritedContainingTypes, visited); + } + + private void CollectPolymorphicCSharpContainingTypes(CSharpContainingTypeScope containingTypeScope, HashSet inheritedContainingTypes, HashSet visited) { foreach (var inheritedScope in ResolveDirectCSharpInheritedContainingTypeScopes(containingTypeScope)) { @@ -282,7 +303,7 @@ private void CollectInheritedCSharpContainingTypes(CSharpContainingTypeScope con continue; inheritedContainingTypes.Add(inheritedScope.QualifiedName); - CollectInheritedCSharpContainingTypes(inheritedScope, inheritedContainingTypes, visited); + CollectPolymorphicCSharpContainingTypes(inheritedScope, inheritedContainingTypes, visited); } }