From cfbc31e7c9d503ce735574930f3083901505a5cf Mon Sep 17 00:00:00 2001 From: Stanislaw Szczepanowski <37585349+stan-sz@users.noreply.github.com> Date: Wed, 7 Jan 2026 10:18:39 +0100 Subject: [PATCH 1/2] Warn when direct package reference is unused Repro for #119 --- src/Tests/E2ETests.cs | 11 +++++++++++ .../UnusedPackageReferenceWithSdk/Test/Test.cs | 9 +++++++++ .../UnusedPackageReferenceWithSdk/Test/Test.csproj | 12 ++++++++++++ 3 files changed, 32 insertions(+) create mode 100644 src/Tests/TestData/UnusedPackageReferenceWithSdk/Test/Test.cs create mode 100644 src/Tests/TestData/UnusedPackageReferenceWithSdk/Test/Test.csproj diff --git a/src/Tests/E2ETests.cs b/src/Tests/E2ETests.cs index 63a3124..9386c5b 100644 --- a/src/Tests/E2ETests.cs +++ b/src/Tests/E2ETests.cs @@ -306,6 +306,17 @@ public Task UnusedPackageReference() }); } + [TestMethod] + public Task UnusedPackageReferenceWithSdk() + { + return RunMSBuildAsync( + projectFile: "Test/Test.csproj", + expectedWarnings: + [ + new Warning("RT0003: PackageReference Moq can be removed", "Test/Test.csproj") + ]); + } + [TestMethod] public Task UnusedPackageReferenceNoWarn() { diff --git a/src/Tests/TestData/UnusedPackageReferenceWithSdk/Test/Test.cs b/src/Tests/TestData/UnusedPackageReferenceWithSdk/Test/Test.cs new file mode 100644 index 0000000..af2e3f7 --- /dev/null +++ b/src/Tests/TestData/UnusedPackageReferenceWithSdk/Test/Test.cs @@ -0,0 +1,9 @@ +using Castle.Core.Logging; + +namespace Test +{ + public class Foo + { + public static ILogger Logger() => NullLogger.Instance; + } +} diff --git a/src/Tests/TestData/UnusedPackageReferenceWithSdk/Test/Test.csproj b/src/Tests/TestData/UnusedPackageReferenceWithSdk/Test/Test.csproj new file mode 100644 index 0000000..0cd2624 --- /dev/null +++ b/src/Tests/TestData/UnusedPackageReferenceWithSdk/Test/Test.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + enable + + + + + + + From cf24e7b64e4d51c82b41188201ee2007e8eb26ef Mon Sep 17 00:00:00 2001 From: Stanislaw Szczepanowski <37585349+stan-sz@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:12:10 +0100 Subject: [PATCH 2/2] Analyzer changes Code up the new rule --- src/Analyzer/ReferenceTrimmerAnalyzer.cs | 23 +++++++++++++++++++--- src/Shared/DeclaredReferences.cs | 11 +++++++---- src/Tasks/CollectDeclaredReferencesTask.cs | 22 ++++++++++----------- src/Tests/E2ETests.cs | 2 +- 4 files changed, 39 insertions(+), 19 deletions(-) diff --git a/src/Analyzer/ReferenceTrimmerAnalyzer.cs b/src/Analyzer/ReferenceTrimmerAnalyzer.cs index 52ff60d..66f555c 100644 --- a/src/Analyzer/ReferenceTrimmerAnalyzer.cs +++ b/src/Analyzer/ReferenceTrimmerAnalyzer.cs @@ -39,7 +39,7 @@ public class ReferenceTrimmerAnalyzer : DiagnosticAnalyzer private static readonly DiagnosticDescriptor RT0003Descriptor = new( "RT0003", "Unnecessary package reference", - "PackageReference {0} can be removed", + "PackageReference {0} can be removed{1}", "ReferenceTrimmer", DiagnosticSeverity.Warning, isEnabledByDefault: true); @@ -109,6 +109,7 @@ private static void DumpUsedReferences(CompilationAnalysisContext context) } Dictionary> packageAssembliesDict = new(StringComparer.OrdinalIgnoreCase); + Dictionary> topLevelPackageAssembliesDict = new(StringComparer.OrdinalIgnoreCase); foreach (DeclaredReference declaredReference in declaredReferences.References) { switch (declaredReference.Kind) @@ -135,11 +136,23 @@ private static void DumpUsedReferences(CompilationAnalysisContext context) { if (!packageAssembliesDict.TryGetValue(declaredReference.Spec, out List packageAssemblies)) { - packageAssemblies = new List(); + packageAssemblies = []; packageAssembliesDict.Add(declaredReference.Spec, packageAssemblies); } packageAssemblies.Add(declaredReference.AssemblyPath); + + bool isTopLevelPackageAssembly = string.Equals(declaredReference.Spec, declaredReference.AdditionalSpec, StringComparison.OrdinalIgnoreCase); + if (isTopLevelPackageAssembly) + { + if (!topLevelPackageAssembliesDict.TryGetValue(declaredReference.Spec, out List topLevelPackageAssemblies)) + { + topLevelPackageAssemblies = []; + topLevelPackageAssembliesDict.Add(declaredReference.Spec, topLevelPackageAssemblies); + } + + topLevelPackageAssemblies.Add(declaredReference.AssemblyPath); + } break; } } @@ -152,7 +165,11 @@ private static void DumpUsedReferences(CompilationAnalysisContext context) List packageAssemblies = kvp.Value; if (!packageAssemblies.Any(usedReferences.Contains)) { - context.ReportDiagnostic(Diagnostic.Create(RT0003Descriptor, Location.None, packageName)); + context.ReportDiagnostic(Diagnostic.Create(RT0003Descriptor, Location.None, packageName, string.Empty)); + } + else if (!topLevelPackageAssembliesDict[packageName].Any(usedReferences.Contains)) + { + context.ReportDiagnostic(Diagnostic.Create(RT0003Descriptor, Location.None, packageName, " (though some of its transitive dependent packages may be used)")); } } } diff --git a/src/Shared/DeclaredReferences.cs b/src/Shared/DeclaredReferences.cs index 12128b8..261146e 100644 --- a/src/Shared/DeclaredReferences.cs +++ b/src/Shared/DeclaredReferences.cs @@ -32,6 +32,8 @@ public void SaveToFile(string filePath) writer.Append(KindEnumToString[reference.Kind]); writer.Append(FieldDelimiter); writer.Append(reference.Spec); + writer.Append(FieldDelimiter); + writer.Append(reference.AdditionalSpec); writer.AppendLine(); } @@ -58,8 +60,8 @@ public static DeclaredReferences ReadFromFile(string filePath) string? line; while ((line = reader.ReadLine()) != null) { - string[] parts = line.Split(FieldDelimiters, 3); - if (parts.Length != 3) + string[] parts = line.Split(FieldDelimiters, 4); + if (parts.Length != 4) { throw new InvalidDataException($"File '{filePath}' is invalid. Line: {references.Count + 1}"); } @@ -67,7 +69,8 @@ public static DeclaredReferences ReadFromFile(string filePath) string assemblyName = parts[0]; DeclaredReferenceKind kind = KindStringToEnum[parts[1]]; string spec = parts[2]; - DeclaredReference reference = new(assemblyName, kind, spec); + string additionalSpec = parts[3]; + DeclaredReference reference = new(assemblyName, kind, spec, additionalSpec); references.Add(reference); } @@ -75,6 +78,6 @@ public static DeclaredReferences ReadFromFile(string filePath) } } -internal record DeclaredReference(string AssemblyPath, DeclaredReferenceKind Kind, string Spec); +internal record DeclaredReference(string AssemblyPath, DeclaredReferenceKind Kind, string Spec, string AdditionalSpec); internal enum DeclaredReferenceKind { Reference, ProjectReference, PackageReference } \ No newline at end of file diff --git a/src/Tasks/CollectDeclaredReferencesTask.cs b/src/Tasks/CollectDeclaredReferencesTask.cs index 392d3dc..63011e6 100644 --- a/src/Tasks/CollectDeclaredReferencesTask.cs +++ b/src/Tasks/CollectDeclaredReferencesTask.cs @@ -121,7 +121,7 @@ public override bool Execute() if (referencePath is not null) { - declaredReferences.Add(new DeclaredReference(referencePath, DeclaredReferenceKind.Reference, referenceSpec)); + declaredReferences.Add(new DeclaredReference(referencePath, DeclaredReferenceKind.Reference, referenceSpec, string.Empty)); } } } @@ -149,7 +149,7 @@ public override bool Execute() string projectReferenceAssemblyPath = Path.GetFullPath(projectReference.ItemSpec); string referenceProjectFile = projectReference.GetMetadata("OriginalProjectReferenceItemSpec"); - declaredReferences.Add(new DeclaredReference(projectReferenceAssemblyPath, DeclaredReferenceKind.ProjectReference, referenceProjectFile)); + declaredReferences.Add(new DeclaredReference(projectReferenceAssemblyPath, DeclaredReferenceKind.ProjectReference, referenceProjectFile, string.Empty)); } } @@ -176,9 +176,9 @@ public override bool Execute() continue; } - foreach (string assemblyPath in packageInfo.CompileTimeAssemblies) + foreach (var assemblyPath in packageInfo.CompileTimeAssemblies) { - declaredReferences.Add(new DeclaredReference(assemblyPath, DeclaredReferenceKind.PackageReference, packageReference.ItemSpec)); + declaredReferences.Add(new DeclaredReference(assemblyPath.Item2, DeclaredReferenceKind.PackageReference, packageReference.ItemSpec, assemblyPath.Item1)); } } } @@ -297,7 +297,7 @@ private Dictionary GetPackageInfos() packageInfoBuilders.Add(packageId, packageInfoBuilder); } - packageInfoBuilder.AddCompileTimeAssemblies(nugetLibraryAssemblies); + packageInfoBuilder.AddCompileTimeAssemblies(nugetLibrary.Name, nugetLibraryAssemblies); packageInfoBuilder.AddBuildFiles(buildFiles); // Recurse though dependents @@ -421,10 +421,10 @@ private static bool IsSuppressed(ITaskItem item, string warningId) private sealed class PackageInfoBuilder { - private List? _compileTimeAssemblies; + private List>? _compileTimeAssemblies; private List? _buildFiles; - public void AddCompileTimeAssemblies(List compileTimeAssemblies) + public void AddCompileTimeAssemblies(string packageName, List compileTimeAssemblies) { if (compileTimeAssemblies.Count == 0) { @@ -432,7 +432,7 @@ public void AddCompileTimeAssemblies(List compileTimeAssemblies) } _compileTimeAssemblies ??= new(compileTimeAssemblies.Count); - _compileTimeAssemblies.AddRange(compileTimeAssemblies); + _compileTimeAssemblies.AddRange(compileTimeAssemblies.Select(assemblyPath => Tuple.Create(packageName, assemblyPath))); } public void AddBuildFiles(List buildFiles) @@ -448,11 +448,11 @@ public void AddBuildFiles(List buildFiles) public PackageInfo ToPackageInfo() => new( - (IReadOnlyCollection?)_compileTimeAssemblies ?? Array.Empty(), - (IReadOnlyCollection?)_buildFiles ?? Array.Empty()); + (IReadOnlyCollection>?)_compileTimeAssemblies ?? [], + (IReadOnlyCollection?)_buildFiles ?? []); } private readonly record struct PackageInfo( - IReadOnlyCollection CompileTimeAssemblies, + IReadOnlyCollection> CompileTimeAssemblies, IReadOnlyCollection BuildFiles); } diff --git a/src/Tests/E2ETests.cs b/src/Tests/E2ETests.cs index 9386c5b..e0d6048 100644 --- a/src/Tests/E2ETests.cs +++ b/src/Tests/E2ETests.cs @@ -313,7 +313,7 @@ public Task UnusedPackageReferenceWithSdk() projectFile: "Test/Test.csproj", expectedWarnings: [ - new Warning("RT0003: PackageReference Moq can be removed", "Test/Test.csproj") + new Warning("RT0003: PackageReference Moq can be removed (though some of its transitive dependent packages may be used)", "Test/Test.csproj") ]); }