From 1fa138ab64590781262a5190e55c2fb5c360f981 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/4] 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 38e52da..49bf680 100644 --- a/src/Tests/E2ETests.cs +++ b/src/Tests/E2ETests.cs @@ -307,6 +307,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 155d80d6afae01b950d78872a0443664c7a06006 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/4] Analyzer changes Code up the new rule --- src/Analyzer/ReferenceTrimmerAnalyzer.cs | 23 +++++++++++++++++++--- src/Shared/DeclaredReferences.cs | 2 +- src/Tasks/CollectDeclaredReferencesTask.cs | 22 ++++++++++----------- src/Tests/E2ETests.cs | 2 +- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/src/Analyzer/ReferenceTrimmerAnalyzer.cs b/src/Analyzer/ReferenceTrimmerAnalyzer.cs index 0fa619f..db7d414 100644 --- a/src/Analyzer/ReferenceTrimmerAnalyzer.cs +++ b/src/Analyzer/ReferenceTrimmerAnalyzer.cs @@ -40,7 +40,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); @@ -140,6 +140,7 @@ private static void DumpUsedReferencesCore(CompilationAnalysisContext context) } Dictionary> packageAssembliesDict = new(StringComparer.OrdinalIgnoreCase); + Dictionary> topLevelPackageAssembliesDict = new(StringComparer.OrdinalIgnoreCase); foreach (DeclaredReference declaredReference in ReadDeclaredReferences(sourceText)) { switch (declaredReference.Kind) @@ -166,11 +167,23 @@ private static void DumpUsedReferencesCore(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; } } @@ -183,7 +196,11 @@ private static void DumpUsedReferencesCore(CompilationAnalysisContext context) List packageAssemblies = kvp.Value; if (!usedReferences.Overlaps(packageAssemblies)) { - 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 fa9aa05..b21fd57 100644 --- a/src/Shared/DeclaredReferences.cs +++ b/src/Shared/DeclaredReferences.cs @@ -1,5 +1,5 @@ namespace ReferenceTrimmer.Shared; -internal readonly record struct DeclaredReference(string AssemblyPath, DeclaredReferenceKind Kind, string Spec); +internal readonly record struct 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 00e3c6a..f0cfc8b 100644 --- a/src/Tasks/CollectDeclaredReferencesTask.cs +++ b/src/Tasks/CollectDeclaredReferencesTask.cs @@ -129,7 +129,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)); } } } @@ -162,7 +162,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)); } } else @@ -199,9 +199,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)); } } } @@ -347,7 +347,7 @@ private Dictionary GetPackageInfos() packageInfoBuilders.Add(packageId, packageInfoBuilder); } - packageInfoBuilder.AddCompileTimeAssemblies(nugetLibraryAssemblies); + packageInfoBuilder.AddCompileTimeAssemblies(nugetLibrary.Name, nugetLibraryAssemblies); packageInfoBuilder.AddBuildFiles(buildFiles); // Recurse though dependents @@ -515,10 +515,10 @@ private static void SaveDeclaredReferences(IReadOnlyList decl 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) { @@ -526,7 +526,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) @@ -542,11 +542,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 49bf680..79b95dd 100644 --- a/src/Tests/E2ETests.cs +++ b/src/Tests/E2ETests.cs @@ -314,7 +314,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") ]); } From c459c1b1376c68498a3a069a2c3e6de046f1f306 Mon Sep 17 00:00:00 2001 From: Stanislaw Szczepanowski <37585349+stan-sz@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:41:39 +0100 Subject: [PATCH 3/4] Add metapackage test --- src/Tests/E2ETests.cs | 8 ++++++++ .../Test/Test.cs | 10 ++++++++++ .../Test/Test.csproj | 12 ++++++++++++ 3 files changed, 30 insertions(+) create mode 100644 src/Tests/TestData/UnusedPackageReferenceWithMetaPackage/Test/Test.cs create mode 100644 src/Tests/TestData/UnusedPackageReferenceWithMetaPackage/Test/Test.csproj diff --git a/src/Tests/E2ETests.cs b/src/Tests/E2ETests.cs index 79b95dd..a8172ed 100644 --- a/src/Tests/E2ETests.cs +++ b/src/Tests/E2ETests.cs @@ -318,6 +318,14 @@ public Task UnusedPackageReferenceWithSdk() ]); } + [TestMethod] + public Task UnusedPackageReferenceWithMetaPackage() + { + return RunMSBuildAsync( + projectFile: "Test/Test.csproj", + expectedWarnings: []); + } + [TestMethod] public Task UnusedPackageReferenceNoWarn() { diff --git a/src/Tests/TestData/UnusedPackageReferenceWithMetaPackage/Test/Test.cs b/src/Tests/TestData/UnusedPackageReferenceWithMetaPackage/Test/Test.cs new file mode 100644 index 0000000..f22919d --- /dev/null +++ b/src/Tests/TestData/UnusedPackageReferenceWithMetaPackage/Test/Test.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Frozen; + +namespace Test +{ + public class Foo + { + public static FrozenSet SomeSet() => FrozenSet.ToFrozenSet(Array.Empty()); + } +} diff --git a/src/Tests/TestData/UnusedPackageReferenceWithMetaPackage/Test/Test.csproj b/src/Tests/TestData/UnusedPackageReferenceWithMetaPackage/Test/Test.csproj new file mode 100644 index 0000000..deb0030 --- /dev/null +++ b/src/Tests/TestData/UnusedPackageReferenceWithMetaPackage/Test/Test.csproj @@ -0,0 +1,12 @@ + + + + net8.0 + enable + + + + + + + From e7ef9c07a9383216fb7e050e9aa15bc044199137 Mon Sep 17 00:00:00 2001 From: Stanislaw Szczepanowski <37585349+stan-sz@users.noreply.github.com> Date: Fri, 13 Feb 2026 10:35:06 +0100 Subject: [PATCH 4/4] Adjustments --- src/Analyzer/ReferenceTrimmerAnalyzer.cs | 16 +++++++++++----- src/Tasks/CollectDeclaredReferencesTask.cs | 4 +++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Analyzer/ReferenceTrimmerAnalyzer.cs b/src/Analyzer/ReferenceTrimmerAnalyzer.cs index db7d414..b469fe7 100644 --- a/src/Analyzer/ReferenceTrimmerAnalyzer.cs +++ b/src/Analyzer/ReferenceTrimmerAnalyzer.cs @@ -249,7 +249,7 @@ private static void WriteFile(string filePath, string text) return null; } - // File format: tab-separated fields (AssemblyPath, Kind, Spec), one reference per line. + // File format: tab-separated fields (AssemblyPath, Kind, Spec, AdditionalSpec), one reference per line. // Keep in sync with SaveDeclaredReferences in CollectDeclaredReferencesTask.cs. private static IEnumerable ReadDeclaredReferences(SourceText sourceText) { @@ -267,6 +267,7 @@ private static IEnumerable ReadDeclaredReferences(SourceText int firstTab = -1; int secondTab = -1; + int thirdTab = -1; for (int i = start; i < end; i++) { if (sourceText[i] == '\t') @@ -275,21 +276,26 @@ private static IEnumerable ReadDeclaredReferences(SourceText { firstTab = i; } - else + else if (secondTab == -1) { secondTab = i; + } + else + { + thirdTab = i; break; } } } - if (firstTab == -1 || secondTab == -1) + if (firstTab == -1 || secondTab == -1 || thirdTab == -1) { yield break; } string assemblyPath = sourceText.ToString(TextSpan.FromBounds(start, firstTab)); - string spec = sourceText.ToString(TextSpan.FromBounds(secondTab + 1, end)); + string spec = sourceText.ToString(TextSpan.FromBounds(secondTab + 1, thirdTab)); + string additionalSpec = sourceText.ToString(TextSpan.FromBounds(thirdTab + 1, end)); // Determine kind without allocating a string. The three possible values are // "Reference" (len 9), "ProjectReference" (len 16), "PackageReference" (len 16). @@ -312,7 +318,7 @@ private static IEnumerable ReadDeclaredReferences(SourceText continue; } - yield return new DeclaredReference(assemblyPath, kind, spec); + yield return new DeclaredReference(assemblyPath, kind, spec, additionalSpec); } } } diff --git a/src/Tasks/CollectDeclaredReferencesTask.cs b/src/Tasks/CollectDeclaredReferencesTask.cs index f0cfc8b..02bdd36 100644 --- a/src/Tasks/CollectDeclaredReferencesTask.cs +++ b/src/Tasks/CollectDeclaredReferencesTask.cs @@ -476,7 +476,7 @@ private static bool IsSuppressed(ITaskItem item, string warningId) return false; } - // File format: tab-separated fields (AssemblyPath, Kind, Spec), one reference per line. + // File format: tab-separated fields (AssemblyPath, Kind, Spec, AdditionalSpec), one reference per line. // Keep in sync with ReadDeclaredReferences in ReferenceTrimmerAnalyzer.cs. private static void SaveDeclaredReferences(IReadOnlyList declaredReferences, string filePath) { @@ -497,6 +497,8 @@ private static void SaveDeclaredReferences(IReadOnlyList decl writer.Append(kindString); writer.Append(fieldDelimiter); writer.Append(reference.Spec); + writer.Append(fieldDelimiter); + writer.Append(reference.AdditionalSpec); writer.AppendLine(); }