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
39 changes: 39 additions & 0 deletions src/Analyzer/ReferenceTrimmerAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,16 @@ private static void InitializeSymbolBasedAnalysis(
// callbacks short-circuit. A briefly stale read just means a few extra no-op lookups.
int trackedCount = 0;

// Tracks named types whose inheritance chain (base types + transitively-implemented
// interfaces) has already been walked, to break self-referential cycles such as
// `int` → `IComparable<int>` → typeArg `int` → ... and to avoid redundant work for
// types used many times. Use SymbolEqualityComparer to match Roslyn semantics for
// constructed generic types (e.g., distinct INamedTypeSymbol instances representing
// the same `List<int>` are considered equal).
#pragma warning disable RS1024 // Compare symbols correctly (false positive: comparer is supplied explicitly)
var inheritanceWalked = new ConcurrentDictionary<ISymbol, byte>(SymbolEqualityComparer.Default);
#pragma warning restore RS1024

void TrackAssembly(IAssemblySymbol? assembly)
{
if (trackedCount >= totalReferenceCount)
Expand Down Expand Up @@ -247,6 +257,35 @@ void TrackType(ITypeSymbol? type)
{
TrackType(typeArg);
}

// When a named type is referenced, the C# compiler validates the entire
// inheritance chain at type-check time -- CS0012 fires when *any* base
// type or implemented interface is defined in an unreferenced assembly.
// Walk BaseType (recursively) and AllInterfaces (transitively) so every
// assembly along the chain is credited. AllInterfaces is broader than
// Interfaces and mirrors what the compiler actually validates. The
// visited-set guard breaks self-referential cycles (e.g. int ->
// IComparable<int> -> typeArg int -> ...) and avoids redundant work.
if (inheritanceWalked.TryAdd(named, 0))
{
for (INamedTypeSymbol? baseType = named.BaseType; baseType != null; baseType = baseType.BaseType)
{
TrackAssembly(baseType.ContainingAssembly);
foreach (ITypeSymbol typeArg in baseType.TypeArguments)
{
TrackType(typeArg);
}
}

foreach (INamedTypeSymbol iface in named.AllInterfaces)
{
TrackAssembly(iface.ContainingAssembly);
foreach (ITypeSymbol typeArg in iface.TypeArguments)
{
TrackType(typeArg);
}
}
}
}

return;
Expand Down
157 changes: 157 additions & 0 deletions src/Tests/AnalyzerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,163 @@ public async Task UnrelatedReferenceNotMarkedByOverride()
StringAssert.Contains(diagnostics[0].GetMessage(CultureInfo.InvariantCulture), "Unrelated");
}

[TestMethod]
public async Task UsedViaInheritedBaseType()
{
// The canonical issue #144 scenario: Consumer derives from a class in B, which itself
// derives from a class in A. With <DisableTransitiveProjectReferences>true</...>, A
// does not flow transitively from B → Consumer, so Consumer must reference A directly.
// Without walking the BaseType chain we'd flag A as removable, but removing it produces
// CS0012 because the C# compiler validates the entire base-type chain.
var aAsm = EmitDependency(
"namespace Dep { public class ProviderDependency { public int Counter; } }",
assemblyName: "AAsm");
var bAsm = EmitDependency(
"namespace Dep { public class Provider : ProviderDependency { } }",
assemblyName: "BAsm",
additionalReferences: [aAsm.Reference]);
var diagnostics = await RunAnalyzerAsync(
"class Consumer : Dep.Provider { }",
[(aAsm.Reference, aAsm.Path, "ProjectReference", "../A/A.csproj"),
(bAsm.Reference, bAsm.Path, "ProjectReference", "../B/B.csproj")]);
AssertNoDiagnostics(diagnostics);
}

[TestMethod]
public async Task UsedViaImplementedInterface()
{
// Interface chain: A defines IFoo, B defines a class Foo : IFoo, Consumer derives from Foo.
// Consumer's reference to A is required because the compiler validates that Foo's
// implemented interface is reachable. Without walking the interface chain we'd miss A.
var aAsm = EmitDependency(
"namespace Dep { public interface IFoo { } }",
assemblyName: "AAsm");
var bAsm = EmitDependency(
"namespace Dep { public class Foo : IFoo { } }",
assemblyName: "BAsm",
additionalReferences: [aAsm.Reference]);
var diagnostics = await RunAnalyzerAsync(
"class Consumer : Dep.Foo { }",
[(aAsm.Reference, aAsm.Path, "ProjectReference", "../A/A.csproj"),
(bAsm.Reference, bAsm.Path, "ProjectReference", "../B/B.csproj")]);
AssertNoDiagnostics(diagnostics);
}

