From d35f92fe542084af001c8203a8de55ef3fbc0678 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 23 Sep 2026 15:48:48 +0100 Subject: [PATCH 1/2] chore: fixed source gen il-merge --- docs/wiki/Packaging.md | 14 +- package.json | 2 +- src/Directory.Build.props | 1 + src/SourceGeneratorFramework.slnx | 1 + .../IsExternalInitNormalizer.cs | 276 ++++++++++++++++ .../MergeToolRunner.cs | 161 ++++++++++ .../Program.cs | 126 +------- .../SourceGeneratorFramework.MergeTool.csproj | 6 +- .../SourceGeneratorFramework.csproj | 4 +- .../MergeToolRunnerTests.cs | 296 ++++++++++++++++++ ...eratorFramework.MergeTool.UnitTests.csproj | 10 + 11 files changed, 766 insertions(+), 131 deletions(-) create mode 100644 src/src/SourceGeneratorFramework.MergeTool/IsExternalInitNormalizer.cs create mode 100644 src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs create mode 100644 src/tests/SourceGeneratorFramework.MergeTool.UnitTests/MergeToolRunnerTests.cs create mode 100644 src/tests/SourceGeneratorFramework.MergeTool.UnitTests/SourceGeneratorFramework.MergeTool.UnitTests.csproj diff --git a/docs/wiki/Packaging.md b/docs/wiki/Packaging.md index 61e43ed..a3ff57b 100644 --- a/docs/wiki/Packaging.md +++ b/docs/wiki/Packaging.md @@ -144,8 +144,18 @@ arrangements is an error. The framework assembly defines `System.Runtime.CompilerServices.IsExternalInit` **publicly** so the framework's own bundled generators can emit `init`-based attribute types into any consumer compilation, and so the merge step has a single marker definition to internalize. Consumers -(generator projects) must **not** declare their own `IsExternalInit`: doing so produces a duplicate -type definition against the framework reference. +(generator projects) should not declare their own `IsExternalInit`. + +A generator-local marker gives calls to the framework's `init` setters a different required custom +modifier identity from the setter definitions. Older merge-tool versions passed both identities to +ILRepack, which could emit `Method reference is used with definition return type / parameter` +warnings while rewriting the component. + +For compatibility with generators that still receive a local marker from legacy source or build +tooling, the merge tool normalizes those required modifiers to the framework marker in a temporary +copy before merging. The generator's bin output is not changed, and the shipped self-contained +analyzer contains one internalized `IsExternalInit` definition. Removing the redundant marker from +the generator project remains the preferred configuration. ### Generators embedded in another package diff --git a/package.json b/package.json index e25a004..ef06027 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-sourcegenerator-framework", - "version": "1.0.0-prerelease.48", + "version": "1.0.0-prerelease.49", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 18c9499..7684379 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,6 +2,7 @@ net8.0;net9.0;net10.0; netstandard2.1;$(TestingTargetFrameworks) + net10.0 Purview.SourceGeneratorFramework true https://github.com/purview-dev/sourcegenerator-framework diff --git a/src/SourceGeneratorFramework.slnx b/src/SourceGeneratorFramework.slnx index 189a73a..685c9ad 100644 --- a/src/SourceGeneratorFramework.slnx +++ b/src/SourceGeneratorFramework.slnx @@ -37,6 +37,7 @@ Id="006ab41c-5588-49d6-8d3f-5b14ae3e6396" /> + diff --git a/src/src/SourceGeneratorFramework.MergeTool/IsExternalInitNormalizer.cs b/src/src/SourceGeneratorFramework.MergeTool/IsExternalInitNormalizer.cs new file mode 100644 index 0000000..1eb3019 --- /dev/null +++ b/src/src/SourceGeneratorFramework.MergeTool/IsExternalInitNormalizer.cs @@ -0,0 +1,276 @@ +using Mono.Cecil; + +static class IsExternalInitNormalizer +{ + const string MarkerFullName = "System.Runtime.CompilerServices.IsExternalInit"; + + public static NormalizedAssembly? Normalize( + string componentPath, + string frameworkPath, + string outputPath, + IEnumerable searchDirectories + ) + { + using var resolver = MergeToolRunner.CreateResolver(searchDirectories); + using var framework = AssemblyDefinition.ReadAssembly( + frameworkPath, + new ReaderParameters { AssemblyResolver = resolver } + ); + var frameworkMarker = framework.MainModule.GetType(MarkerFullName); + if (frameworkMarker is null) + { + return null; + } + + var componentPdbPath = Path.ChangeExtension(componentPath, ".pdb"); + var hasSymbols = File.Exists(componentPdbPath); + using var component = AssemblyDefinition.ReadAssembly( + componentPath, + new ReaderParameters + { + AssemblyResolver = resolver, + ReadSymbols = hasSymbols, + InMemory = true, + } + ); + var componentMarker = component.MainModule.GetType(MarkerFullName); + if (componentMarker is null) + { + return null; + } + + // A component-local marker gives init setter references a different modreq identity from + // framework definitions. Normalize a temporary copy so ILRepack can bind those methods. + var frameworkMarkerReference = component.MainModule.ImportReference(frameworkMarker); + RequiredModifierRewriter rewriter = new(component.MainModule, componentMarker, frameworkMarkerReference); + rewriter.Rewrite(); + + if (rewriter.RemainingComponentMarkerReferences != 0) + { + throw new InvalidOperationException( + $"Failed to normalize all {MarkerFullName} references in '{componentPath}'." + ); + } + + component.MainModule.Types.Remove(componentMarker); + + // Keep the component's bin output untouched; only the transient merge input is rewritten. + var normalizedDirectory = Path.Combine( + Path.GetDirectoryName(outputPath)!, + $".purview-merge-{Guid.NewGuid():N}" + ); + Directory.CreateDirectory(normalizedDirectory); + var normalizedPath = Path.Combine(normalizedDirectory, Path.GetFileName(componentPath)); + component.Write(normalizedPath, new WriterParameters { WriteSymbols = hasSymbols }); + + return new NormalizedAssembly(normalizedPath, normalizedDirectory); + } + + sealed class RequiredModifierRewriter( + ModuleDefinition componentModule, + TypeDefinition componentMarker, + TypeReference frameworkMarker + ) + { + int _remainingComponentMarkerReferences; + + public int RemainingComponentMarkerReferences => _remainingComponentMarkerReferences; + + public void Rewrite() + { + foreach (var type in componentModule.Types.SelectMany(MergeToolRunner.Flatten)) + { + Rewrite(type.BaseType); + foreach (var @interface in type.Interfaces) + { + Rewrite(@interface.InterfaceType); + } + + Rewrite(type.GenericParameters); + + foreach (var field in type.Fields) + { + Rewrite(field.FieldType); + } + + foreach (var property in type.Properties) + { + Rewrite(property.PropertyType); + Rewrite(property.Parameters); + } + + foreach (var @event in type.Events) + { + Rewrite(@event.EventType); + } + + foreach (var method in type.Methods) + { + Rewrite(method.ReturnType); + Rewrite(method.Parameters); + Rewrite(method.GenericParameters); + + foreach (var @override in method.Overrides) + { + Rewrite(@override); + } + + if (!method.HasBody) + { + continue; + } + + foreach (var variable in method.Body.Variables) + { + Rewrite(variable.VariableType); + } + + foreach (var handler in method.Body.ExceptionHandlers) + { + Rewrite(handler.CatchType); + } + + foreach (var instruction in method.Body.Instructions) + { + RewriteOperand(instruction.Operand); + } + } + } + } + + void RewriteOperand(object? operand) + { + switch (operand) + { + case TypeReference type: + Rewrite(type); + break; + case MethodReference method: + Rewrite(method); + break; + case FieldReference field: + Rewrite(field.DeclaringType); + Rewrite(field.FieldType); + break; + case CallSite callSite: + Rewrite(callSite.ReturnType); + Rewrite(callSite.Parameters); + break; + default: + break; + } + } + + void Rewrite(MethodReference method) + { + Rewrite(method.DeclaringType); + Rewrite(method.ReturnType); + Rewrite(method.Parameters); + + if (method is GenericInstanceMethod genericMethod) + { + foreach (var argument in genericMethod.GenericArguments) + { + Rewrite(argument); + } + } + } + + void Rewrite(IEnumerable parameters) + { + foreach (var parameter in parameters) + { + Rewrite(parameter.ParameterType); + } + } + + void Rewrite(IEnumerable parameters) + { + foreach (var parameter in parameters) + { + foreach (var constraint in parameter.Constraints) + { + Rewrite(constraint.ConstraintType); + } + } + } + + void Rewrite(TypeReference? type) + { + if (type is null) + { + return; + } + + if (type is RequiredModifierType requiredModifier) + { + if (IsComponentMarker(requiredModifier.ModifierType)) + { + requiredModifier.ModifierType = frameworkMarker; + } + + Rewrite(requiredModifier.ModifierType); + Rewrite(requiredModifier.ElementType); + return; + } + + if (type is OptionalModifierType optionalModifier) + { + Rewrite(optionalModifier.ModifierType); + Rewrite(optionalModifier.ElementType); + return; + } + + if (type is GenericInstanceType genericInstance) + { + Rewrite(genericInstance.ElementType); + foreach (var argument in genericInstance.GenericArguments) + { + Rewrite(argument); + } + + return; + } + + if (type is FunctionPointerType functionPointer) + { + Rewrite(functionPointer.ReturnType); + Rewrite(functionPointer.Parameters); + return; + } + + if (type is TypeSpecification specification) + { + Rewrite(specification.ElementType); + } + + if (IsComponentMarker(type)) + { + _remainingComponentMarkerReferences++; + } + + Rewrite(type.DeclaringType); + } + + bool IsComponentMarker(TypeReference type) => + type.FullName == MarkerFullName + && ( + ReferenceEquals(type, componentMarker) + || ReferenceEquals(type.Scope, componentModule) + || (type.Scope is AssemblyNameReference assembly && assembly.Name == componentModule.Assembly.Name.Name) + ); + } +} + +sealed class NormalizedAssembly(string assemblyPath, string directoryPath) : IDisposable +{ + public string AssemblyPath { get; } = assemblyPath; + + public void Dispose() + { + if (Directory.Exists(directoryPath)) + { + Directory.Delete(directoryPath, recursive: true); + } + } +} diff --git a/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs b/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs new file mode 100644 index 0000000..a8a8e34 --- /dev/null +++ b/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs @@ -0,0 +1,161 @@ +using ILRepacking; +using Mono.Cecil; + +static class MergeToolRunner +{ + public static int Run(string[] args, TextWriter error, ILogger? logger = null) + { + if (args.Length < 3) + { + error.WriteLine("Usage: Purview.SourceGeneratorFramework.MergeTool "); + return 2; + } + + var componentPath = Path.GetFullPath(args[0]); + var frameworkPath = Path.GetFullPath(args[1]); + var outputPath = Path.GetFullPath(args[2]); + + if (!File.Exists(componentPath)) + { + error.WriteLine($"Roslyn component assembly was not found: {componentPath}"); + return 3; + } + + if (!File.Exists(frameworkPath)) + { + error.WriteLine($"Source Generator Framework assembly was not found: {frameworkPath}"); + return 4; + } + + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + + HashSet searchDirectories = new(StringComparer.OrdinalIgnoreCase) + { + Path.GetDirectoryName(componentPath)!, + Path.GetDirectoryName(frameworkPath)!, + }; + + foreach (var searchPath in args.Skip(3)) + { + var fullSearchPath = Path.GetFullPath(searchPath); + searchDirectories.Add( + File.Exists(fullSearchPath) ? Path.GetDirectoryName(fullSearchPath)! : fullSearchPath + ); + } + + var normalizedComponent = IsExternalInitNormalizer.Normalize( + componentPath, + frameworkPath, + outputPath, + searchDirectories + ); + + try + { + if (normalizedComponent is not null) + { + searchDirectories.Add(Path.GetDirectoryName(normalizedComponent.AssemblyPath)!); + } + + RepackOptions options = new() + { + InputAssemblies = [normalizedComponent?.AssemblyPath ?? componentPath, frameworkPath], + OutputFile = outputPath, + SearchDirectories = searchDirectories, + Internalize = true, + InternalizeAssemblies = [Path.GetFileNameWithoutExtension(frameworkPath)], + RenameInternalized = false, + UnionMerge = true, + Parallel = true, + DebugInfo = File.Exists( + Path.ChangeExtension(normalizedComponent?.AssemblyPath ?? componentPath, ".pdb") + ), + TargetKind = ILRepack.Kind.Dll, + }; + + if (logger is null) + { + new ILRepack(options).Repack(); + } + else + { + new ILRepack(options, logger).Repack(); + } + + var frameworkTypeNames = ReadTypeNames(frameworkPath, searchDirectories); + InternalizeFrameworkTypes(outputPath, frameworkTypeNames, searchDirectories); + return 0; + } + finally + { + normalizedComponent?.Dispose(); + } + } + + static HashSet ReadTypeNames(string assemblyPath, IEnumerable searchDirectories) + { + using var resolver = CreateResolver(searchDirectories); + using var assembly = AssemblyDefinition.ReadAssembly( + assemblyPath, + new ReaderParameters { AssemblyResolver = resolver } + ); + return assembly + .MainModule.Types.SelectMany(Flatten) + .Select(static type => type.FullName) + .ToHashSet(StringComparer.Ordinal); + } + + static void InternalizeFrameworkTypes( + string assemblyPath, + HashSet frameworkTypeNames, + IEnumerable searchDirectories + ) + { + var pdbPath = Path.ChangeExtension(assemblyPath, ".pdb"); + var hasSymbols = File.Exists(pdbPath); + using var resolver = CreateResolver(searchDirectories); + using var assembly = AssemblyDefinition.ReadAssembly( + assemblyPath, + new ReaderParameters + { + AssemblyResolver = resolver, + ReadSymbols = hasSymbols, + InMemory = true, + } + ); + + foreach (var type in assembly.MainModule.Types.SelectMany(Flatten)) + { + if (!frameworkTypeNames.Contains(type.FullName)) + { + continue; + } + + type.Attributes = type.IsNested + ? (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NestedAssembly + : (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NotPublic; + } + + assembly.Write(assemblyPath, new WriterParameters { WriteSymbols = hasSymbols }); + } + + internal static IEnumerable Flatten(TypeDefinition type) + { + yield return type; + foreach (var nestedType in type.NestedTypes.SelectMany(Flatten)) + { + yield return nestedType; + } + } + + internal static DefaultAssemblyResolver CreateResolver(IEnumerable searchDirectories) + { + DefaultAssemblyResolver resolver = new(); + foreach (var searchDirectory in searchDirectories) + { + resolver.AddSearchDirectory(searchDirectory); + } + + return resolver; + } +} diff --git a/src/src/SourceGeneratorFramework.MergeTool/Program.cs b/src/src/SourceGeneratorFramework.MergeTool/Program.cs index 7e9363d..a427def 100644 --- a/src/src/SourceGeneratorFramework.MergeTool/Program.cs +++ b/src/src/SourceGeneratorFramework.MergeTool/Program.cs @@ -1,125 +1 @@ -using ILRepacking; -using Mono.Cecil; - -if (args.Length < 3) -{ - Console.Error.WriteLine("Usage: Purview.SourceGeneratorFramework.MergeTool "); - return 2; -} - -var componentPath = Path.GetFullPath(args[0]); -var frameworkPath = Path.GetFullPath(args[1]); -var outputPath = Path.GetFullPath(args[2]); - -if (!File.Exists(componentPath)) -{ - Console.Error.WriteLine($"Roslyn component assembly was not found: {componentPath}"); - return 3; -} - -if (!File.Exists(frameworkPath)) -{ - Console.Error.WriteLine($"Source Generator Framework assembly was not found: {frameworkPath}"); - return 4; -} - -Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); - -HashSet searchDirectories = new(StringComparer.OrdinalIgnoreCase) -{ - Path.GetDirectoryName(componentPath)!, - Path.GetDirectoryName(frameworkPath)!, -}; - -foreach (var searchPath in args.Skip(3)) -{ - var fullSearchPath = Path.GetFullPath(searchPath); - searchDirectories.Add(File.Exists(fullSearchPath) ? Path.GetDirectoryName(fullSearchPath)! : fullSearchPath); -} - -RepackOptions options = new() -{ - InputAssemblies = [componentPath, frameworkPath], - OutputFile = outputPath, - SearchDirectories = searchDirectories, - Internalize = true, - InternalizeAssemblies = [Path.GetFileNameWithoutExtension(frameworkPath)], - RenameInternalized = false, - UnionMerge = true, - Parallel = true, - DebugInfo = File.Exists(Path.ChangeExtension(componentPath, ".pdb")), - TargetKind = ILRepack.Kind.Dll, -}; - -new ILRepack(options).Repack(); - -var frameworkTypeNames = ReadTypeNames(frameworkPath, searchDirectories); -InternalizeFrameworkTypes(outputPath, frameworkTypeNames, searchDirectories); -return 0; - -static HashSet ReadTypeNames(string assemblyPath, IEnumerable searchDirectories) -{ - using var resolver = CreateResolver(searchDirectories); - using var assembly = AssemblyDefinition.ReadAssembly( - assemblyPath, - new ReaderParameters { AssemblyResolver = resolver } - ); - return assembly - .MainModule.Types.SelectMany(Flatten) - .Select(static type => type.FullName) - .ToHashSet(StringComparer.Ordinal); -} - -static void InternalizeFrameworkTypes( - string assemblyPath, - IReadOnlySet frameworkTypeNames, - IEnumerable searchDirectories -) -{ - var pdbPath = Path.ChangeExtension(assemblyPath, ".pdb"); - var hasSymbols = File.Exists(pdbPath); - using var resolver = CreateResolver(searchDirectories); - using var assembly = AssemblyDefinition.ReadAssembly( - assemblyPath, - new ReaderParameters - { - AssemblyResolver = resolver, - ReadSymbols = hasSymbols, - InMemory = true, - } - ); - - foreach (var type in assembly.MainModule.Types.SelectMany(Flatten)) - { - if (!frameworkTypeNames.Contains(type.FullName)) - { - continue; - } - - type.Attributes = type.IsNested - ? (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NestedAssembly - : (type.Attributes & ~TypeAttributes.VisibilityMask) | TypeAttributes.NotPublic; - } - - assembly.Write(assemblyPath, new WriterParameters { WriteSymbols = hasSymbols }); -} - -static IEnumerable Flatten(TypeDefinition type) -{ - yield return type; - foreach (var nestedType in type.NestedTypes.SelectMany(Flatten)) - { - yield return nestedType; - } -} - -static DefaultAssemblyResolver CreateResolver(IEnumerable searchDirectories) -{ - DefaultAssemblyResolver resolver = new(); - foreach (var searchDirectory in searchDirectories) - { - resolver.AddSearchDirectory(searchDirectory); - } - - return resolver; -} +return MergeToolRunner.Run(args, Console.Error); diff --git a/src/src/SourceGeneratorFramework.MergeTool/SourceGeneratorFramework.MergeTool.csproj b/src/src/SourceGeneratorFramework.MergeTool/SourceGeneratorFramework.MergeTool.csproj index 9642df9..aefbd48 100644 --- a/src/src/SourceGeneratorFramework.MergeTool/SourceGeneratorFramework.MergeTool.csproj +++ b/src/src/SourceGeneratorFramework.MergeTool/SourceGeneratorFramework.MergeTool.csproj @@ -1,6 +1,6 @@ - net10.0 + $(MergeToolTFM) Exe false @@ -9,4 +9,8 @@ + + + + diff --git a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj index aadca28..f05926c 100644 --- a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj +++ b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj @@ -62,8 +62,8 @@ > analyzers/dotnet/cs/ - <_PurviewMergeToolPackageFile Include="..\SourceGeneratorFramework.MergeTool\bin\$(Configuration)\net10.0\*.dll;..\SourceGeneratorFramework.MergeTool\bin\$(Configuration)\net10.0\*.json"> - tools/net10.0/%(_PurviewMergeToolPackageFile.Filename)%(_PurviewMergeToolPackageFile.Extension) + <_PurviewMergeToolPackageFile Include="..\SourceGeneratorFramework.MergeTool\bin\$(Configuration)\$(MergeToolTFM)\*.dll;..\SourceGeneratorFramework.MergeTool\bin\$(Configuration)\$(MergeToolTFM)\*.json"> + tools/$(MergeToolTFM)/%(_PurviewMergeToolPackageFile.Filename)%(_PurviewMergeToolPackageFile.Extension) diff --git a/src/tests/SourceGeneratorFramework.MergeTool.UnitTests/MergeToolRunnerTests.cs b/src/tests/SourceGeneratorFramework.MergeTool.UnitTests/MergeToolRunnerTests.cs new file mode 100644 index 0000000..7394c32 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.MergeTool.UnitTests/MergeToolRunnerTests.cs @@ -0,0 +1,296 @@ +using System.Reflection; +using System.Runtime.Loader; +using ILRepacking; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Mono.Cecil; + +namespace Purview.SourceGeneratorFramework.MergeTool; + +public sealed class MergeToolRunnerTests +{ + const string MarkerFullName = "System.Runtime.CompilerServices.IsExternalInit"; + + const string FrameworkSource = """ + namespace System.Runtime.CompilerServices + { + public static class IsExternalInit; + } + + namespace Fixture.Framework + { + public enum OptionKind + { + None, + Enabled, + } + + public sealed class Options + { + public OptionKind Kind { get; init; } + } + } + """; + + const string ComponentSource = """ + namespace System.Runtime.CompilerServices + { + internal static class IsExternalInit; + } + + namespace Fixture.Component + { + using Fixture.Framework; + + public static class Consumer + { + public static Options Create() => new() { Kind = OptionKind.Enabled }; + } + + public sealed class ComponentOptions + { + public int Value { get; init; } + } + } + """; + + const string ComponentWithoutMarkerSource = """ + namespace Fixture.Component + { + using Fixture.Framework; + + public static class Consumer + { + public static Options Create() => new() { Kind = OptionKind.Enabled }; + } + } + """; + + [Test] + public async Task GivenDuplicateIsExternalInit_MergeProducesCanonicalWarningFreeAssembly( + CancellationToken cancellationToken + ) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + using TestWorkspace workspace = new(); + var frameworkPath = workspace.Compile("Fixture.Framework", FrameworkSource); + var componentPath = workspace.Compile("Fixture.Component", ComponentSource, frameworkPath); + var outputPath = workspace.GetPath("merged", "Fixture.Component.dll"); + TestLogger logger = new(); + + // Act + var exitCode = MergeToolRunner.Run( + [componentPath, frameworkPath, outputPath, TestWorkspace.NetStandardReferenceDirectory], + TextWriter.Null, + logger + ); + + // Assert + await Assert.That(exitCode).IsEqualTo(0); + await Assert + .That( + logger.Warnings.Any(static warning => + warning.Contains( + "Method reference is used with definition return type / parameter", + StringComparison.Ordinal + ) + ) + ) + .IsFalse(); + + await AssertMergedMetadataAsync(outputPath); + await AssertMergedAssemblyExecutesAsync(outputPath); + using var original = AssemblyDefinition.ReadAssembly(componentPath); + await Assert.That(original.MainModule.GetType(MarkerFullName)).IsNotNull(); + await Assert + .That( + Directory + .EnumerateDirectories( + Path.GetDirectoryName(outputPath)!, + ".purview-merge-*", + SearchOption.TopDirectoryOnly + ) + .Any() + ) + .IsFalse(); + } + + [Test] + public async Task GivenNoDuplicateIsExternalInit_MergeKeepsExistingCleanPath(CancellationToken cancellationToken) + { + // Arrange + cancellationToken.ThrowIfCancellationRequested(); + using TestWorkspace workspace = new(); + var frameworkPath = workspace.Compile("Fixture.Framework", FrameworkSource); + var componentPath = workspace.Compile("Fixture.Component", ComponentWithoutMarkerSource, frameworkPath); + var outputPath = workspace.GetPath("merged", "Fixture.Component.dll"); + TestLogger logger = new(); + + // Act + var exitCode = MergeToolRunner.Run( + [componentPath, frameworkPath, outputPath, TestWorkspace.NetStandardReferenceDirectory], + TextWriter.Null, + logger + ); + + // Assert + await Assert.That(exitCode).IsEqualTo(0); + await Assert.That(logger.Warnings).IsEmpty(); + await Assert.That(File.Exists(outputPath)).IsTrue(); + } + + static async Task AssertMergedMetadataAsync(string outputPath) + { + using var merged = AssemblyDefinition.ReadAssembly(outputPath); + var markers = merged.MainModule.Types.Where(static type => type.FullName == MarkerFullName).ToArray(); + await Assert.That(markers).HasSingleItem(); + await Assert.That(markers[0].IsNotPublic).IsTrue(); + await Assert + .That(merged.MainModule.AssemblyReferences.Any(static reference => reference.Name == "Fixture.Framework")) + .IsFalse(); + + var consumer = merged.MainModule.GetType("Fixture.Component.Consumer"); + var create = consumer.Methods.Single(static method => method.Name == "Create"); + var setter = create + .Body.Instructions.Select(static instruction => instruction.Operand) + .OfType() + .Single(static method => method.Name == "set_Kind"); + var modifier = (RequiredModifierType)setter.ReturnType; + await Assert.That(modifier.ModifierType.FullName).IsEqualTo(MarkerFullName); + await Assert.That(modifier.ModifierType.Scope).IsSameReferenceAs(merged.MainModule); + + var componentOptions = merged.MainModule.GetType("Fixture.Component.ComponentOptions"); + var componentSetter = componentOptions.Methods.Single(static method => method.Name == "set_Value"); + var componentModifier = (RequiredModifierType)componentSetter.ReturnType; + await Assert.That(componentModifier.ModifierType.FullName).IsEqualTo(MarkerFullName); + await Assert.That(componentModifier.ModifierType.Scope).IsSameReferenceAs(merged.MainModule); + } + + static async Task AssertMergedAssemblyExecutesAsync(string outputPath) + { + AssemblyLoadContext loadContext = new( + name: nameof(GivenDuplicateIsExternalInit_MergeProducesCanonicalWarningFreeAssembly), + isCollectible: true + ); + try + { + await using var stream = File.OpenRead(outputPath); + var loaded = loadContext.LoadFromStream(stream); + var created = loaded + .GetType("Fixture.Component.Consumer", throwOnError: true)! + .GetMethod("Create", BindingFlags.Public | BindingFlags.Static)! + .Invoke(null, null); + var kind = created!.GetType().GetProperty("Kind")!.GetValue(created); + await Assert.That(kind!.ToString()).IsEqualTo("Enabled"); + } + finally + { + loadContext.Unload(); + } + } + + sealed class TestLogger : ILogger + { + public bool ShouldLogVerbose { get; set; } + + public List Warnings { get; } = []; + + public void Error(string msg) { } + + public void Info(string msg) { } + + public void Verbose(string msg) { } + + public void Warn(string msg) => Warnings.Add(msg); + } + + sealed class TestWorkspace : IDisposable + { + readonly string _directory = Path.Combine( + Path.GetTempPath(), + $"Purview.SourceGeneratorFramework.MergeTool.Tests.{Guid.NewGuid():N}" + ); + + public static string NetStandardReferenceDirectory { get; } = GetNetStandardReferenceDirectory(); + + public TestWorkspace() + { + Directory.CreateDirectory(_directory); + } + + public string Compile(string assemblyName, string source, params string[] additionalReferences) + { + var outputPath = GetPath(assemblyName, $"{assemblyName}.dll"); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + + var references = Directory + .EnumerateFiles(NetStandardReferenceDirectory, "*.dll") + .Select(static path => (MetadataReference)MetadataReference.CreateFromFile(path)) + .ToList(); + references.AddRange(additionalReferences.Select(static path => MetadataReference.CreateFromFile(path))); + + var compilation = CreateCompilation(assemblyName, source, references); + Emit(compilation, outputPath); + return outputPath; + } + + static CSharpCompilation CreateCompilation( + string assemblyName, + string source, + IEnumerable references + ) => + CSharpCompilation.Create( + assemblyName, + [ + CSharpSyntaxTree.ParseText( + source, + CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.Latest) + ), + ], + references, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary) + ); + + static void Emit(CSharpCompilation compilation, string outputPath) + { + using var output = File.Create(outputPath); + var result = compilation.Emit(output); + if (!result.Success) + { + throw new InvalidOperationException(string.Join(Environment.NewLine, result.Diagnostics)); + } + } + + public string GetPath(params string[] parts) => parts.Aggregate(_directory, Path.Combine); + + public void Dispose() + { + if (Directory.Exists(_directory)) + { + Directory.Delete(_directory, recursive: true); + } + } + + static string GetNetStandardReferenceDirectory() + { + var packageRoot = Environment.GetEnvironmentVariable("NUGET_PACKAGES"); + if (string.IsNullOrWhiteSpace(packageRoot)) + { + packageRoot = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".nuget", + "packages" + ); + } + + var path = Path.Combine(packageRoot, "netstandard.library", "2.0.3", "build", "netstandard2.0", "ref"); + + return Directory.Exists(path) + ? path + : throw new DirectoryNotFoundException( + $"The .NET Standard 2.0 reference directory was not found: {path}" + ); + } + } +} diff --git a/src/tests/SourceGeneratorFramework.MergeTool.UnitTests/SourceGeneratorFramework.MergeTool.UnitTests.csproj b/src/tests/SourceGeneratorFramework.MergeTool.UnitTests/SourceGeneratorFramework.MergeTool.UnitTests.csproj new file mode 100644 index 0000000..92eea72 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.MergeTool.UnitTests/SourceGeneratorFramework.MergeTool.UnitTests.csproj @@ -0,0 +1,10 @@ + + + $(MergeToolTFM) + + + + + + + From 2052201b0e884efc00578443b8ad805c2b7bad00 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 23 Sep 2026 16:18:18 +0100 Subject: [PATCH 2/2] build: added the validated packaged content to the build json --- Justfile | 7 +++++++ purview-build.json | 44 ++++++++++++++++++++++----------------- src/Directory.Build.props | 3 +-- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/Justfile b/Justfile index c4e06a6..01a9e2a 100644 --- a/Justfile +++ b/Justfile @@ -67,6 +67,13 @@ pipeline-tests *args: echo "Running tests pipeline..." "{{ pipeline_tool }}" --Build:RunTests=true --Release:Mode=None {{ args }} +# Run the pipeline through pack + validate (restore, build, lint, tests, pack, validate pack contents) without publishing/releasing +[group('Pipeline')] +pipeline-pack-validate *args: + just ensure-pipeline-tool + echo "Running pack + validate pipeline..." + "{{ pipeline_tool }}" --Build:RunPack=true --Build:ValidatePack=true --Release:Mode=None {{ args }} + # Build and test with the specified configuration, defaulting to "Debug" [group('Build and Test')] build *args: diff --git a/purview-build.json b/purview-build.json index b9765e0..8d3e035 100644 --- a/purview-build.json +++ b/purview-build.json @@ -10,39 +10,45 @@ "RequireSymbolFiles": true, "RequiredContent": { "purview.sourcegeneratorframework": [ - "lib/netstandard2.0/Purview.SourceGeneratorFramework.dll", - "analyzers/dotnet/cs/Purview.SourceGeneratorFramework.Generators.dll", + ".agents/**/*", "analyzers/dotnet/cs/Purview.SourceGeneratorFramework.Analyzers.dll", "analyzers/dotnet/cs/Purview.SourceGeneratorFramework.CodeFixers.dll", + "analyzers/dotnet/cs/Purview.SourceGeneratorFramework.Generators.dll", "build/Purview.SourceGeneratorFramework.props", "build/Purview.SourceGeneratorFramework.targets", - "tools/net10.0/Purview.SourceGeneratorFramework.MergeTool.dll", + "lib/netstandard2.0/Purview.SourceGeneratorFramework.dll", + "lib/netstandard2.0/Purview.SourceGeneratorFramework.xml", + "purview-logo-light.png", "README.md", - "LICENSE.md", - "purview-logo-light.png" + "tools/*/ILRepack.dll", + "tools/*/Mono.Cecil.dll", + "tools/*/Mono.Cecil.Mdb.dll", + "tools/*/Mono.Cecil.Pdb.dll", + "tools/*/Mono.Cecil.Rocks.dll", + "tools/*/Purview.SourceGeneratorFramework.MergeTool.deps.json", + "tools/*/Purview.SourceGeneratorFramework.MergeTool.dll", + "tools/*/Purview.SourceGeneratorFramework.MergeTool.runtimeconfig.json", + "tools/*/System.IO.Hashing.dll" ], "purview.sourcegeneratorframework.testing": [ - "lib/netstandard2.0/Purview.SourceGeneratorFramework.Testing.dll", + ".agents/**/*", "build/Purview.SourceGeneratorFramework.Testing.props", - "README.md", - "LICENSE.md", - "purview-logo-light.png" + "lib/*/Purview.SourceGeneratorFramework.Testing.dll", + "lib/*/Purview.SourceGeneratorFramework.Testing.xml", + "purview-logo-light.png", + "README.md" ], "purview.sourcegeneratorframework.testing.tunit": [ - "lib/netstandard2.0/Purview.SourceGeneratorFramework.Testing.TUnit.dll", + ".agents/**/*", "build/Purview.SourceGeneratorFramework.Testing.TUnit.props", - "README.md", - "LICENSE.md", - "purview-logo-light.png" - ] - }, - "ForbiddenContent": { - "*": [ - "*.pdb" + "lib/*/Purview.SourceGeneratorFramework.Testing.TUnit.dll", + "lib/*/Purview.SourceGeneratorFramework.Testing.TUnit.xml", + "purview-logo-light.png", + "README.md" ] } }, "Release": { "Mode": "None" } -} +} \ No newline at end of file diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 7684379..1ae1b18 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -44,13 +44,12 @@ $(PurviewProjectUrl) https://github.com/purview-dev/sourcegenerator-framework README.md - LICENSE.md + MIT purview-logo-light.png false -