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();
}