From 250e6f6f5d65064455d211ee657eeeb1042b218b Mon Sep 17 00:00:00 2001 From: Kieron Lanning Date: Fri, 25 Sep 2026 13:01:32 +0100 Subject: [PATCH] fix: sg resolution on pack --- Directory.Packages.props | 1 + docs/wiki/Analyzers.md | 14 + docs/wiki/Getting-Started.md | 15 + docs/wiki/Packaging.md | 98 ++++- global.json | 2 +- package.json | 2 +- purview-build.json | 7 +- src/SourceGeneratorFramework.slnx | 1 + .../MergeToolRunner.cs | 118 +++++- .../SourceGeneratorFramework/Sdk/README.md | 35 ++ .../Purview.SourceGeneratorFramework.props | 3 + .../Purview.SourceGeneratorFramework.targets | 331 +++++++++++++-- .../SourceGeneratorFramework.csproj | 8 + .../Fixture.Consumer/Fixture.Consumer.csproj | 30 ++ .../Fixture.Consumer/FixtureConsumerType.cs | 10 + .../Fixture.Generator.CodeFixers.csproj | 30 ++ .../FixtureCodeFixProvider.cs | 23 ++ .../Fixture.Generator/Directory.Build.props | 16 + .../Fixture.Generator/Directory.Build.targets | 12 + .../Fixture.Generator.csproj | 47 +++ .../FixtureComponentDiagnostics.cs | 13 + .../Fixture.Generator/FixtureGenerator.cs | 40 ++ ...GeneratorFramework.Fixture.Generator.props | 12 + ...neratorFramework.Fixture.Generator.targets | 12 + .../ConsumerType.cs | 10 + .../Fixture.ProjectReferenceConsumer.csproj | 18 + .../VsAnalyzerSimulation.targets | 24 ++ .../AnalyzerClosureTests.cs | 391 ++++++++++++++++++ ...atorFramework.BuildIntegrationTests.csproj | 13 + 29 files changed, 1286 insertions(+), 50 deletions(-) create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Consumer/Fixture.Consumer.csproj create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Consumer/FixtureConsumerType.cs create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator.CodeFixers/Fixture.Generator.CodeFixers.csproj create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator.CodeFixers/FixtureCodeFixProvider.cs create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Directory.Build.props create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Directory.Build.targets create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Fixture.Generator.csproj create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/FixtureComponentDiagnostics.cs create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/FixtureGenerator.cs create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Sdk/build/Purview.SourceGeneratorFramework.Fixture.Generator.props create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Sdk/buildTransitive/Purview.SourceGeneratorFramework.Fixture.Generator.targets create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.ProjectReferenceConsumer/ConsumerType.cs create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.ProjectReferenceConsumer/Fixture.ProjectReferenceConsumer.csproj create mode 100644 src/tests/Fixtures/Purview.ComponentClosureFixture/VsAnalyzerSimulation.targets create mode 100644 src/tests/SourceGeneratorFramework.BuildIntegrationTests/AnalyzerClosureTests.cs create mode 100644 src/tests/SourceGeneratorFramework.BuildIntegrationTests/SourceGeneratorFramework.BuildIntegrationTests.csproj diff --git a/Directory.Packages.props b/Directory.Packages.props index 7223b1a..988fb0e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -18,6 +18,7 @@ + diff --git a/docs/wiki/Analyzers.md b/docs/wiki/Analyzers.md index 4723a6d..5ae0c2a 100644 --- a/docs/wiki/Analyzers.md +++ b/docs/wiki/Analyzers.md @@ -19,6 +19,20 @@ The analyzers enforce two families of rules: - **C# 14 extension-member conventions** — `PSGFR34`–`PSGFR38`, plus the associated `ReorganizeExtensionClassCodeFixProvider` and `ConvertToExtensionBlockCodeFixProvider`. +## Build-time validation diagnostics + +These are MSBuild diagnostics rather than compiler analyzers, so they are not tracked in +`AnalyzerReleases.*.md`: + +| Code | Raised by | Summary | +|------|-----------|---------| +| `PSGF0001` | `Purview.BuildSdk` | A Roslyn component did not produce (or did not declare) a source-generator analyzer file. | +| `PSGF0003` | `Purview.SourceGeneratorFramework` | A `PurviewGeneratorVisibleProperty` is not compiler-visible in the declaring project or its `Sdk/build`/`Sdk/buildTransitive` assets, so consumers cannot read `build_property.`. | +| `PRSGD0005` | `Purview.BuildSdk` | A file in the returned analyzer closure references an assembly that is neither part of the closure nor a compiler-host assembly. | + +Opt out with `PurviewSourceGeneratorFrameworkAnalyzerValidation=false` (`PRSGD0005`) or +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false` (`PSGF0003`). + ## Rule reference | Rule | Summary | diff --git a/docs/wiki/Getting-Started.md b/docs/wiki/Getting-Started.md index b483b9e..e32cbd1 100644 --- a/docs/wiki/Getting-Started.md +++ b/docs/wiki/Getting-Started.md @@ -47,6 +47,21 @@ type identity. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"` explicitly is not required. Set `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles` to `false` only when the unmerged assembly + loose framework DLL shape is required (see [Packaging.md](Packaging.md)). +### A component that references another component + +A code-fix component can reference the generator component normally (`ProjectReference`, +`ReferenceOutputAssembly` not `false`) when it needs the generator's internal diagnostic identity. +The generator's **analyzer artifact** is the merged, self-contained assembly, while its **bin output** +stays unmerged; the framework assembly is copied beside that bin output and flows transitively +through `ProjectReference`, so the dependent component's bin folder is self-sufficient. The dependent +component's analyzer closure includes the referenced component's merged artifact — it is never +IL-merged a second time, which would duplicate its types. See +[Packaging.md](Packaging.md#a-component-that-references-another-component). + +Declare each MSBuild property the generator reads with +``; `PSGF0003` fails the build when +the property is not compiler-visible. + ### Referencing a generator from its test project A test project can need the source-generator project in two different roles at the same time: diff --git a/docs/wiki/Packaging.md b/docs/wiki/Packaging.md index fc96158..d8ed517 100644 --- a/docs/wiki/Packaging.md +++ b/docs/wiki/Packaging.md @@ -99,10 +99,70 @@ different shape: | Path | Trigger | Output | | --- | --- | --- | -| `GetSourceGeneratorAnalyzerFiles` (default) | A consuming project references the component as an analyzer | The **merged**, self-contained component, returned from the intermediate `purview-merged/` directory. The component's bin output stays unmerged, so its in-process test harness retains shared framework type identity and `InternalsVisibleTo` access without `CS0433` collisions. | +| `GetSourceGeneratorAnalyzerFiles` (default) | A consuming project references the component as an analyzer | The **merged**, self-contained component, returned from the content-addressed intermediate `purview-merged//` directory. The component's bin output stays unmerged, so its in-process test harness retains shared framework type identity and `InternalsVisibleTo` access without `CS0433` collisions. | | `GetSourceGeneratorAnalyzerFiles` (opt-out) | Same, with `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles=false` | Unmerged component + the loose `Purview.SourceGeneratorFramework.dll` copied from the framework package `lib/`. | | `GetPurviewMergedAnalyzerFile` | The framework package's own bundled-component pack, or a third-party package embedding the generator | The **merged** component from the intermediate output; the component's bin is never overwritten. | -| `GenerateNuspec` (`EmbedPurviewSourceGeneratorFrameworkForPack`) | Packing a standalone, packable generator project | The generator's bin is replaced by the **merged** self-contained DLL and the loose framework DLL is deleted before the package is written. | +| `GetPurviewMergedAnalyzerFileForPack` (pack-time, via `TargetsForTfmSpecificContentInPackage`) | Packing a standalone, packable generator project | The **merged**, self-contained DLL (and its PDB) is contributed directly to `analyzers/dotnet/cs`. The component's bin output is never mutated, so its on-disk shape does not depend on whether `build` or `pack` ran last. | + +### A component that references another component + +A code-fix (or analyzer) component can reference the generator component normally +(`ProjectReference`, `ReferenceOutputAssembly` not `false`) when it needs the generator's internal +diagnostic identity. Both components are `IsRoslynComponent`; only the generator references the +framework. + +- The generator component merges at build time. Its **analyzer artifact** is the merged, + self-contained assembly from `obj/.../purview-merged//`; its **bin output** stays unmerged and + references `Purview.SourceGeneratorFramework`. +- Because a package consumer's framework `lib/` asset is not copied to output, the framework + assembly is declared as copy-to-output content beside the generator's bin output. Content with + `CopyToOutputDirectory` flows transitively through `ProjectReference`, so the dependent code-fix + component's bin folder is self-sufficient too — that is what lets Visual Studio load the code-fix + provider from its own bin without a `FileNotFoundException` for the framework assembly. +- The dependent component's **analyzer closure** (what `GetSourceGeneratorAnalyzerFiles` returns) + includes the referenced component's analyzer artifact. The referenced component is returned as its + own merged assembly and is **never** IL-merged into the dependent component, which would duplicate + its types (including `InternalsVisibleTo`-visible internals) inside a second analyzer in the same + host. +- Visual Studio's project system resolves an `OutputItemType=Analyzer` project reference to the + referenced project's default target path — the component's **unmerged** bin assembly — and adds it + to the compiler's analyzers, which the command-line build does not. `Purview.BuildSdk` removes that + item before adding the resolved closure, so Roslyn only ever receives the merged artifact. + Otherwise the unmerged copy drags `Purview.SourceGeneratorFramework` into the compiler host + (`CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`) + and the generators are registered twice. + +### Build-time analyzer-closure validation + +`Purview.BuildSdk` validates the whole closure returned by `GetSourceGeneratorAnalyzerFiles`: every +file's PE `AssemblyRef` must resolve to another file in the returned set or to a compiler-host +assembly (`Microsoft.CodeAnalysis*`, `System.Composition.*`, `System.*`, `netstandard`, …). A missing +component artifact fails the build with `PRSGD0005`. Extend the permitted set with +`` (or the +`PurviewAnalyzerClosurePermittedReferences` property). Opt out with +`PurviewSourceGeneratorFrameworkAnalyzerValidation=false`. + +### Generator-read MSBuild properties + +A generator that reads `build_property.` is only correct if `` is a +`CompilerVisibleProperty` wherever the generator runs. Declare the properties a component reads: + +```xml + + + +``` + +`PSGF0003` fails the build when a declared property is neither a `CompilerVisibleProperty` in the +project nor declared by the project's own `Sdk/build` or `Sdk/buildTransitive` assets (which is what +consumers receive). Opt out with +`PurviewSourceGeneratorFrameworkGeneratorPropertyValidation=false`. + +NuGet imports `buildTransitive` assets for **PackageReference** consumers only. While developing the +package repository itself every project uses `ProjectReference`, so +`Purview.BuildSdk`'s `ImportProjectReferencedBuildTransitiveAssets` target registers the referenced +project's `Sdk/buildTransitive/*.props|*.targets` `CompilerVisibleProperty` items for in-repo +consumers. Opt out with `PurviewImportProjectReferenceBuildTransitive=false`. In the opt-out path the loose framework DLL is declared as a `SourceGeneratorRuntimeDependency` (statically from the framework package `lib/` for package consumers, with a target-time fallback for @@ -110,10 +170,36 @@ in-repo `ProjectReference` components) so the SDK copies it beside the generator it. The merged paths never declare it. The merge itself (`_PurviewMergeSourceGeneratorFramework`) only writes to the component's -intermediate `purview-merged/` directory. `GetSourceGeneratorAnalyzerFiles` returns that result by -substituting the merged path into `TargetPathWithTargetPlatformMoniker` immediately before its body -runs, leaving `GetTargetPath` — which resolves assembly references — pointing at the unmerged bin. -This is what keeps the in-repo test harness working while shipped assemblies stay self-contained. +intermediate output. `GetSourceGeneratorAnalyzerFiles` returns that result by substituting the merged +path into `TargetPathWithTargetPlatformMoniker` immediately before its body runs, leaving +`GetTargetPath` — which resolves assembly references — pointing at the unmerged bin. This is what +keeps the in-repo test harness working while shipped assemblies stay self-contained. + +The merged artifact is **content-addressed**: it lives in +`$(IntermediateOutputPath)purview-merged//$(TargetFileName)`, where `` is a +SHA-256 of the component, the framework assembly and the merge tool (the merge tool computes it via +its `--tag` mode). The tag is in the *directory*, never the file name, because ILRepack derives the +merged assembly's simple name from the output file name and an analyzer must keep +`.dll`. + +That gives two properties the earlier fixed-name output could not: + +- a rebuild with identical inputs reuses the existing file and **skips the merge**, keeping + `_PurviewMergeSourceGeneratorFramework` idempotent across the per-consumer (and potentially + parallel) invocations of `GetSourceGeneratorAnalyzerFiles`; and +- changed inputs select a **new directory**, so the merge never overwrites a merged assembly that a + compiler host (csc/`VBCSCompiler`/Visual Studio) already has loaded. Overwriting such a file fails + on Windows with a sharing violation, which surfaced as `MSB3073` (merge tool exit code + `-532462766`) and left consumers loading a stale or partially written analyzer — the root cause of + `CS8784 FileNotFoundException: Could not load file or assembly 'Purview.SourceGeneratorFramework'`. + +The merge tool merges into a per-process `.staging-` directory and publishes it with a +non-overwriting directory rename; a concurrent invocation that loses the race simply observes the +published artifact instead of failing. + +Packaging needs the stable `.dll` name, so the pack paths +(`GetPurviewMergedAnalyzerFile`, `GetPurviewMergedAnalyzerFileForPack`) copy the content-addressed +artifact into `$(IntermediateOutputPath)purview-pack/` and pack that plainly named staging copy. ### Framework type internalization in merged components diff --git a/global.json b/global.json index f8c7bba..b476ad4 100644 --- a/global.json +++ b/global.json @@ -3,7 +3,7 @@ "allowPrerelease": false }, "msbuild-sdks": { - "Purview.BuildSdk": "1.0.0-prerelease.57" + "Purview.BuildSdk": "1.0.0-prerelease.60" }, "test": { "runner": "Microsoft.Testing.Platform" diff --git a/package.json b/package.json index ed999bc..c20d121 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "purview-sourcegenerator-framework", - "version": "1.0.0-prerelease.51", + "version": "1.0.0-prerelease.52", "license": "MIT", "author": { "name": "Kieron Lanning", diff --git a/purview-build.json b/purview-build.json index 3d71580..4d12dca 100644 --- a/purview-build.json +++ b/purview-build.json @@ -16,8 +16,11 @@ "analyzers/dotnet/cs/Purview.SourceGeneratorFramework.Generators.dll", "build/Purview.SourceGeneratorFramework.props", "build/Purview.SourceGeneratorFramework.targets", + "content/Packaging.md", + "contentFiles/any/netstandard2.0/Packaging.md", "lib/netstandard2.0/Purview.SourceGeneratorFramework.dll", "lib/netstandard2.0/Purview.SourceGeneratorFramework.xml", + "packaging.md", "purview-logo-light.png", "README.md", "tools/*/ILRepack.dll", @@ -48,7 +51,9 @@ ] }, "ForbiddenContent": { - "*": ["analyzers/**/Purview.SourceGeneratorFramework.dll"] + "*": [ + "analyzers/**/Purview.SourceGeneratorFramework.dll" + ] } }, "Release": { diff --git a/src/SourceGeneratorFramework.slnx b/src/SourceGeneratorFramework.slnx index 685c9ad..7546cfb 100644 --- a/src/SourceGeneratorFramework.slnx +++ b/src/SourceGeneratorFramework.slnx @@ -40,6 +40,7 @@ + diff --git a/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs b/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs index d145ce9..a77b9e5 100644 --- a/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs +++ b/src/src/SourceGeneratorFramework.MergeTool/MergeToolRunner.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using ILRepacking; using Mono.Cecil; @@ -5,12 +7,17 @@ static class MergeToolRunner { const string IsExternalInitName = "IsExternalInit"; const string IsExternalInitNamespace = "System.Runtime.CompilerServices"; + const string TagCommand = "--tag"; public static int Run(string[] args, TextWriter error, ILogger? logger = null) { + if (args.Length > 0 && string.Equals(args[0], TagCommand, StringComparison.Ordinal)) + return WriteContentTag(args, error); + if (args.Length < 3) { error.WriteLine("Usage: Purview.SourceGeneratorFramework.MergeTool "); + error.WriteLine(" Purview.SourceGeneratorFramework.MergeTool --tag [...]"); return 2; } @@ -30,7 +37,16 @@ public static int Run(string[] args, TextWriter error, ILogger? logger = null) return 4; } - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!); + // Merge into a per-process staging directory that keeps the output's file name, then publish + // the directory with a non-overwriting move. The file name must be preserved because ILRepack + // derives the merged assembly's simple name from it, and the build content-addresses the + // output directory so a concurrent invocation that loses the race simply observes the + // published artifact instead of failing with a sharing violation against a file the compiler + // host already has loaded. + var outputDirectory = Path.GetDirectoryName(outputPath)!; + var stagingDirectory = outputDirectory + ".staging-" + Environment.ProcessId; + Directory.CreateDirectory(stagingDirectory); + var stagingPath = Path.Combine(stagingDirectory, Path.GetFileName(outputPath)); HashSet searchDirectories = new(StringComparer.OrdinalIgnoreCase) { @@ -49,7 +65,7 @@ public static int Run(string[] args, TextWriter error, ILogger? logger = null) var normalizedComponent = IsExternalInitNormalizer.Normalize( componentPath, frameworkPath, - outputPath, + stagingPath, searchDirectories ); @@ -63,7 +79,7 @@ public static int Run(string[] args, TextWriter error, ILogger? logger = null) RepackOptions options = new() { InputAssemblies = [normalizedComponent?.AssemblyPath ?? componentPath, frameworkPath], - OutputFile = outputPath, + OutputFile = stagingPath, SearchDirectories = searchDirectories, Internalize = true, InternalizeAssemblies = [Path.GetFileNameWithoutExtension(frameworkPath)], @@ -84,13 +100,13 @@ public static int Run(string[] args, TextWriter error, ILogger? logger = null) new ILRepack(options, logger).Repack(); } - RestoreCanonicalIsExternalInit(outputPath, searchDirectories); + RestoreCanonicalIsExternalInit(stagingPath, searchDirectories); // ILRepack's internalize is best-effort and cannot reach framework types the component's // own generators emit, so force self-containment deterministically and fail the build // rather than shipping an analyzer that leaks framework types. var internalization = FrameworkTypeInternalizer.Apply( - outputPath, + stagingPath, searchDirectories, logger is not null ? logger.Warn : message => error.WriteLine(message) ); @@ -98,12 +114,12 @@ public static int Run(string[] args, TextWriter error, ILogger? logger = null) if (internalization.PublicFrameworkTypesRemaining.Length > 0) { error.WriteLine( - $"The merged component '{outputPath}' still exposes public Purview.SourceGeneratorFramework types: {string.Join(", ", internalization.PublicFrameworkTypesRemaining)}." + $"The merged component '{stagingPath}' still exposes public Purview.SourceGeneratorFramework types: {string.Join(", ", internalization.PublicFrameworkTypesRemaining)}." ); return 5; } - return 0; + return Publish(stagingPath, outputPath, error) ? 0 : 6; } finally { @@ -111,6 +127,94 @@ public static int Run(string[] args, TextWriter error, ILogger? logger = null) } } + /// + /// Writes a deterministic content tag (SHA-256 over the per-input SHA-256 values) for the given + /// inputs to standard output. The build uses it to give the merged artifact a content-addressed + /// directory, so a rebuild with unchanged inputs skips the merge and changed inputs never + /// overwrite a merged assembly the compiler host has loaded. + /// + static int WriteContentTag(string[] args, TextWriter error) + { + StringBuilder builder = new(); + for (var index = 1; index < args.Length; index++) + { + var path = Path.GetFullPath(args[index]); + if (!File.Exists(path)) + { + error.WriteLine($"Merge input was not found: {path}"); + return 3; + } + + builder.Append(Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path)))); + builder.Append('\n'); + } + + Console.Out.WriteLine(Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString())))); + return 0; + } + + /// + /// Publishes the staged merged assembly by renaming its staging directory onto the + /// content-addressed destination directory. When a concurrent invocation has already produced + /// the same artifact the staging directory is discarded and the call succeeds. + /// + static bool Publish(string stagingPath, string outputPath, TextWriter error) + { + var stagingDirectory = Path.GetDirectoryName(stagingPath)!; + var outputDirectory = Path.GetDirectoryName(outputPath)!; + + // IsExternalInitNormalizer stages its normalized component in a .purview-merge-* directory + // beside the output; it must not be published with the merged artifact. + foreach ( + var temporary in Directory.EnumerateDirectories( + stagingDirectory, + ".purview-merge-*", + SearchOption.TopDirectoryOnly + ) + ) + { + TryDeleteDirectory(temporary); + } + + if (File.Exists(outputPath)) + { + // Another invocation produced the same (content-addressed) artifact first. + TryDeleteDirectory(stagingDirectory); + return true; + } + + try + { + Directory.Move(stagingDirectory, outputDirectory); + } + catch (IOException) when (File.Exists(outputPath)) + { + TryDeleteDirectory(stagingDirectory); + return true; + } + catch (IOException exception) + { + error.WriteLine($"Failed to publish the merged component '{outputPath}': {exception.Message}"); + TryDeleteDirectory(stagingDirectory); + return false; + } + + return true; + } + + static void TryDeleteDirectory(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, recursive: true); + } + catch (IOException) + { + // Leftover staging directories are harmless; the build cleans its intermediate directory. + } + } + internal static IEnumerable Flatten(TypeDefinition type) { yield return type; diff --git a/src/src/SourceGeneratorFramework/Sdk/README.md b/src/src/SourceGeneratorFramework/Sdk/README.md index a16a2d2..65e33f4 100644 --- a/src/src/SourceGeneratorFramework/Sdk/README.md +++ b/src/src/SourceGeneratorFramework/Sdk/README.md @@ -25,6 +25,41 @@ type identity. Specifying `Targets="GetSourceGeneratorAnalyzerFiles"` explicitly is not required. Set `PurviewMergeSourceGeneratorFrameworkForAnalyzerFiles` to `false` only when the unmerged assembly + loose framework DLL shape is required (see [Packaging.md](Packaging.md)). +### A component that references another component + +A code-fix component can reference the generator component normally (`ProjectReference`, +`ReferenceOutputAssembly` not `false`) when it needs the generator's internal diagnostic identity. +Only the generator references the framework, so: + +- the generator merges to a **merged, self-contained analyzer artifact** while its **bin output stays + unmerged**; +- the framework assembly is copied beside the generator's bin output and flows transitively through + `ProjectReference`, so the dependent component's bin folder is self-sufficient — the IDE can load + the code-fix provider without a `FileNotFoundException` for the framework assembly; and +- the dependent component's analyzer closure includes the referenced component's merged artifact (it + is never IL-merged a second time, which would duplicate its types). + +The analyzer closure is validated at build time: every `AssemblyRef` must resolve inside the closure +or to a compiler-host assembly, otherwise `PRSGD0005` fails the build (see +[Packaging.md](Packaging.md#build-time-analyzer-closure-validation)). + +### Generator-read MSBuild properties + +Declare each MSBuild property the generator reads so it is validated against +`CompilerVisibleProperty`: + +```xml + + + +``` + +`PSGF0003` fails the build when a declared property is not compiler-visible in the project or its +`Sdk/build`/`Sdk/buildTransitive` assets. In-repo `ProjectReference` consumers additionally receive a +referenced project's `Sdk/buildTransitive` `CompilerVisibleProperty` items, which NuGet would only +register for `PackageReference` consumers (see +[Packaging.md](Packaging.md#generator-read-msbuild-properties)). + ### Referencing a generator from its test project A test project can need the source-generator project in two different roles at the same time: diff --git a/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props b/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props index 15a5fa8..3264c40 100644 --- a/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props +++ b/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.props @@ -36,6 +36,9 @@ False to opt out of PSGFR39 self-contained analyzer validation. + + False to opt out of PSGF0003 generator-visible property validation. + diff --git a/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.targets b/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.targets index e49f5f6..2b16606 100644 --- a/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.targets +++ b/src/src/SourceGeneratorFramework/Sdk/build/Purview.SourceGeneratorFramework.targets @@ -1,4 +1,4 @@ - + <_PurviewSourceGeneratorFrameworkAssembly Condition="'$(_PurviewSourceGeneratorFrameworkAssembly)' == ''" >$(MSBuildThisFileDirectory)..\lib\netstandard2.0\Purview.SourceGeneratorFramework.dll @@ -44,6 +44,51 @@ + + + + <_PurviewFrameworkOutputCopyAssembly>$([System.IO.Path]::GetFullPath('$(_PurviewSourceGeneratorFrameworkAssembly)')) + + + + + + + + + + + <_PurviewMergedAnalyzerStagingDirectoryFullPath>$([System.IO.Path]::GetFullPath('$(_PurviewMergedAnalyzerStagingDirectory)')) - + + + + + + + + <_PurviewMergedComponentTag>$([System.String]::Copy('$(_PurviewMergedComponentTagRaw)').Substring(0, 16).Trim()) + <_PurviewMergedComponentTagDirectory>$(_PurviewMergedComponentDirectory)$(_PurviewMergedComponentTag)\ + <_PurviewMergedComponent>$(_PurviewMergedComponentTagDirectory)$(TargetFileName) + <_PurviewMergedComponentPdb>$(_PurviewMergedComponentTagDirectory)$(TargetName).pdb + + + + + + + + + + + + + <_PurviewStagedMergedAnalyzerFile + Include="$(_PurviewMergedAnalyzerStagingDirectoryFullPath)$(TargetFileName)" + Condition="Exists('$(_PurviewMergedAnalyzerStagingDirectoryFullPath)$(TargetFileName)')" + /> + - - <_PurviewMergedComponentFullPath>$([System.IO.Path]::GetFullPath('$(_PurviewMergedComponent)')) - - <_PurviewMergedAnalyzerFileOutput - Include="$(_PurviewMergedComponentFullPath)" - Condition="Exists('$(_PurviewMergedComponentFullPath)')" - /> + <_PurviewMergedAnalyzerFileOutput Include="@(_PurviewStagedMergedAnalyzerFile)" /> + + $(TargetsForTfmSpecificContentInPackage);GetPurviewMergedAnalyzerFileForPack + + + + + + + + - - - - + + + + + + + + + + analyzers/dotnet/cs/ + + + analyzers/dotnet/cs/ + + + analyzers/dotnet/cs/ + + + + + + + + + + + + + + + + + + + + + + + [^\"']+)[\"']", + RegexOptions.IgnoreCase + ); + + var available = new HashSet(StringComparer.OrdinalIgnoreCase); + if (CompilerVisible != null) + { + foreach (var item in CompilerVisible) + available.Add(item.ItemSpec); + } + + if (AssetFiles != null) + { + foreach (var file in AssetFiles) + { + if (!File.Exists(file.ItemSpec)) + continue; + + string text; + try + { + text = File.ReadAllText(file.ItemSpec); + } + catch (Exception) + { + continue; + } + + foreach (Match match in compilerVisiblePropertyPattern.Matches(text)) + { + var include = match.Groups["name"].Value; + if (!string.IsNullOrWhiteSpace(include)) + available.Add(include.Trim()); + } + } + } + + foreach (var item in Declared) + { + if (available.Contains(item.ItemSpec)) + continue; + + Log.LogError(null, "PSGF0003", null, null, 0, 0, 0, 0, + "The project '{0}' declares that its generator reads the MSBuild property '{1}', but '{1}' is not compiler-visible here: there is no CompilerVisibleProperty item for it and no build/buildTransitive asset declares it. Add to the project's Sdk/build or Sdk/buildTransitive assets (or to the outer package that embeds the generator) so consumers can read build_property.{1}.", + ProjectName, item.ItemSpec); + } + ]]> + + + + + + + <_PurviewGeneratorVisiblePropertyAsset Include="$(MSBuildProjectDirectory)\Sdk\build\**\*.props" /> + <_PurviewGeneratorVisiblePropertyAsset Include="$(MSBuildProjectDirectory)\Sdk\build\**\*.targets" /> + <_PurviewGeneratorVisiblePropertyAsset Include="$(MSBuildProjectDirectory)\Sdk\buildTransitive\**\*.props" /> + <_PurviewGeneratorVisiblePropertyAsset Include="$(MSBuildProjectDirectory)\Sdk\buildTransitive\**\*.targets" /> + + + + + diff --git a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj index f05926c..bdfa264 100644 --- a/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj +++ b/src/src/SourceGeneratorFramework/SourceGeneratorFramework.csproj @@ -38,6 +38,14 @@ --> + + + + + + + + netstandard2.0 + Fixture.Consumer + Fixture.Consumer + + false + + + + + + + diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Consumer/FixtureConsumerType.cs b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Consumer/FixtureConsumerType.cs new file mode 100644 index 0000000..642bba8 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Consumer/FixtureConsumerType.cs @@ -0,0 +1,10 @@ +namespace Fixture.Consumer; + +/// +/// Placeholder type so the fixture consumer has a compilation to run the generator against. +/// +public sealed class FixtureConsumerType +{ + /// Gets the fixture marker. + public static string Marker => "fixture"; +} diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator.CodeFixers/Fixture.Generator.CodeFixers.csproj b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator.CodeFixers/Fixture.Generator.CodeFixers.csproj new file mode 100644 index 0000000..b6a2714 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator.CodeFixers/Fixture.Generator.CodeFixers.csproj @@ -0,0 +1,30 @@ + + + + true + Fixture.Generator.CodeFixers + Fixture.Generator.CodeFixers + + true + + false + + + + + + + + + + + diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator.CodeFixers/FixtureCodeFixProvider.cs b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator.CodeFixers/FixtureCodeFixProvider.cs new file mode 100644 index 0000000..d224be2 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator.CodeFixers/FixtureCodeFixProvider.cs @@ -0,0 +1,23 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; + +namespace Fixture.Generator.CodeFixers; + +/// +/// Minimal code-fix provider that resolves its diagnostic id from the referenced generator +/// assembly, forcing a real runtime dependency between the two components. +/// +[ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(FixtureCodeFixProvider))] +public sealed class FixtureCodeFixProvider : CodeFixProvider +{ + /// + public override ImmutableArray FixableDiagnosticIds => + [global::Fixture.Generator.FixtureComponentDiagnostics.DiagnosticId]; + + /// + public override FixAllProvider? GetFixAllProvider() => null; + + /// + public override Task RegisterCodeFixesAsync(CodeFixContext context) => Task.CompletedTask; +} diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Directory.Build.props b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Directory.Build.props new file mode 100644 index 0000000..0a07120 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Directory.Build.props @@ -0,0 +1,16 @@ + + + + + + + + + + + + + diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Directory.Build.targets b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Directory.Build.targets new file mode 100644 index 0000000..292f891 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Directory.Build.targets @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Fixture.Generator.csproj b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Fixture.Generator.csproj new file mode 100644 index 0000000..030add7 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Fixture.Generator.csproj @@ -0,0 +1,47 @@ + + + + true + Fixture.Generator + Fixture.Generator + + + + + + + + + + + + + + + + + + + + + + + + <_PurviewSourceGeneratorFrameworkAssembly>..\..\..\..\src\SourceGeneratorFramework\bin\$(Configuration)\netstandard2.0\Purview.SourceGeneratorFramework.dll + <_PurviewSourceGeneratorFrameworkMergeTool>..\..\..\..\src\SourceGeneratorFramework.MergeTool\bin\$(Configuration)\net10.0\Purview.SourceGeneratorFramework.MergeTool.dll + <_PurviewSourceGeneratorFrameworkMergeToolProject>..\..\..\..\src\SourceGeneratorFramework.MergeTool\SourceGeneratorFramework.MergeTool.csproj + + + diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/FixtureComponentDiagnostics.cs b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/FixtureComponentDiagnostics.cs new file mode 100644 index 0000000..91e3c7b --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/FixtureComponentDiagnostics.cs @@ -0,0 +1,13 @@ +namespace Fixture.Generator; + +/// +/// Internal diagnostic identity shared with the companion code-fix component through +/// InternalsVisibleTo, making the code-fix assembly a genuine runtime dependency of the +/// generator assembly. +/// +static class FixtureComponentDiagnostics +{ + // Deliberately not a const: a const would be inlined into the code-fix assembly and the + // component -> component assembly reference would vanish from the metadata. + internal static readonly string DiagnosticId = "FIX0001"; +} diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/FixtureGenerator.cs b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/FixtureGenerator.cs new file mode 100644 index 0000000..ca7e9ad --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/FixtureGenerator.cs @@ -0,0 +1,40 @@ +using Microsoft.CodeAnalysis; +using Purview.SourceGeneratorFramework.Helpers; + +namespace Fixture.Generator; + +/// +/// Minimal incremental generator used to give the fixture component a real analyzer artifact. It +/// uses framework helpers so the unmerged component carries an assembly reference to +/// Purview.SourceGeneratorFramework, exactly like a real framework-based component. +/// +[Generator(LanguageNames.CSharp)] +public sealed class FixtureGenerator : IIncrementalGenerator +{ + /// + public void Initialize(IncrementalGeneratorInitializationContext context) + { + var disabled = IncrementalPipeline.IsDisabledValueProvider(context, "Fixture_Disable"); + + context.RegisterSourceOutput( + disabled, + static (output, isDisabled) => + { + if (isDisabled) + return; + + output.AddSource( + "Fixture.Generator.Marker.g.cs", + "// \n" + + "namespace Fixture.Generator.Generated;\n\n" + + "internal static class Marker\n" + + "{\n" + + "\tinternal const string DiagnosticId = \"" + + FixtureComponentDiagnostics.DiagnosticId + + "\";\n" + + "}\n" + ); + } + ); + } +} diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Sdk/build/Purview.SourceGeneratorFramework.Fixture.Generator.props b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Sdk/build/Purview.SourceGeneratorFramework.Fixture.Generator.props new file mode 100644 index 0000000..2f44162 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Sdk/build/Purview.SourceGeneratorFramework.Fixture.Generator.props @@ -0,0 +1,12 @@ + + + + + When true, the Fixture.Generator source generator is disabled. + + + diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Sdk/buildTransitive/Purview.SourceGeneratorFramework.Fixture.Generator.targets b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Sdk/buildTransitive/Purview.SourceGeneratorFramework.Fixture.Generator.targets new file mode 100644 index 0000000..09f3708 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.Generator/Sdk/buildTransitive/Purview.SourceGeneratorFramework.Fixture.Generator.targets @@ -0,0 +1,12 @@ + + + + + Property that an in-repo ProjectReference consumer must be able to read. + + + diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.ProjectReferenceConsumer/ConsumerType.cs b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.ProjectReferenceConsumer/ConsumerType.cs new file mode 100644 index 0000000..1766514 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.ProjectReferenceConsumer/ConsumerType.cs @@ -0,0 +1,10 @@ +namespace Fixture.ProjectReferenceConsumer; + +/// +/// Placeholder type so the fixture consumer has a compilation of its own. +/// +public sealed class ConsumerType +{ + /// Gets the fixture marker. + public static string Marker => "consumer"; +} diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.ProjectReferenceConsumer/Fixture.ProjectReferenceConsumer.csproj b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.ProjectReferenceConsumer/Fixture.ProjectReferenceConsumer.csproj new file mode 100644 index 0000000..eb9b745 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/Fixture.ProjectReferenceConsumer/Fixture.ProjectReferenceConsumer.csproj @@ -0,0 +1,18 @@ + + + + + netstandard2.0 + Fixture.ProjectReferenceConsumer + Fixture.ProjectReferenceConsumer + false + + + + + + diff --git a/src/tests/Fixtures/Purview.ComponentClosureFixture/VsAnalyzerSimulation.targets b/src/tests/Fixtures/Purview.ComponentClosureFixture/VsAnalyzerSimulation.targets new file mode 100644 index 0000000..eb009e0 --- /dev/null +++ b/src/tests/Fixtures/Purview.ComponentClosureFixture/VsAnalyzerSimulation.targets @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/src/tests/SourceGeneratorFramework.BuildIntegrationTests/AnalyzerClosureTests.cs b/src/tests/SourceGeneratorFramework.BuildIntegrationTests/AnalyzerClosureTests.cs new file mode 100644 index 0000000..c71f31b --- /dev/null +++ b/src/tests/SourceGeneratorFramework.BuildIntegrationTests/AnalyzerClosureTests.cs @@ -0,0 +1,391 @@ +using System.Diagnostics; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Text.Json; + +namespace Purview.SourceGeneratorFramework; + +/// +/// Builds the checked-in Purview.ComponentClosureFixture with the real MSBuild toolchain and +/// asserts the two invariants Defect A violated: +/// +/// a merged component's bin output is self-sufficient, so a dependent code-fix component can +/// resolve its component dependency when the IDE loads it from bin; and +/// the analyzer closure returned by GetSourceGeneratorAnalyzerFiles has no unresolvable +/// assembly reference. +/// +/// +[NotInParallel(nameof(AnalyzerClosureTests))] +public sealed class AnalyzerClosureTests +{ + const string Configuration = "Debug"; + const string FrameworkAssemblyFileName = "Purview.SourceGeneratorFramework.dll"; + + static readonly string RepoRoot = FindRepositoryRoot(); + static readonly string FixtureRoot = Path.Combine( + RepoRoot, + "src", + "tests", + "Fixtures", + "Purview.ComponentClosureFixture" + ); + + [Test] + public async Task MergedComponentBinOutputs_AreSelfSufficient(CancellationToken cancellationToken) + { + await BuildAsync(ProjectPath("Fixture.Consumer"), cancellationToken); + + List problems = new(); + foreach (var component in new[] { "Fixture.Generator", "Fixture.Generator.CodeFixers" }) + { + var binDirectory = BinDirectory(component); + if (!File.Exists(Path.Combine(binDirectory, FrameworkAssemblyFileName))) + { + problems.Add( + $"The bin output of '{component}' ('{binDirectory}') does not contain " + + $"'{FrameworkAssemblyFileName}'. A code-fix component loaded from its own bin " + + "cannot resolve its framework dependency at runtime." + ); + } + } + + await Assert.That(string.Join(Environment.NewLine, problems)).IsEmpty(); + } + + [Test] + public async Task ComponentClosure_ContainsNoUnresolvableAssemblyReferences(CancellationToken cancellationToken) + { + List problems = new(); + + foreach (var component in new[] { "Fixture.Generator", "Fixture.Generator.CodeFixers" }) + { + var closure = await ResolveAnalyzerClosureAsync(ProjectPath(component), cancellationToken); + if (closure.Count == 0) + { + problems.Add($"'{component}' returned an empty analyzer closure."); + continue; + } + + var availableAssemblies = closure + .Select(static path => Path.GetFileNameWithoutExtension(path)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + if (availableAssemblies.Contains(Path.GetFileNameWithoutExtension(FrameworkAssemblyFileName))) + { + problems.Add( + $"'{component}' returned the loose '{FrameworkAssemblyFileName}' in its analyzer " + + "closure. Merged components must be self-contained." + ); + } + + foreach (var file in closure) + { + foreach (var reference in ReadAssemblyReferenceNames(file)) + { + if (IsPermittedReference(reference) || availableAssemblies.Contains(reference)) + continue; + + problems.Add( + $"'{Path.GetFileName(file)}' references '{reference}', which is neither in the " + + $"returned analyzer closure ({string.Join(", ", availableAssemblies.Order(StringComparer.OrdinalIgnoreCase))}) " + + "nor a host-provided assembly." + ); + } + } + } + + await Assert.That(string.Join(Environment.NewLine, problems)).IsEmpty(); + } + + [Test] + public async Task ProjectReferenceConsumer_ReceivesBuildTransitiveCompilerVisibleProperties( + CancellationToken cancellationToken + ) + { + await BuildAsync(ProjectPath("Fixture.ProjectReferenceConsumer"), cancellationToken); + + var editorConfig = Path.Combine( + FixtureRoot, + "Fixture.ProjectReferenceConsumer", + "obj", + Configuration, + "netstandard2.0", + "Fixture.ProjectReferenceConsumer.GeneratedMSBuildEditorConfig.editorconfig" + ); + + await Assert.That(File.Exists(editorConfig)).IsTrue(); + await Assert + .That(await File.ReadAllTextAsync(editorConfig, cancellationToken)) + .Contains("build_property.Fixture_TransitiveProperty"); + } + + [Test] + public async Task GeneratorVisibleProperty_NotCompilerVisible_ReportsPsgf0003(CancellationToken cancellationToken) + { + var output = await RunAllowFailureAsync( + ProjectPath("Fixture.Generator"), + "-p:FixtureGeneratorVisiblePropertyName=Fixture_MissingInBuildAssets", + cancellationToken + ); + + await Assert.That(output).Contains("PSGF0003"); + } + + [Test] + public async Task GeneratorVisibleProperty_DeclaredInBuildAssets_Succeeds(CancellationToken cancellationToken) + { + var output = await RunAllowFailureAsync( + ProjectPath("Fixture.Generator"), + "-p:FixtureGeneratorVisiblePropertyName=Fixture_Disable", + cancellationToken + ); + + await Assert.That(output).DoesNotContain("PSGF0003"); + } + + [Test] + public async Task ConsumerAnalyzerSet_ExcludesUnmergedComponentAnalyzer(CancellationToken cancellationToken) + { + var probe = Path.Combine(FixtureRoot, "VsAnalyzerSimulation.targets"); + + var output = await RunAsync( + "dotnet", + [ + "msbuild", + ProjectPath("Fixture.Consumer"), + "-t:ResolveProjectReferences;ResolveSourceGeneratorProjectReferenceFiles;GetFixtureAnalyzerItems", + "-getTargetResult:GetFixtureAnalyzerItems", + $"-p:Configuration={Configuration}", + $"-p:CustomAfterMicrosoftCommonTargets={probe}", + "-nologo", + ], + Path.Combine(FixtureRoot, "Fixture.Consumer"), + cancellationToken + ); + + var start = output.IndexOf('{', StringComparison.Ordinal); + var end = output.LastIndexOf('}'); + if (start < 0 || end <= start) + { + throw new InvalidOperationException( + $"Unable to locate the target-result JSON in the msbuild output:{Environment.NewLine}{output}" + ); + } + + using var document = JsonDocument.Parse(output[start..(end + 1)]); + var items = document + .RootElement.GetProperty("TargetResults") + .GetProperty("GetFixtureAnalyzerItems") + .GetProperty("Items"); + + List analyzerPaths = new(); + foreach (var item in items.EnumerateArray()) + analyzerPaths.Add(item.GetProperty("Identity").GetString()!); + + List problems = new(); + if (!analyzerPaths.Any(static path => path.Contains("purview-merged", StringComparison.OrdinalIgnoreCase))) + problems.Add("The analyzer set does not contain the merged artifact."); + + var unmerged = analyzerPaths + .Where(static path => + path.Contains(@"Fixture.Generator\bin", StringComparison.OrdinalIgnoreCase) + && path.EndsWith("Fixture.Generator.dll", StringComparison.OrdinalIgnoreCase) + ) + .ToArray(); + if (unmerged.Length > 0) + { + problems.Add( + "The analyzer set still contains the unmerged component assembly, which the Visual " + + "Studio project system adds: " + + string.Join(", ", unmerged) + ); + } + + await Assert.That(string.Join(Environment.NewLine, problems)).IsEmpty(); + } + + static bool IsPermittedReference(string name) => + name.StartsWith("Microsoft.CodeAnalysis", StringComparison.Ordinal) + || name.StartsWith("System.Composition", StringComparison.Ordinal) + || name.StartsWith("System.", StringComparison.Ordinal) + || name.Equals("netstandard", StringComparison.Ordinal) + || name.Equals("mscorlib", StringComparison.Ordinal) + || name.Equals("System.Collections.Immutable", StringComparison.Ordinal) + || name.Equals("System.Memory", StringComparison.Ordinal) + || name.Equals("System.Threading.Tasks.Extensions", StringComparison.Ordinal); + + static List ReadAssemblyReferenceNames(string path) + { + using var stream = File.OpenRead(path); + using PEReader peReader = new(stream); + var metadata = peReader.GetMetadataReader(); + + List names = new(); + foreach (var handle in metadata.AssemblyReferences) + names.Add(metadata.GetString(metadata.GetAssemblyReference(handle).Name)); + + return names; + } + + static async Task> ResolveAnalyzerClosureAsync(string projectPath, CancellationToken cancellationToken) + { + var output = await RunAsync( + "dotnet", + [ + "msbuild", + projectPath, + "-t:GetSourceGeneratorAnalyzerFiles", + "-getTargetResult:GetSourceGeneratorAnalyzerFiles", + $"-p:Configuration={Configuration}", + "-nologo", + ], + Path.GetDirectoryName(projectPath)!, + cancellationToken + ); + + var start = output.IndexOf('{', StringComparison.Ordinal); + var end = output.LastIndexOf('}'); + if (start < 0 || end <= start) + { + throw new InvalidOperationException( + $"Unable to locate the target-result JSON in the msbuild output:{Environment.NewLine}{output}" + ); + } + + using var document = JsonDocument.Parse(output[start..(end + 1)]); + var items = document + .RootElement.GetProperty("TargetResults") + .GetProperty("GetSourceGeneratorAnalyzerFiles") + .GetProperty("Items"); + + List files = new(); + foreach (var item in items.EnumerateArray()) + files.Add(item.GetProperty("Identity").GetString()!); + + return files; + } + + static Task BuildAsync(string projectPath, CancellationToken cancellationToken) => + RunAsync( + "dotnet", + ["build", projectPath, "-c", Configuration, "-v:minimal"], + Path.GetDirectoryName(projectPath)!, + cancellationToken + ); + + static async Task<(int ExitCode, string Output, string Error)> RunCoreAsync( + string fileName, + string[] arguments, + string workingDirectory, + CancellationToken cancellationToken + ) + { + // The test assembly runs once per target framework in separate processes, and every test + // builds the same fixture projects. Serialize them across processes so concurrent MSBuild + // invocations cannot corrupt the fixture's obj/bin output. + using var buildLock = await AcquireFixtureBuildLockAsync(cancellationToken); + + ProcessStartInfo startInfo = new(fileName) + { + WorkingDirectory = workingDirectory, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + foreach (var argument in arguments) + startInfo.ArgumentList.Add(argument); + + startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + startInfo.Environment["DOTNET_SKIP_FIRST_TIME_EXPERIENCE"] = "1"; + + using var process = Process.Start(startInfo)!; + var standardOutput = process.StandardOutput.ReadToEndAsync(cancellationToken); + var standardError = process.StandardError.ReadToEndAsync(cancellationToken); + + await process.WaitForExitAsync(cancellationToken); + var output = await standardOutput; + var error = await standardError; + + return (process.ExitCode, output, error); + } + + static async Task RunAsync( + string fileName, + string[] arguments, + string workingDirectory, + CancellationToken cancellationToken + ) + { + var (exitCode, output, error) = await RunCoreAsync(fileName, arguments, workingDirectory, cancellationToken); + if (exitCode != 0) + { + throw new InvalidOperationException( + $"'{fileName} {string.Join(' ', arguments)}' failed with exit code {exitCode}." + + $"{Environment.NewLine}{output}{Environment.NewLine}{error}" + ); + } + + return output; + } + + static async Task RunAllowFailureAsync( + string projectPath, + string extraProperty, + CancellationToken cancellationToken + ) + { + var (_, output, error) = await RunCoreAsync( + "dotnet", + ["build", projectPath, "-c", Configuration, "-v:minimal", extraProperty], + Path.GetDirectoryName(projectPath)!, + cancellationToken + ); + return output + Environment.NewLine + error; + } + + static string ProjectPath(string projectName) => Path.Combine(FixtureRoot, projectName, projectName + ".csproj"); + + static string BinDirectory(string projectName) => + Path.Combine(FixtureRoot, projectName, "bin", Configuration, "netstandard2.0"); + + static async Task AcquireFixtureBuildLockAsync(CancellationToken cancellationToken) + { + var lockPath = Path.Combine(Path.GetTempPath(), "purview-analyzer-closure-fixture.lock"); + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + return new FileStream( + lockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None, + 1, + FileOptions.DeleteOnClose + ); + } + catch (IOException) + { + await Task.Delay(250, cancellationToken); + } + } + } + + static string FindRepositoryRoot() + { + DirectoryInfo? directory = new(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "src", "SourceGeneratorFramework.slnx"))) + return directory.FullName; + + directory = directory.Parent; + } + + throw new InvalidOperationException( + $"Unable to locate the repository root above '{AppContext.BaseDirectory}'." + ); + } +} diff --git a/src/tests/SourceGeneratorFramework.BuildIntegrationTests/SourceGeneratorFramework.BuildIntegrationTests.csproj b/src/tests/SourceGeneratorFramework.BuildIntegrationTests/SourceGeneratorFramework.BuildIntegrationTests.csproj new file mode 100644 index 0000000..b6e2367 --- /dev/null +++ b/src/tests/SourceGeneratorFramework.BuildIntegrationTests/SourceGeneratorFramework.BuildIntegrationTests.csproj @@ -0,0 +1,13 @@ + + + + + +