[TestMethod]
public async Task UsedViaMultiLevelInheritanceChain()
{
// Three-level base-type chain: A ← B ← C. Consumer references C and uses it as a base
// class. Every assembly along the chain must be credited because the C# compiler
// validates the full inheritance chain (CS0012 fires on any missing link).
var aAsm = EmitDependency(
"namespace Dep { public class A { } }",
assemblyName: "AAsm");
var bAsm = EmitDependency(
"namespace Dep { public class B : A { } }",
assemblyName: "BAsm",
additionalReferences: [aAsm.Reference]);
var cAsm = EmitDependency(
"namespace Dep { public class C : B { } }",
assemblyName: "CAsm",
additionalReferences: [aAsm.Reference, bAsm.Reference]);
var diagnostics = await RunAnalyzerAsync(
"class Consumer : Dep.C { }",
[(aAsm.Reference, aAsm.Path, "ProjectReference", "../A/A.csproj"),
(bAsm.Reference, bAsm.Path, "ProjectReference", "../B/B.csproj"),
(cAsm.Reference, cAsm.Path, "ProjectReference", "../C/C.csproj")]);
AssertNoDiagnostics(diagnostics);
}

[TestMethod]
public async Task UsedViaInheritanceChainOnVariableType()
{
// The chain must be walked even when the named type is encountered as a variable type
// (not as an explicit base in Consumer's own code). Declaring a parameter of type
// Dep.Provider still requires every assembly in Provider's inheritance chain.
var aAsm = EmitDependency(
"namespace Dep { public class ProviderDependency { } }",
assemblyName: "AAsm");
var bAsm = EmitDependency(
"namespace Dep { public class Provider : ProviderDependency { } }",
assemblyName: "BAsm",
additionalReferences: [aAsm.Reference]);
var diagnostics = await RunAnalyzerAsync(
"class Consumer { void M(Dep.Provider p) { } }",
[(aAsm.Reference, aAsm.Path, "ProjectReference", "../A/A.csproj"),
(bAsm.Reference, bAsm.Path, "ProjectReference", "../B/B.csproj")]);
AssertNoDiagnostics(diagnostics);
}

[TestMethod]
public async Task UsedViaMixedBaseAndInterfaceChain()
{
// Mixed scenario: B's class inherits from A's class and implements D's interface.
// Consumer derives from B's class. All four assemblies (A, B, D, and B itself via the
// direct base) must be credited.
var aAsm = EmitDependency(
"namespace Dep { public class BaseA { } }",
assemblyName: "AAsm");
var dAsm = EmitDependency(
"namespace Dep { public interface IFromD { } }",
assemblyName: "DAsm");
var bAsm = EmitDependency(
"namespace Dep { public class Provider : BaseA, IFromD { } }",
assemblyName: "BAsm",
additionalReferences: [aAsm.Reference, dAsm.Reference]);
var diagnostics = await RunAnalyzerAsync(
"class Consumer : Dep.Provider { }",
[(aAsm.Reference, aAsm.Path, "ProjectReference", "../A/A.csproj"),
(bAsm.Reference, bAsm.Path, "ProjectReference", "../B/B.csproj"),
(dAsm.Reference, dAsm.Path, "ProjectReference", "../D/D.csproj")]);
AssertNoDiagnostics(diagnostics);
}

[TestMethod]
public async Task UsedViaGenericConstraintBaseChain()
{
// Generic constraint variant: T : Provider where Provider : ProviderDependency.
// Consumer's constraint forces the compiler to validate Provider's base chain, so A
// must remain a reference.
var aAsm = EmitDependency(
"namespace Dep { public class ProviderDependency { } }",
assemblyName: "AAsm");
var bAsm = EmitDependency(
"namespace Dep { public class Provider : ProviderDependency { } }",
assemblyName: "BAsm",
additionalReferences: [aAsm.Reference]);
var diagnostics = await RunAnalyzerAsync(
"class Consumer<T> where T : Dep.Provider { }",
[(aAsm.Reference, aAsm.Path, "ProjectReference", "../A/A.csproj"),
(bAsm.Reference, bAsm.Path, "ProjectReference", "../B/B.csproj")]);
AssertNoDiagnostics(diagnostics);
}

[TestMethod]
public async Task UnrelatedReferenceNotMarkedByInheritance()
{
// Negative test: deriving from a type whose inheritance chain spans two assemblies
// should not credit an entirely unrelated assembly. Only the assemblies along the
// chain are required.
var aAsm = EmitDependency(
"namespace Dep { public class ProviderDependency { } }",
assemblyName: "AAsm");
var bAsm = EmitDependency(
"namespace Dep { public class Provider : ProviderDependency { } }",
assemblyName: "BAsm",
additionalReferences: [aAsm.Reference]);
var unrelated = EmitDependency(
"namespace Other { public class Unused { } }",
assemblyName: "UnrelatedAsm");
var diagnostics = await RunAnalyzerAsync(
"class Consumer : Dep.Provider { }",
[(aAsm.Reference, aAsm.Path, "ProjectReference", "../A/A.csproj"),
(bAsm.Reference, bAsm.Path, "ProjectReference", "../B/B.csproj"),
(unrelated.Reference, unrelated.Path, "ProjectReference", "../Unrelated/Unrelated.csproj")]);
Assert.AreEqual(1, diagnostics.Length);
Assert.AreEqual("RT0002", diagnostics[0].Id);
StringAssert.Contains(diagnostics[0].GetMessage(CultureInfo.InvariantCulture), "Unrelated");
}

// ──────────────────────────────────────────────────────────────────────
// Test infrastructure
// ──────────────────────────────────────────────────────────────────────
Expand Down
Loading