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")
]);
}