diff --git a/ICSharpCode.BamlDecompiler/BamlDecompilerTypeSystem.cs b/ICSharpCode.BamlDecompiler/BamlDecompilerTypeSystem.cs index 79542334a..dd1624968 100644 --- a/ICSharpCode.BamlDecompiler/BamlDecompilerTypeSystem.cs +++ b/ICSharpCode.BamlDecompiler/BamlDecompilerTypeSystem.cs @@ -126,6 +126,15 @@ namespace ICSharpCode.BamlDecompiler if (asm != null) { referencedAssemblies.Add(asm); + // Whether an element is a markup extension is decided by walking its base types, + // and a base type can sit in an assembly the document's own assembly never names. + // Stopping at the assemblies it does name leaves such a type unresolved, the + // extension unrecognised, and the decompiler's placeholder namespace in the XAML + // (issue #2930). + foreach (var indirectReference in asm.AssemblyReferences) + { + assemblyReferenceQueue.Enqueue((true, asm, indirectReference)); + } var metadata = asm.Metadata; foreach (var h in metadata.ExportedTypes) { diff --git a/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj b/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj index 97dde68e0..d0950e4f3 100644 --- a/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj +++ b/ILSpy.BamlDecompiler.Tests/ILSpy.BamlDecompiler.Tests.csproj @@ -46,6 +46,7 @@ + diff --git a/ILSpy.BamlDecompiler.Tests/TransitiveReferenceTests.cs b/ILSpy.BamlDecompiler.Tests/TransitiveReferenceTests.cs new file mode 100644 index 000000000..a68cf5ae8 --- /dev/null +++ b/ILSpy.BamlDecompiler.Tests/TransitiveReferenceTests.cs @@ -0,0 +1,111 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection.PortableExecutable; + +using ICSharpCode.BamlDecompiler; +using ICSharpCode.Decompiler.Metadata; +using ICSharpCode.Decompiler.TypeSystem; + +using NUnit.Framework; + +namespace ILSpy.BamlDecompiler.Tests +{ + /// + /// Deciding whether an element is a markup extension means walking its base types, and those + /// leave the assemblies a document's own assembly names. A base type landing in an assembly + /// nobody loaded resolves to nothing, the extension is not recognised, and it goes out as an + /// element tree carrying the decompiler's own placeholder namespace, + /// "https://github.com/icsharpcode/ILSpy", into the XAML (issue #2930). + /// + [TestFixture] + public class TransitiveReferenceTests + { + /// + /// ICSharpCode.BamlDecompiler stands in for a document's assembly here: it has references of + /// its own, and those references have references it does not name itself. + /// + static readonly string MainModulePath = Path.Combine( + Path.GetDirectoryName(typeof(TransitiveReferenceTests).Assembly.Location), + "ICSharpCode.BamlDecompiler.dll"); + + static PEFile Load(string path) + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read); + return new PEFile(path, stream, streamOptions: PEStreamOptions.PrefetchEntireImage); + } + + static ICompilation TypeSystemOf(PEFile file) + { + var resolver = new UniversalAssemblyResolver(file.FileName, throwOnError: false, + file.DetectTargetFrameworkId(), file.DetectRuntimePack()); + return new BamlDecompilerTypeSystem(file, resolver); + } + + /// + /// An assembly the main module reaches only through one of its own references. Derived from + /// the metadata rather than named here, so that changing what either assembly references + /// cannot quietly turn this test into one that proves nothing. + /// + static string IndirectlyReferencedAssembly(PEFile mainModule) + { + var direct = new HashSet(mainModule.AssemblyReferences.Select(r => r.Name)); + foreach (var reference in mainModule.AssemblyReferences) + { + if (reference.Name != "ICSharpCode.Decompiler") + continue; + using var referenced = Load(Path.Combine( + Path.GetDirectoryName(mainModule.FileName), reference.Name + ".dll")); + string indirect = referenced.AssemblyReferences + .Select(r => r.Name) + .FirstOrDefault(name => !direct.Contains(name)); + Assert.That(indirect, Is.Not.Null, + "the premise of this test is gone: every assembly ICSharpCode.Decompiler " + + "references is now referenced by ICSharpCode.BamlDecompiler as well"); + return indirect; + } + Assert.Fail("ICSharpCode.BamlDecompiler no longer references ICSharpCode.Decompiler"); + return null; + } + + [Test] + public void AnAssemblyReachedOnlyThroughAReferenceIsLoaded() + { + using var mainModule = Load(MainModulePath); + string indirect = IndirectlyReferencedAssembly(mainModule); + + var compilation = TypeSystemOf(mainModule); + + Assert.That(compilation.Modules.Select(m => m.AssemblyName), Does.Contain(indirect)); + } + + [Test] + public void TheAssembliesTheModuleNamesItselfAreStillLoaded() + { + using var mainModule = Load(MainModulePath); + + var compilation = TypeSystemOf(mainModule); + + Assert.That(compilation.Modules.Select(m => m.AssemblyName), + Does.Contain("ICSharpCode.Decompiler")); + } + } +}