From 5ecfe1c6c85bb69d37f6c8eab5bb1067920d1703 Mon Sep 17 00:00:00 2001 From: David Federman Date: Wed, 12 Aug 2026 11:38:36 -0700 Subject: [PATCH] Fix workspace project reference identity matching Preserve resolved project FusionName values in the declared-reference manifest and match RT0002 candidates by full Roslyn assembly identity while retaining path matching for existing reference kinds. Copilot-Session: fd367b4f-39d5-45fe-bd61-813ba71259e6 --- src/Analyzer/ReferenceTrimmerAnalyzer.cs | 368 +++++++++++++++--- src/Package/build/ReferenceTrimmer.targets | 1 + src/Shared/DeclaredReferences.cs | 6 +- src/Tasks/CollectDeclaredReferencesTask.cs | 30 +- src/Tests/AnalyzerTests.cs | 318 ++++++++++++++- .../CollectDeclaredReferencesTaskTests.cs | 65 ++++ 6 files changed, 728 insertions(+), 60 deletions(-) diff --git a/src/Analyzer/ReferenceTrimmerAnalyzer.cs b/src/Analyzer/ReferenceTrimmerAnalyzer.cs index 5f2edab..a9b4d8f 100644 --- a/src/Analyzer/ReferenceTrimmerAnalyzer.cs +++ b/src/Analyzer/ReferenceTrimmerAnalyzer.cs @@ -148,15 +148,30 @@ private static void RunDefaultAnalysisCore(CompilationAnalysisContext context, A } HashSet usedReferences = new(PathComparer); + HashSet usedAssemblyIdentities = new(); foreach (MetadataReference metadataReference in compilation.GetUsedAssemblyReferences()) { - if (metadataReference.Display != null) + string? referencePath = GetReferencePath(metadataReference); + if (referencePath is not null) { - usedReferences.Add(metadataReference.Display); + usedReferences.Add(referencePath); + } + + AssemblyIdentity? identity = GetReferenceAssemblyIdentity(compilation, metadataReference); + if (identity is not null) + { + usedAssemblyIdentities.Add(identity); } } - ReportUnusedReferences(context, declaredReferencesFile, sourceText, usedReferences, usedReferences); + ReportUnusedReferences( + context, + declaredReferencesFile, + sourceText, + usedReferences, + usedReferences, + usedAssemblyIdentities, + usedAssemblyIdentities); } // ────────────────────────────────────────────────────────────────────── @@ -168,28 +183,35 @@ private static void InitializeSymbolBasedAnalysis( Compilation compilation, AdditionalText declaredReferencesFile) { - // Build mappings from reference assembly identities to their metadata reference display paths. + // Build mappings from reference assembly identities to their source symbols and file paths. // These are used both for symbol tracking and for the transitive closure computation. - var assemblyToPath = new Dictionary(); - var pathToAssembly = new Dictionary(PathComparer); + var referencesByIdentity = new Dictionary(); foreach (MetadataReference reference in compilation.References) { - if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol asm && reference.Display != null) + IAssemblySymbol? asm = GetReferenceAssemblySymbol(compilation, reference); + if (asm is not null) { - if (!assemblyToPath.ContainsKey(asm.Identity)) + if (!referencesByIdentity.TryGetValue(asm.Identity, out ReferenceInfo? referenceInfo)) + { + referenceInfo = new ReferenceInfo(asm); + referencesByIdentity.Add(asm.Identity, referenceInfo); + } + else { - assemblyToPath.Add(asm.Identity, reference.Display); + referenceInfo.AddAssembly(asm); } - if (!pathToAssembly.ContainsKey(reference.Display)) + string? referencePath = GetReferencePath(reference); + if (referencePath is not null) { - pathToAssembly.Add(reference.Display, asm); + referenceInfo.AddPath(referencePath); } } } - int totalReferenceCount = assemblyToPath.Count; + int totalReferenceCount = referencesByIdentity.Count; var usedReferencePaths = new ConcurrentDictionary(PathComparer); + var usedAssemblyIdentities = new ConcurrentDictionary(); // Monotonically increasing counter. Once it reaches totalReferenceCount, all // callbacks short-circuit. A briefly stale read just means a few extra no-op lookups. int trackedCount = 0; @@ -224,10 +246,23 @@ void TrackAssembly(IAssemblySymbol? assembly) return; } - if (assemblyToPath.TryGetValue(assembly.Identity, out string? path) - && usedReferencePaths.TryAdd(path, 0)) + if (!referencesByIdentity.TryGetValue(assembly.Identity, out ReferenceInfo? referenceInfo) + || !usedAssemblyIdentities.TryAdd(assembly.Identity, 0)) { - Interlocked.Increment(ref trackedCount); + return; + } + + Interlocked.Increment(ref trackedCount); + if (referenceInfo.Path is not null) + { + usedReferencePaths.TryAdd(referenceInfo.Path, 0); + if (referenceInfo.AdditionalPaths is not null) + { + foreach (string path in referenceInfo.AdditionalPaths) + { + usedReferencePaths.TryAdd(path, 0); + } + } } } @@ -870,26 +905,22 @@ void TrackOverriddenChain(ISymbol? member) // Mark type-forwarding assemblies as used when the destination assembly is used. // E.g. a package may forward types to the runtime; the code uses the type (tracking the // runtime assembly) but the forwarder assembly must also be kept as a reference. - foreach (KeyValuePair kvp in pathToAssembly) + foreach (KeyValuePair kvp in referencesByIdentity) { - if (usedReferencePaths.ContainsKey(kvp.Key)) + if (usedAssemblyIdentities.ContainsKey(kvp.Key)) { continue; } - foreach (INamedTypeSymbol forwardedType in kvp.Value.GetForwardedTypes()) + IAssemblySymbol? forwardingAssembly = GetForwardingAssembly(kvp.Value, usedAssemblyIdentities); + if (forwardingAssembly is not null) { - if (forwardedType.ContainingAssembly != null - && assemblyToPath.TryGetValue(forwardedType.ContainingAssembly.Identity, out string? destPath) - && usedReferencePaths.ContainsKey(destPath)) - { - usedReferencePaths.TryAdd(kvp.Key, 0); - break; - } + TrackAssembly(forwardingAssembly); } } HashSet usedReferences = new(usedReferencePaths.Keys, PathComparer); + HashSet usedIdentities = new(usedAssemblyIdentities.Keys); // For bare Reference items (RT0001), we always need a conservative "transitively used" set // because bare References control copy-to-output behavior directly and have no transitive @@ -898,9 +929,17 @@ void TrackOverriddenChain(ISymbol? member) // For ProjectReference items (RT0002), we also need the conservative set when // DisableTransitiveProjectReferences is enabled, since MSBuild won't propagate transitive // project dependencies in that case. - HashSet transitivelyUsedReferences = ComputeTransitivelyUsedReferences(assemblyToPath, pathToAssembly, usedReferences); - - ReportUnusedReferences(endContext, declaredReferencesFile, sourceText, usedReferences, transitivelyUsedReferences); + HashSet transitivelyUsedIdentities = ComputeTransitivelyUsedAssemblyIdentities(referencesByIdentity, usedIdentities); + HashSet transitivelyUsedReferences = GetReferencePaths(referencesByIdentity, transitivelyUsedIdentities, usedReferences); + + ReportUnusedReferences( + endContext, + declaredReferencesFile, + sourceText, + usedReferences, + transitivelyUsedReferences, + usedIdentities, + transitivelyUsedIdentities); } catch (OperationCanceledException) { @@ -922,7 +961,9 @@ private static void ReportUnusedReferences( AdditionalText declaredReferencesFile, SourceText sourceText, HashSet usedReferences, - HashSet transitivelyUsedReferences) + HashSet transitivelyUsedReferences, + HashSet usedAssemblyIdentities, + HashSet transitivelyUsedAssemblyIdentities) { Compilation compilation = context.Compilation; @@ -931,21 +972,61 @@ private static void ReportUnusedReferences( .TryGetValue("build_property.DisableTransitiveProjectReferences", out string? disableTransitive) && string.Equals(disableTransitive, "true", StringComparison.OrdinalIgnoreCase); HashSet projectReferenceUsedSet = disableTransitiveProjectReferences ? transitivelyUsedReferences : usedReferences; + HashSet projectReferenceUsedIdentitySet = disableTransitiveProjectReferences ? transitivelyUsedAssemblyIdentities : usedAssemblyIdentities; + bool hasCompilationReferences = false; + foreach (MetadataReference reference in compilation.References) + { + if (reference is CompilationReference) + { + hasCompilationReferences = true; + break; + } + } + + HashSet? portableExecutableReferencePaths = null; + if (hasCompilationReferences) + { + portableExecutableReferencePaths = new HashSet(PathComparer); + foreach (MetadataReference reference in compilation.References) + { + string? referencePath = GetReferencePath(reference); + if (referencePath is not null) + { + portableExecutableReferencePaths.Add(referencePath); + } + } + } if (context.Options.AnalyzerConfigOptionsProvider.GlobalOptions .TryGetValue("build_property.EnableReferenceTrimmerDiagnostics", out string? enableDiagnostics) && string.Equals(enableDiagnostics, "true", StringComparison.OrdinalIgnoreCase)) { + HashSet usedReferenceDiagnostics = new(PathComparer); HashSet unusedReferences = new(PathComparer); foreach (MetadataReference metadataReference in compilation.References) { - if (metadataReference.Display != null && !usedReferences.Contains(metadataReference.Display)) + string? referencePath = GetReferencePath(metadataReference); + AssemblyIdentity? referenceIdentity = GetReferenceAssemblyIdentity(compilation, metadataReference); + string? diagnosticKey = referencePath ?? referenceIdentity?.GetDisplayName(); + if (diagnosticKey is null) + { + continue; + } + + bool isUsed = + (referencePath is not null && usedReferences.Contains(referencePath)) + || (referenceIdentity is not null && usedAssemblyIdentities.Contains(referenceIdentity)); + if (isUsed) + { + usedReferenceDiagnostics.Add(diagnosticKey); + } + else { - unusedReferences.Add(metadataReference.Display); + unusedReferences.Add(diagnosticKey); } } - DumpReferencesInfo(usedReferences, unusedReferences, declaredReferencesFile.Path); + DumpReferencesInfo(usedReferenceDiagnostics, unusedReferences, declaredReferencesFile.Path); } Dictionary> packageAssembliesDict = new(PathComparer); @@ -965,7 +1046,24 @@ private static void ReportUnusedReferences( } case DeclaredReferenceKind.ProjectReference: { - if (!projectReferenceUsedSet.Contains(declaredReference.AssemblyPath)) + bool isUsed = projectReferenceUsedSet.Contains(declaredReference.AssemblyPath); + bool hasAssemblyIdentity = false; + if (!isUsed && declaredReference.ProjectAssemblyIdentity.Length != 0) + { + hasAssemblyIdentity = AssemblyIdentity.TryParseDisplayName( + declaredReference.ProjectAssemblyIdentity, + out AssemblyIdentity? projectAssemblyIdentity); + isUsed = hasAssemblyIdentity + && projectReferenceUsedIdentitySet.Contains(projectAssemblyIdentity!); + } + + bool hasPortableExecutableReferencePath = + portableExecutableReferencePaths?.Contains(declaredReference.AssemblyPath) == true; + + if (!isUsed + && (hasAssemblyIdentity + || hasPortableExecutableReferencePath + || !hasCompilationReferences)) { context.ReportDiagnostic(Diagnostic.Create(RT0002Descriptor, Location.None, declaredReference.Spec)); } @@ -1100,27 +1198,48 @@ private static void RegisterCSharpSyntaxTracking( return null; } - private static HashSet ComputeTransitivelyUsedReferences( - Dictionary identityToPath, - Dictionary pathToAssembly, - HashSet usedReferences) + private static HashSet GetReferencePaths( + Dictionary referencesByIdentity, + HashSet assemblyIdentities, + HashSet directlyUsedReferences) { - HashSet transitivelyUsed = new(usedReferences, PathComparer); - Queue queue = new(usedReferences); + HashSet paths = new(directlyUsedReferences, PathComparer); + foreach (AssemblyIdentity identity in assemblyIdentities) + { + if (referencesByIdentity.TryGetValue(identity, out ReferenceInfo? referenceInfo) + && referenceInfo.Path is not null) + { + paths.Add(referenceInfo.Path); + if (referenceInfo.AdditionalPaths is not null) + { + foreach (string path in referenceInfo.AdditionalPaths) + { + paths.Add(path); + } + } + } + } + + return paths; + } + + private static HashSet ComputeTransitivelyUsedAssemblyIdentities( + Dictionary referencesByIdentity, + HashSet usedAssemblyIdentities) + { + HashSet transitivelyUsed = new(usedAssemblyIdentities); + Queue queue = new(usedAssemblyIdentities); while (queue.Count > 0) { - string path = queue.Dequeue(); - if (pathToAssembly.TryGetValue(path, out IAssemblySymbol? asm)) + AssemblyIdentity identity = queue.Dequeue(); + if (referencesByIdentity.TryGetValue(identity, out ReferenceInfo? referenceInfo)) { - foreach (IModuleSymbol module in asm.Modules) + AddReferencedAssemblies(referenceInfo.Assembly, referencesByIdentity, transitivelyUsed, queue); + if (referenceInfo.AdditionalAssemblies is not null) { - foreach (AssemblyIdentity dep in module.ReferencedAssemblies) + foreach (IAssemblySymbol assembly in referenceInfo.AdditionalAssemblies) { - if (identityToPath.TryGetValue(dep, out string? depPath) - && transitivelyUsed.Add(depPath)) - { - queue.Enqueue(depPath); - } + AddReferencedAssemblies(assembly, referencesByIdentity, transitivelyUsed, queue); } } } @@ -1129,6 +1248,142 @@ private static HashSet ComputeTransitivelyUsedReferences( return transitivelyUsed; } + private static void AddReferencedAssemblies( + IAssemblySymbol assembly, + Dictionary referencesByIdentity, + HashSet transitivelyUsed, + Queue queue) + { + foreach (IModuleSymbol module in assembly.Modules) + { + foreach (AssemblyIdentity dependency in module.ReferencedAssemblies) + { + if (referencesByIdentity.ContainsKey(dependency) + && transitivelyUsed.Add(dependency)) + { + queue.Enqueue(dependency); + } + } + } + } + + private static IAssemblySymbol? GetForwardingAssembly( + ReferenceInfo referenceInfo, + ConcurrentDictionary usedAssemblyIdentities) + { + if (ForwardsToUsedAssembly(referenceInfo.Assembly, usedAssemblyIdentities)) + { + return referenceInfo.Assembly; + } + + if (referenceInfo.AdditionalAssemblies is not null) + { + foreach (IAssemblySymbol assembly in referenceInfo.AdditionalAssemblies) + { + if (ForwardsToUsedAssembly(assembly, usedAssemblyIdentities)) + { + return assembly; + } + } + } + + return null; + } + + private static bool ForwardsToUsedAssembly( + IAssemblySymbol assembly, + ConcurrentDictionary usedAssemblyIdentities) + { + foreach (INamedTypeSymbol forwardedType in assembly.GetForwardedTypes()) + { + if (forwardedType.ContainingAssembly is not null + && usedAssemblyIdentities.ContainsKey(forwardedType.ContainingAssembly.Identity)) + { + return true; + } + } + + return false; + } + + private static IAssemblySymbol? GetReferenceAssemblySymbol(Compilation compilation, MetadataReference reference) + => reference is CompilationReference compilationReference + ? compilationReference.Compilation.Assembly + : compilation.GetAssemblyOrModuleSymbol(reference) as IAssemblySymbol; + + private static AssemblyIdentity? GetReferenceAssemblyIdentity(Compilation compilation, MetadataReference reference) + => GetReferenceAssemblySymbol(compilation, reference)?.Identity; + + private static string? GetReferencePath(MetadataReference reference) + => (reference as PortableExecutableReference)?.FilePath; + + private sealed class ReferenceInfo(IAssemblySymbol assembly) + { + public IAssemblySymbol Assembly { get; } = assembly; + + public List? AdditionalAssemblies { get; private set; } + + public string? Path { get; private set; } + + public List? AdditionalPaths { get; private set; } + + public void AddAssembly(IAssemblySymbol assembly) + { + if (ReferenceEquals(Assembly, assembly)) + { + return; + } + + if (AdditionalAssemblies is not null) + { + foreach (IAssemblySymbol existing in AdditionalAssemblies) + { + if (ReferenceEquals(existing, assembly)) + { + return; + } + } + } + else + { + AdditionalAssemblies = new List(); + } + + AdditionalAssemblies.Add(assembly); + } + + public void AddPath(string path) + { + if (Path is null) + { + Path = path; + return; + } + + if (PathComparer.Equals(Path, path)) + { + return; + } + + if (AdditionalPaths is not null) + { + foreach (string existing in AdditionalPaths) + { + if (PathComparer.Equals(existing, path)) + { + return; + } + } + } + else + { + AdditionalPaths = new List(); + } + + AdditionalPaths.Add(path); + } + } + private static void DumpReferencesInfo(HashSet usedReferences, HashSet unusedReferences, string declaredReferencesPath) { string dir = Path.GetDirectoryName(declaredReferencesPath); @@ -1160,7 +1415,7 @@ private static void WriteFile(string filePath, string text) } } - // File format: tab-separated fields (AssemblyPath, Kind, Spec), one reference per line. + // File format: tab-separated fields (AssemblyPath, Kind, Spec, optional ProjectAssemblyIdentity), one reference per line. // Keep in sync with SaveDeclaredReferences in CollectDeclaredReferencesTask.cs. private static IEnumerable ReadDeclaredReferences(SourceText sourceText) { @@ -1178,6 +1433,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') @@ -1186,9 +1442,13 @@ private static IEnumerable ReadDeclaredReferences(SourceText { firstTab = i; } - else + else if (secondTab == -1) { secondTab = i; + } + else + { + thirdTab = i; break; } } @@ -1200,7 +1460,11 @@ private static IEnumerable ReadDeclaredReferences(SourceText } string assemblyPath = sourceText.ToString(TextSpan.FromBounds(start, firstTab)); - string spec = sourceText.ToString(TextSpan.FromBounds(secondTab + 1, end)); + int specEnd = thirdTab == -1 ? end : thirdTab; + string spec = sourceText.ToString(TextSpan.FromBounds(secondTab + 1, specEnd)); + string projectAssemblyIdentity = thirdTab == -1 + ? string.Empty + : 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). @@ -1223,7 +1487,7 @@ private static IEnumerable ReadDeclaredReferences(SourceText continue; } - yield return new DeclaredReference(assemblyPath, kind, spec); + yield return new DeclaredReference(assemblyPath, kind, spec, projectAssemblyIdentity); } } } diff --git a/src/Package/build/ReferenceTrimmer.targets b/src/Package/build/ReferenceTrimmer.targets index 0cf944b..0e03e65 100644 --- a/src/Package/build/ReferenceTrimmer.targets +++ b/src/Package/build/ReferenceTrimmer.targets @@ -34,6 +34,7 @@ <_CollectDeclaredReferencesHashInputs Include="@(_ReferenceTrimmerReferences -> 'REF=%(Identity)')" /> + <_CollectDeclaredReferencesHashInputs Include="@(_ReferenceTrimmerProjectReferences -> 'PRJ=%(Identity)|%(FusionName)')" /> <_CollectDeclaredReferencesHashInputs Include="@(PackageReference -> 'PKG=%(Identity)')" /> <_CollectDeclaredReferencesHashInputs Include="@(ReferenceTrimmerIgnorePackageBuildFiles -> 'IGN=%(Identity)')" /> <_CollectDeclaredReferencesHashInputs Include="TFM=$(ReferringTargetFrameworkForProjectReferences)" /> diff --git a/src/Shared/DeclaredReferences.cs b/src/Shared/DeclaredReferences.cs index fa9aa05..c526749 100644 --- a/src/Shared/DeclaredReferences.cs +++ b/src/Shared/DeclaredReferences.cs @@ -1,5 +1,9 @@ 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 ProjectAssemblyIdentity); 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 bcf61c9..5b07515 100644 --- a/src/Tasks/CollectDeclaredReferencesTask.cs +++ b/src/Tasks/CollectDeclaredReferencesTask.cs @@ -125,7 +125,11 @@ 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)); } } } @@ -157,8 +161,13 @@ public override bool Execute() string projectReferenceAssemblyPath = Path.GetFullPath(projectReference.ItemSpec); string referenceProjectFile = projectReference.GetMetadata("OriginalProjectReferenceItemSpec"); + string projectAssemblyIdentity = projectReference.GetMetadata("FusionName"); - declaredReferences.Add(new DeclaredReference(projectReferenceAssemblyPath, DeclaredReferenceKind.ProjectReference, referenceProjectFile)); + declaredReferences.Add(new DeclaredReference( + projectReferenceAssemblyPath, + DeclaredReferenceKind.ProjectReference, + referenceProjectFile, + projectAssemblyIdentity)); } } else @@ -197,7 +206,11 @@ public override bool Execute() foreach (string assemblyPath in packageInfo.CompileTimeAssemblies) { - declaredReferences.Add(new DeclaredReference(assemblyPath, DeclaredReferenceKind.PackageReference, packageReference.ItemSpec)); + declaredReferences.Add(new DeclaredReference( + assemblyPath, + DeclaredReferenceKind.PackageReference, + packageReference.ItemSpec, + string.Empty)); } } } @@ -483,7 +496,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, optional ProjectAssemblyIdentity), one reference per line. // Keep in sync with ReadDeclaredReferences in ReferenceTrimmerAnalyzer.cs. private static void SaveDeclaredReferences(IReadOnlyList declaredReferences, string filePath) { @@ -503,7 +516,14 @@ private static void SaveDeclaredReferences(IReadOnlyList decl }; writer.Write(kindString); writer.Write(fieldDelimiter); - writer.WriteLine(reference.Spec); + writer.Write(reference.Spec); + if (reference.ProjectAssemblyIdentity.Length != 0) + { + writer.Write(fieldDelimiter); + writer.Write(reference.ProjectAssemblyIdentity); + } + + writer.WriteLine(); } } diff --git a/src/Tests/AnalyzerTests.cs b/src/Tests/AnalyzerTests.cs index 067853f..ede799e 100644 --- a/src/Tests/AnalyzerTests.cs +++ b/src/Tests/AnalyzerTests.cs @@ -1277,6 +1277,267 @@ public void Bar(Dep.T t) { } StringAssert.Contains(diagnostics[0].GetMessage(CultureInfo.InvariantCulture), "Dep"); } + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task UsedCompilationReferenceMatchesFullAssemblyIdentity(bool useSymbolAnalysis) + { + var dependency = CreateCompilationReference( + "namespace Dep { public class Foo { } }", + assemblyName: "LogicalAssembly", + declaredFileName: "PhysicalFileName.dll"); + + var diagnostics = await RunAnalyzerAsync( + "class C : Dep.Foo { }", + [(dependency.Reference, dependency.Path, "ProjectReference", "../Dependency/Dependency.csproj", dependency.Identity)], + useSymbolAnalysis: useSymbolAnalysis); + + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task UnusedCompilationReferenceStillReportsDiagnostic(bool useSymbolAnalysis) + { + var dependency = CreateCompilationReference( + "namespace Dep { public class Foo { } }", + assemblyName: "LogicalAssembly", + declaredFileName: "PhysicalFileName.dll"); + + var diagnostics = await RunAnalyzerAsync( + "class C { }", + [(dependency.Reference, dependency.Path, "ProjectReference", "../Dependency/Dependency.csproj", dependency.Identity)], + useSymbolAnalysis: useSymbolAnalysis); + + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0002", diagnostics[0].Id); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task CompilationReferenceRequiresFullAssemblyIdentityMatch(bool useSymbolAnalysis) + { + var dependency = CreateCompilationReference( + "namespace Dep { public class Foo { } }", + assemblyName: "LogicalAssembly", + declaredFileName: "PhysicalFileName.dll"); + string differentVersionIdentity = dependency.Identity.Replace( + "Version=0.0.0.0", + "Version=9.0.0.0", + StringComparison.Ordinal); + Assert.AreNotEqual(dependency.Identity, differentVersionIdentity); + + var diagnostics = await RunAnalyzerAsync( + "class C : Dep.Foo { }", + [(dependency.Reference, dependency.Path, "ProjectReference", "../Dependency/Dependency.csproj", differentVersionIdentity)], + useSymbolAnalysis: useSymbolAnalysis); + + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0002", diagnostics[0].Id); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task AliasedCompilationReferenceMatchesAssemblyIdentity(bool useSymbolAnalysis) + { + var dependency = CreateCompilationReference( + "namespace Dep { public class Foo { } }", + assemblyName: "LogicalAssembly", + declaredFileName: "PhysicalFileName.dll", + aliases: ["LibAlias"]); + + var diagnostics = await RunAnalyzerAsync( + "extern alias LibAlias; class C : LibAlias::Dep.Foo { }", + [(dependency.Reference, dependency.Path, "ProjectReference", "../Dependency/Dependency.csproj", dependency.Identity)], + useSymbolAnalysis: useSymbolAnalysis); + + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task DuplicateCompilationReferenceIdentitiesAreConservativelyRetained(bool useSymbolAnalysis) + { + var first = CreateCompilationReference( + "namespace First { public class Foo { } }", + assemblyName: "DuplicateIdentity", + declaredFileName: "FirstPhysicalName.dll", + aliases: ["FirstAlias"]); + var second = CreateCompilationReference( + "namespace Second { public class Bar { } }", + assemblyName: "DuplicateIdentity", + declaredFileName: "SecondPhysicalName.dll", + aliases: ["SecondAlias"]); + + var diagnostics = await RunAnalyzerAsync( + "extern alias FirstAlias; class C : FirstAlias::First.Foo { }", + [(first.Reference, first.Path, "ProjectReference", "../First/First.csproj", first.Identity), + (second.Reference, second.Path, "ProjectReference", "../Second/Second.csproj", second.Identity)], + useSymbolAnalysis: useSymbolAnalysis); + + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task DuplicateCompilationReferenceIdentityUnionsTransitiveDependencies(bool useSymbolAnalysis) + { + var dependency = CreateCompilationReference( + "namespace Dep { public class A { } }", + assemblyName: "DependencyIdentity", + declaredFileName: "DependencyPhysicalName.dll"); + var first = CreateCompilationReference( + "namespace First { public class Foo { } }", + assemblyName: "DuplicateIdentity", + declaredFileName: "FirstPhysicalName.dll", + aliases: ["FirstAlias"]); + var second = CreateCompilationReference( + "namespace Second { public class Bar { private Dep.A _field; } }", + assemblyName: "DuplicateIdentity", + declaredFileName: "SecondPhysicalName.dll", + aliases: ["SecondAlias"], + additionalReferences: [dependency.Reference]); + var unrelated = CreateCompilationReference( + "namespace Other { public class C { } }", + assemblyName: "UnrelatedIdentity", + declaredFileName: "UnrelatedPhysicalName.dll"); + + var diagnostics = await RunAnalyzerAsync( + "extern alias SecondAlias; class C { SecondAlias::Second.Bar _field; }", + [(dependency.Reference, dependency.Path, "ProjectReference", "../Dependency/Dependency.csproj", dependency.Identity), + (first.Reference, first.Path, "ProjectReference", "../First/First.csproj", first.Identity), + (second.Reference, second.Path, "ProjectReference", "../Second/Second.csproj", second.Identity), + (unrelated.Reference, unrelated.Path, "ProjectReference", "../Unrelated/Unrelated.csproj", unrelated.Identity)], + useSymbolAnalysis: useSymbolAnalysis, + disableTransitiveProjectReferences: true); + + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0002", diagnostics[0].Id); + StringAssert.Contains(diagnostics[0].GetMessage(CultureInfo.InvariantCulture), "../Unrelated/Unrelated.csproj"); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task LegacyProjectReferenceRowIsConservativelyRetainedForCompilationReference(bool useSymbolAnalysis) + { + var dependency = CreateCompilationReference( + "namespace Dep { public class Foo { } }", + assemblyName: "LogicalAssembly", + declaredFileName: "PhysicalFileName.dll"); + + var diagnostics = await RunAnalyzerAsync( + "class C : Dep.Foo { }", + [(dependency.Reference, dependency.Path, "ProjectReference", "../Dependency/Dependency.csproj", null)], + useSymbolAnalysis: useSymbolAnalysis); + + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task UnparseableProjectAssemblyIdentityIsConservativelyRetainedForCompilationReference(bool useSymbolAnalysis) + { + var dependency = CreateCompilationReference( + "namespace Dep { public class Foo { } }", + assemblyName: "LogicalAssembly", + declaredFileName: "PhysicalFileName.dll"); + + var diagnostics = await RunAnalyzerAsync( + "class C { }", + [(dependency.Reference, dependency.Path, "ProjectReference", "../Dependency/Dependency.csproj", "LogicalAssembly, Version=invalid")], + useSymbolAnalysis: useSymbolAnalysis); + + AssertNoDiagnostics(diagnostics); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task UnparseableProjectAssemblyIdentityDoesNotSuppressPeBackedDiagnostic(bool useSymbolAnalysis) + { + var dependency = EmitDependency( + "namespace Dep { public class Foo { } }", + assemblyName: "LogicalAssembly"); + + var diagnostics = await RunAnalyzerAsync( + "class C { }", + [(dependency.Reference, dependency.Path, "ProjectReference", "../Dependency/Dependency.csproj", "LogicalAssembly, Version=invalid")], + useSymbolAnalysis: useSymbolAnalysis); + + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0002", diagnostics[0].Id); + } + + [TestMethod] + [DataRow(false, false)] + [DataRow(false, true)] + [DataRow(true, false)] + [DataRow(true, true)] + public async Task CompilationReferenceDoesNotSuppressPeBackedLegacyDiagnostic( + bool useSymbolAnalysis, + bool useUnparseableIdentity) + { + var used = CreateCompilationReference( + "namespace Used { public class Foo { } }", + assemblyName: "UsedAssembly", + declaredFileName: "UsedPhysicalName.dll"); + var unused = EmitDependency( + "namespace Unused { public class Bar { } }", + assemblyName: "UnusedAssembly"); + string? unusedIdentity = useUnparseableIdentity + ? "UnusedAssembly, Version=invalid" + : null; + + var diagnostics = await RunAnalyzerAsync( + "class C : Used.Foo { }", + [(used.Reference, used.Path, "ProjectReference", "../Used/Used.csproj", used.Identity), + (unused.Reference, unused.Path, "ProjectReference", "../Unused/Unused.csproj", unusedIdentity)], + useSymbolAnalysis: useSymbolAnalysis); + + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0002", diagnostics[0].Id); + StringAssert.Contains(diagnostics[0].GetMessage(CultureInfo.InvariantCulture), "../Unused/Unused.csproj"); + } + + [TestMethod] + [DataRow(false)] + [DataRow(true)] + public async Task CompilationReferenceTransitiveChainRetainsDependenciesButNotUnrelatedReferences(bool useSymbolAnalysis) + { + var a = CreateCompilationReference( + "namespace Dep { public class A { } }", + assemblyName: "AAssembly", + declaredFileName: "APhysical.dll"); + var b = CreateCompilationReference( + "namespace Dep { public class B : A { } }", + assemblyName: "BAssembly", + declaredFileName: "BPhysical.dll", + additionalReferences: [a.Reference]); + var unrelated = CreateCompilationReference( + "namespace Other { public class C { } }", + assemblyName: "CAssembly", + declaredFileName: "CPhysical.dll"); + + var diagnostics = await RunAnalyzerAsync( + "class Consumer : Dep.B { }", + [(a.Reference, a.Path, "ProjectReference", "../A/A.csproj", a.Identity), + (b.Reference, b.Path, "ProjectReference", "../B/B.csproj", b.Identity), + (unrelated.Reference, unrelated.Path, "ProjectReference", "../C/C.csproj", unrelated.Identity)], + useSymbolAnalysis: useSymbolAnalysis, + disableTransitiveProjectReferences: true); + + Assert.AreEqual(1, diagnostics.Length); + Assert.AreEqual("RT0002", diagnostics[0].Id); + StringAssert.Contains(diagnostics[0].GetMessage(CultureInfo.InvariantCulture), "../C/C.csproj"); + } + // ────────────────────────────────────────────────────────────────────── // Test infrastructure // ────────────────────────────────────────────────────────────────────── @@ -1323,6 +1584,35 @@ private static (MetadataReference Reference, string Path) EmitDependency( return (MetadataReference.CreateFromFile(path), path); } + private static (MetadataReference Reference, string Path, string Identity) CreateCompilationReference( + string source, + string assemblyName, + string declaredFileName, + string[]? aliases = null, + MetadataReference[]? additionalReferences = null) + { + var references = new List { CorlibRef }; + if (additionalReferences is not null) + { + references.AddRange(additionalReferences); + } + + var compilation = CSharpCompilation.Create( + assemblyName, + [CSharpSyntaxTree.ParseText(source)], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + ImmutableArray diagnostics = compilation.GetDiagnostics(); + Assert.IsFalse( + diagnostics.Any(diagnostic => diagnostic.Severity == DiagnosticSeverity.Error), + $"Dependency compilation failed:\n{string.Join("\n", diagnostics)}"); + + return ( + compilation.ToMetadataReference(aliases?.ToImmutableArray() ?? ImmutableArray.Empty), + Path.Combine(Path.GetTempPath(), declaredFileName), + compilation.Assembly.Identity.GetDisplayName()); + } + /// /// Run the ReferenceTrimmerAnalyzer on the given library source with symbol-based analysis enabled. /// Dependencies are declared as ProjectReference entries in the TSV file. @@ -1346,6 +1636,26 @@ private static async Task> RunAnalyzerAsync( string librarySource, (MetadataReference Reference, string Path, string Kind, string Spec)[] dependencies, CSharpParseOptions? parseOptions = null) + { + return await RunAnalyzerAsync( + librarySource, + dependencies + .Select(dependency => ( + dependency.Reference, + dependency.Path, + dependency.Kind, + dependency.Spec, + Identity: (string?)null)) + .ToArray(), + parseOptions); + } + + private static async Task> RunAnalyzerAsync( + string librarySource, + (MetadataReference Reference, string Path, string Kind, string Spec, string? Identity)[] dependencies, + CSharpParseOptions? parseOptions = null, + bool useSymbolAnalysis = true, + bool disableTransitiveProjectReferences = false) { var tree = CSharpSyntaxTree.ParseText(librarySource, parseOptions); var references = new List { CorlibRef }; @@ -1353,7 +1663,10 @@ private static async Task> RunAnalyzerAsync( foreach (var dep in dependencies) { references.Add(dep.Reference); - tsvLines.Add($"{dep.Path}\t{dep.Kind}\t{dep.Spec}"); + tsvLines.Add( + dep.Identity is null + ? $"{dep.Path}\t{dep.Kind}\t{dep.Spec}" + : $"{dep.Path}\t{dep.Kind}\t{dep.Spec}\t{dep.Identity}"); } var compilation = CSharpCompilation.Create( @@ -1369,7 +1682,8 @@ private static async Task> RunAnalyzerAsync( var globalOptions = new TestGlobalOptions(new Dictionary { - ["build_property.ReferenceTrimmerUseSymbolAnalysis"] = "true", + ["build_property.ReferenceTrimmerUseSymbolAnalysis"] = useSymbolAnalysis.ToString(), + ["build_property.DisableTransitiveProjectReferences"] = disableTransitiveProjectReferences.ToString(), }); var options = new AnalyzerOptions(additionalTexts, new TestOptionsProvider(globalOptions)); diff --git a/src/Tests/CollectDeclaredReferencesTaskTests.cs b/src/Tests/CollectDeclaredReferencesTaskTests.cs index 53e20b0..73618df 100644 --- a/src/Tests/CollectDeclaredReferencesTaskTests.cs +++ b/src/Tests/CollectDeclaredReferencesTaskTests.cs @@ -1,4 +1,5 @@ using System.Collections; +using System.Xml.Linq; using Microsoft.Build.Framework; using ReferenceTrimmer.Tasks; @@ -109,6 +110,70 @@ public void ExecuteWithPackageReferencesAndNullProjectAssetsFileDoesNotThrow() } } + [TestMethod] + public void ExecuteSerializesProjectAssemblyIdentity() + { + string outputFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".tsv"); + try + { + const string fusionName = "LogicalAssembly, Version=1.2.3.4, Culture=neutral, PublicKeyToken=null"; + var projectReference = new MockTaskItem(Path.Combine("ref", "PhysicalFileName.dll")); + projectReference.SetMetadata("OriginalProjectReferenceItemSpec", @"..\Dependency\Dependency.csproj"); + projectReference.SetMetadata("FusionName", fusionName); + + var engine = new MockBuildEngine(); + var task = new CollectDeclaredReferencesTask + { + BuildEngine = engine, + OutputFile = outputFile, + ProjectReferences = [projectReference], + }; + + bool result = task.Execute(); + + Assert.IsTrue(result, "Task should succeed. Errors: " + string.Join("; ", engine.Errors)); + string expected = + Path.GetFullPath(projectReference.ItemSpec) + + "\tProjectReference\t" + + projectReference.GetMetadata("OriginalProjectReferenceItemSpec") + + "\t" + + fusionName; + Assert.AreEqual(expected, File.ReadAllText(outputFile).TrimEnd()); + } + finally + { + if (File.Exists(outputFile)) + { + File.Delete(outputFile); + } + } + } + + [TestMethod] + public void ProjectAssemblyIdentityParticipatesInIncrementalHash() + { + string targetsPath = Path.GetFullPath(Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "src", + "Package", + "build", + "ReferenceTrimmer.targets")); + XDocument targets = XDocument.Load(targetsPath); + + string? projectHashInput = targets + .Descendants("_CollectDeclaredReferencesHashInputs") + .Select(element => (string?)element.Attribute("Include")) + .SingleOrDefault(include => include?.Contains("_ReferenceTrimmerProjectReferences", StringComparison.Ordinal) == true); + + Assert.IsNotNull(projectHashInput); + StringAssert.Contains(projectHashInput, "%(FusionName)"); + } + private sealed class MockBuildEngine : IBuildEngine { public List Errors { get; } = new();