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);
+ }
}