From 25725e4b82b98375cfa51b200fba5ad7c1e92e14 Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Wed, 30 Sep 2026 11:59:27 +0100 Subject: [PATCH] fix: internal types were merged out --- AGENTS.md | 6 + README.md | 8 +- docs/Getting-Started.md | 3 +- package.json | 2 +- .../SourceGenerator.Refactorings.csproj | 5 +- .../Common/DiagnosticLibrary.cs | 15 +- .../SourceGenerator/SourceGenerator.csproj | 10 +- .../Refactorings/CodeFixTestHarness.cs | 64 +++++- .../PackagedAnalyzerComponents.cs | 210 ++++++++++++++++++ ...lueObjectAddPartialModifierCodeFixTests.cs | 35 +++ 10 files changed, 335 insertions(+), 23 deletions(-) create mode 100644 src/tests/SourceGenerator.UnitTests/Refactorings/PackagedAnalyzerComponents.cs diff --git a/AGENTS.md b/AGENTS.md index cb5df3e..2f692a6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,6 +63,12 @@ a diagnostic analyzer, a code fix, tests, samples, and documentation for scalar - Generated output must be stable for identical input. - Diagnostics are public developer experience: preserve IDs and meanings, choose accurate locations and severity, and update `AnalyzerReleases.*.md` for newly introduced or changed diagnostics. +- Cross-component contracts must go through **public** members. The artifact that consumers (and Visual Studio) + load is the merged, self-contained assembly the `Purview.SourceGeneratorFramework` merge pass produces, and + that pass strips every `InternalsVisibleTo` declaration. A component that reads another component's internals + (for example a code fix reading a diagnostic descriptor) therefore compiles against the unmerged build output + and throws `FieldAccessException` at runtime in the IDE. Do not add `InternalsVisibleTo` between Roslyn + components; expose the shared identity publicly (see `DiagnosticLibrary`). - Source-generator changes normally require tests in `src/tests/SourceGenerator.UnitTests`. ## Runtime project rules diff --git a/README.md b/README.md index 57392f7..5ef1aa6 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ dotnet add package Purview.ValueObjects ``` The package ships the runtime contracts (`[Scalar]`, `[ValueObject]`, `IValueObject`, ...), the source generator, -and the diagnostic analyzer. There is no dependency on any event-sourcing library. +the diagnostic analyzer, and code fixes for the analyzer's diagnostics (for example `VO1001` offers +**Add 'partial' modifier**). There is no dependency on any event-sourcing library. ## Quick start @@ -185,6 +186,9 @@ Set `DisableValueObjectsSourceGenerator` to `true` in your project: - `src/src/EFDomainSample.Domain` / `src/src/EFDomainSample.Persistence` – a domain project without Entity Framework and the persistence project that maps its value objects. - `src/src/SourceGenerator` – incremental source generator + analyzer. -- `src/src/SourceGenerator.Refactorings` – code fix for the "must be partial" diagnostic. +- `src/src/SourceGenerator.Refactorings` – code fixes for the generator's diagnostics (the "must be partial" + fix). It consumes the generator's diagnostic identities through **public** members only: the shipped analyzer + is the merged, self-contained artifact the `Purview.SourceGeneratorFramework` merge pass produces, and that + pass strips every `InternalsVisibleTo` declaration. - `src/tests` – unit and source-generator tests. - `docs` – design and usage guidance. diff --git a/docs/Getting-Started.md b/docs/Getting-Started.md index 165c37d..61b72b8 100644 --- a/docs/Getting-Started.md +++ b/docs/Getting-Started.md @@ -8,7 +8,8 @@ This guide walks through modeling DTOs and domain values with `Purview.ValueObje dotnet add package Purview.ValueObjects ``` -The package includes the runtime contracts, the source generator, and the diagnostic analyzer. +The package includes the runtime contracts, the source generator, the diagnostic analyzer, and code fixes for +the analyzer's diagnostics (for example `VO1001` offers **Add 'partial' modifier**). ## 2. Scalar value objects diff --git a/package.json b/package.json index 0617de8..abb67de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-value-objects", - "version": "1.0.0-prerelease.10", + "version": "1.0.0-prerelease.11", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/src/src/SourceGenerator.Refactorings/SourceGenerator.Refactorings.csproj b/src/src/SourceGenerator.Refactorings/SourceGenerator.Refactorings.csproj index 08c6927..50b507c 100644 --- a/src/src/SourceGenerator.Refactorings/SourceGenerator.Refactorings.csproj +++ b/src/src/SourceGenerator.Refactorings/SourceGenerator.Refactorings.csproj @@ -10,7 +10,10 @@ - + diff --git a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs index bad0494..5bb8c6d 100644 --- a/src/src/SourceGenerator/Common/DiagnosticLibrary.cs +++ b/src/src/SourceGenerator/Common/DiagnosticLibrary.cs @@ -1,6 +1,19 @@ namespace Purview.ValueObjects.SourceGenerator.Common; -static class DiagnosticLibrary +/// +/// The diagnostics this component produces. +/// +/// The type is public because the identities are shared with the code-fix component +/// (Purview.ValueObjects.SourceGenerator.Refactorings). Consumers receive the merged, +/// self-contained analyzer that the Purview.SourceGeneratorFramework merge pass produces, and that +/// pass strips every InternalsVisibleTo declaration from the artifact. Reaching these members +/// through internals therefore compiles against the unmerged build output and then fails with a +/// in the IDE, as soon as Roslyn reads a fixable +/// diagnostic id from the shipped analyzer. Public members are the only cross-component contract a +/// merged artifact preserves. +/// +/// +public static class DiagnosticLibrary { const string ValueObjectCategory = "ValueObjects"; diff --git a/src/src/SourceGenerator/SourceGenerator.csproj b/src/src/SourceGenerator/SourceGenerator.csproj index dd10e25..f40aabb 100644 --- a/src/src/SourceGenerator/SourceGenerator.csproj +++ b/src/src/SourceGenerator/SourceGenerator.csproj @@ -21,9 +21,9 @@ - - - - + diff --git a/src/tests/SourceGenerator.UnitTests/Refactorings/CodeFixTestHarness.cs b/src/tests/SourceGenerator.UnitTests/Refactorings/CodeFixTestHarness.cs index aaad75f..91896f7 100644 --- a/src/tests/SourceGenerator.UnitTests/Refactorings/CodeFixTestHarness.cs +++ b/src/tests/SourceGenerator.UnitTests/Refactorings/CodeFixTestHarness.cs @@ -11,6 +11,11 @@ namespace Purview.ValueObjects.SourceGenerator.Refactorings; /// Manual code-fix harness. The framework's code-fix test base does not run the source generators, /// but the aggregate/value-object attributes used by the analyzers are emitted by those generators, /// so this harness runs them before analyzing. +/// +/// The generic overloads construct the in-repo components. The instance overloads accept components +/// supplied by the caller, which lets a test drive the packaged, merged analyzer artifact that +/// consumers and Visual Studio actually load; see . +/// /// public static class CodeFixTestHarness { @@ -20,7 +25,14 @@ CancellationToken cancellationToken ) where TAnalyzer : DiagnosticAnalyzer, new() where TCodeFix : CodeFixProvider, new() => - ApplyAsync(source, fixAll: false, cancellationToken); + RunAsync( + source, + new Generators.ValueObjectSourceGenerator(), + new TAnalyzer(), + new TCodeFix(), + fixAll: false, + cancellationToken + ); public static Task ApplyFixAllAsync( string source, @@ -28,25 +40,52 @@ CancellationToken cancellationToken ) where TAnalyzer : DiagnosticAnalyzer, new() where TCodeFix : CodeFixProvider, new() => - ApplyAsync(source, fixAll: true, cancellationToken); + RunAsync( + source, + new Generators.ValueObjectSourceGenerator(), + new TAnalyzer(), + new TCodeFix(), + fixAll: true, + cancellationToken + ); + + /// + /// Applies the first registered code fix using components supplied by the caller, so a test can drive + /// a packaged (merged, self-contained) analyzer artifact instead of the in-repo build output. + /// + public static Task ApplyAsync( + string source, + IIncrementalGenerator generator, + DiagnosticAnalyzer analyzer, + CodeFixProvider provider, + CancellationToken cancellationToken + ) => RunAsync(source, generator, analyzer, provider, fixAll: false, cancellationToken); + + /// Applies every registered code fix using components supplied by the caller. + public static Task ApplyFixAllAsync( + string source, + IIncrementalGenerator generator, + DiagnosticAnalyzer analyzer, + CodeFixProvider provider, + CancellationToken cancellationToken + ) => RunAsync(source, generator, analyzer, provider, fixAll: true, cancellationToken); - static async Task ApplyAsync( + static async Task RunAsync( string source, + IIncrementalGenerator generator, + DiagnosticAnalyzer analyzer, + CodeFixProvider provider, bool fixAll, CancellationToken cancellationToken ) - where TAnalyzer : DiagnosticAnalyzer, new() - where TCodeFix : CodeFixProvider, new() { - var (updatedCompilation, originalTree) = CreateCompilation(source); + var (updatedCompilation, originalTree) = CreateCompilation(source, generator); - TAnalyzer analyzer = new(); var analyzerDiagnosticsAll = await updatedCompilation .WithAnalyzers([analyzer]) .GetAnalyzerDiagnosticsAsync(cancellationToken); var analyzerDiagnostics = analyzerDiagnosticsAll.ToArray(); - TCodeFix provider = new(); var applicable = analyzerDiagnostics .Where(diagnostic => provider.FixableDiagnosticIds.Contains(diagnostic.Id, StringComparer.Ordinal)) .ToArray(); @@ -100,7 +139,10 @@ [.. applicable.Select(static diagnostic => diagnostic.Id)] ); } - static (Compilation Compilation, SyntaxTree OriginalTree) CreateCompilation(string source) + static (Compilation Compilation, SyntaxTree OriginalTree) CreateCompilation( + string source, + IIncrementalGenerator generator + ) { CSharpParseOptions parseOptions = new(LanguageVersion.Latest); var tree = CSharpSyntaxTree.ParseText(source, parseOptions, path: "Test.cs"); @@ -115,9 +157,7 @@ [.. applicable.Select(static diagnostic => diagnostic.Id)] ); // The value-object attributes are emitted by the source generator. - GeneratorDriver driver = CSharpGeneratorDriver.Create([ - new Generators.ValueObjectSourceGenerator().AsSourceGenerator(), - ]); + GeneratorDriver driver = CSharpGeneratorDriver.Create([generator.AsSourceGenerator()]); driver.RunGeneratorsAndUpdateCompilation(compilation, out var updatedCompilation, out _); return (updatedCompilation, tree); diff --git a/src/tests/SourceGenerator.UnitTests/Refactorings/PackagedAnalyzerComponents.cs b/src/tests/SourceGenerator.UnitTests/Refactorings/PackagedAnalyzerComponents.cs new file mode 100644 index 0000000..d29ce5b --- /dev/null +++ b/src/tests/SourceGenerator.UnitTests/Refactorings/PackagedAnalyzerComponents.cs @@ -0,0 +1,210 @@ +using System.Reflection; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace Purview.ValueObjects.SourceGenerator.Refactorings; + +/// +/// Loads the value-object Roslyn components in the shape a package consumer receives: the merged, +/// self-contained analyzer artifact (the assembly Visual Studio loads from +/// analyzers/dotnet/cs) together with the code-fix component. +/// +/// The merge pass strips every InternalsVisibleTo declaration from the artifact, so a +/// component that reaches into another component's internals compiles against the unmerged build +/// output and then fails with at runtime. Loading the packaged +/// shape here reproduces that environment and keeps the failure detectable without Visual Studio. +/// +/// +/// A merged artifact must never be referenced at compile time, so it is loaded out of band into an +/// isolated ; host assemblies (Roslyn, immutable collections, and +/// composition) are shared so the reflected components keep one type identity with the test host. +/// +/// +public sealed class PackagedAnalyzerComponents : IDisposable +{ + const string GeneratorAssemblyName = "Purview.ValueObjects.SourceGenerator"; + + const string CodeFixAssemblyName = "Purview.ValueObjects.SourceGenerator.Refactorings"; + + const string GeneratorTypeName = "Purview.ValueObjects.SourceGenerator.Generators.ValueObjectSourceGenerator"; + + const string AnalyzerTypeName = "Purview.ValueObjects.SourceGenerator.Analyzers.ValueObjectDiagnosticAnalyzer"; + + const string CodeFixTypeName = + "Purview.ValueObjects.SourceGenerator.Refactorings.AddPartialModifierCodeFixProvider"; + + const string MergedArtifactDirectoryName = "purview-merged"; + + readonly PackagedAnalyzerLoadContext _loadContext; + + PackagedAnalyzerComponents( + PackagedAnalyzerLoadContext loadContext, + IIncrementalGenerator generator, + DiagnosticAnalyzer analyzer, + CodeFixProvider codeFix + ) + { + _loadContext = loadContext; + Generator = generator; + Analyzer = analyzer; + CodeFix = codeFix; + } + + /// Gets the value-object source generator loaded from the packaged analyzer artifact. + public IIncrementalGenerator Generator { get; } + + /// Gets the value-object diagnostic analyzer loaded from the packaged analyzer artifact. + public DiagnosticAnalyzer Analyzer { get; } + + /// Gets the code-fix provider loaded from the packaged code-fix component. + public CodeFixProvider CodeFix { get; } + + /// Loads the packaged analyzer and code-fix components for the current build configuration. + public static PackagedAnalyzerComponents Load() + { + var packageAnalyzerPath = FindPackagedAnalyzerPath(); + var codeFixPath = typeof(AddPartialModifierCodeFixProvider).Assembly.Location; + + PackagedAnalyzerLoadContext loadContext = new(packageAnalyzerPath, codeFixPath, AppContext.BaseDirectory); + try + { + var analyzerAssembly = loadContext.LoadFromAssemblyPath(packageAnalyzerPath); + var codeFixAssembly = loadContext.LoadFromAssemblyPath(codeFixPath); + + return new PackagedAnalyzerComponents( + loadContext, + (IIncrementalGenerator)CreateComponent(analyzerAssembly, GeneratorTypeName), + (DiagnosticAnalyzer)CreateComponent(analyzerAssembly, AnalyzerTypeName), + (CodeFixProvider)CreateComponent(codeFixAssembly, CodeFixTypeName) + ); + } + catch + { + loadContext.Unload(); + throw; + } + } + + /// + public void Dispose() => _loadContext.Unload(); + + static object CreateComponent(Assembly assembly, string typeName) => + Activator.CreateInstance(assembly.GetType(typeName, throwOnError: true)!)!; + + /// + /// Resolves the merged analyzer artifact produced by the Purview.SourceGeneratorFramework merge + /// pass into the generator's intermediate output. + /// + static string FindPackagedAnalyzerPath() + { + var mergedRoot = Path.Combine(FindRepositoryRoot(), "src", "src", "SourceGenerator", "obj"); + if (!Directory.Exists(mergedRoot)) + throw new DirectoryNotFoundException( + $"The generator intermediate output '{mergedRoot}' was not found. Build the solution before running the packaged-shape tests." + ); + + var configuration = FindBuildConfiguration(); + var candidates = Directory + .EnumerateFiles(mergedRoot, GeneratorAssemblyName + ".dll", SearchOption.AllDirectories) + .Where(path => + path.Contains( + $"{Path.DirectorySeparatorChar}{MergedArtifactDirectoryName}{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase + ) + ) + // A failed merge can leave a partially written staging directory behind; never load that. + .Where(path => !path.Contains(".staging-", StringComparison.OrdinalIgnoreCase)) + .Where(path => + configuration is null + || path.Contains( + $"{Path.DirectorySeparatorChar}{configuration}{Path.DirectorySeparatorChar}", + StringComparison.OrdinalIgnoreCase + ) + ) + .OrderByDescending(path => File.GetLastWriteTimeUtc(path)) + .ToArray(); + + if (candidates.Length == 0) + throw new FileNotFoundException( + "The merged, self-contained analyzer artifact was not found under " + + $"'{mergedRoot}'. Building the solution runs the Purview.SourceGeneratorFramework merge pass that produces it.", + mergedRoot + ); + + return candidates[0]; + } + + static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "src", "ValueObjects.slnx"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new DirectoryNotFoundException( + $"The repository root could not be located above '{AppContext.BaseDirectory}'." + ); + } + + static string? FindBuildConfiguration() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (directory.Name is "Debug" or "Release") + return directory.Name; + + directory = directory.Parent; + } + + return null; + } + + /// + /// Isolates the packaged components from the in-repo build output. The generator/analyzer assembly + /// and the code-fix assembly resolve from the packaged shape; every other assembly is shared with + /// the test host when it is already loaded, and otherwise probed beside the test binaries. + /// + sealed class PackagedAnalyzerLoadContext : AssemblyLoadContext + { + readonly Dictionary _componentPaths; + + readonly string _probeDirectory; + + public PackagedAnalyzerLoadContext(string packageAnalyzerPath, string codeFixPath, string probeDirectory) + : base(nameof(PackagedAnalyzerLoadContext), isCollectible: true) + { + _componentPaths = new Dictionary(StringComparer.Ordinal) + { + [GeneratorAssemblyName] = packageAnalyzerPath, + [CodeFixAssemblyName] = codeFixPath, + }; + _probeDirectory = probeDirectory; + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + var name = assemblyName.Name; + if (name is null) + return null; + + if (_componentPaths.TryGetValue(name, out var componentPath)) + return LoadFromAssemblyPath(componentPath); + + var host = Default.Assemblies.FirstOrDefault(assembly => + AssemblyName.ReferenceMatchesDefinition(assembly.GetName(), assemblyName) + ); + if (host is not null) + return host; + + var probePath = Path.Combine(_probeDirectory, name + ".dll"); + return File.Exists(probePath) ? LoadFromAssemblyPath(probePath) : null; + } + } +} diff --git a/src/tests/SourceGenerator.UnitTests/Refactorings/ValueObjectAddPartialModifierCodeFixTests.cs b/src/tests/SourceGenerator.UnitTests/Refactorings/ValueObjectAddPartialModifierCodeFixTests.cs index ed23f48..d50df4e 100644 --- a/src/tests/SourceGenerator.UnitTests/Refactorings/ValueObjectAddPartialModifierCodeFixTests.cs +++ b/src/tests/SourceGenerator.UnitTests/Refactorings/ValueObjectAddPartialModifierCodeFixTests.cs @@ -1,3 +1,4 @@ +using Microsoft.CodeAnalysis.CodeFixes; using Purview.ValueObjects.SourceGenerator.Analyzers; namespace Purview.ValueObjects.SourceGenerator.Refactorings; @@ -48,4 +49,38 @@ public async Task GivenNonPartialComplexValueObject_AddsPartialModifier(Cancella await Assert.That(result.FixedCode).Contains("public readonly partial record struct Money"); } + + /// + /// The analyzer artifact consumers receive is the merged, self-contained assembly the framework + /// merge pass produces, and that pass strips every InternalsVisibleTo declaration. A code + /// fix that shares the diagnostic identity through generator internals therefore compiles against + /// the unmerged build output and throws as soon as Visual Studio + /// reads from the packaged artifact. + /// + [Test] + public async Task GivenPackagedAnalyzerShape_FixableDiagnosticIds_ExposeTheValueObjectMustBePartialId() + { + using var components = PackagedAnalyzerComponents.Load(); + + await Assert + .That(components.CodeFix.FixableDiagnosticIds) + .Contains(DiagnosticLibrary.ValueObjectMustBePartial.Id); + } + + [Test] + public async Task GivenPackagedAnalyzerShape_AddsPartialModifier(CancellationToken cancellationToken) + { + using var components = PackagedAnalyzerComponents.Load(); + + var result = await CodeFixTestHarness.ApplyAsync( + NonPartialScalar, + components.Generator, + components.Analyzer, + components.CodeFix, + cancellationToken + ); + + await Assert.That(result.FixedCode).Contains("public readonly partial record struct EmailAddress"); + await Assert.That(result.FixedDiagnosticIds).Contains(DiagnosticLibrary.ValueObjectMustBePartial.Id); + } }