From fcb9226c7e4a1c1f38d7004411ed347201860551 Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 30 Jul 2026 13:48:36 +0200 Subject: [PATCH 1/6] Remove SourceGenerator.Foundations and multi-target analyzers for VS2022/VS2026 The two OPC UA source generators were built on SourceGenerator.Foundations, whose only job was getting the generator's dependency closure loaded inside the compiler. It did so with a process-wide, first-generator-wins AppDomain.AssemblyResolve hook backed by a non-concurrent Dictionary mutated from AssemblyLoad callbacks, which raced on parallel clean builds and failed with CS8784 ("Generator 'StackSourceGeneratorHoist' failed to initialize ... Could not load file or assembly 'SourceGenerator.Foundations.Contracts'"). The repository already solves that problem deterministically: the NuGet packages ship the closure under analyzers/dotnet/.../cs and Directory.Build.targets registers the same closure as Analyzer items for project-reference consumers. SourceGenerator.Foundations is therefore removed and both generators are plain Roslyn IIncrementalGenerator implementations again. Its implicit crash handling is replaced by an explicit SourceGenerator.Guard that reports MODELGEN003 / STACKGEN003 and rethrows cancellation. Analyzers and generators are now built once per supported Roslyn API version so they load on both Visual Studio 2022 and Visual Studio 2026. tools/RoslynVariants.props is the single source of truth for the versions; each analyzer has a primary project (Roslyn 5.0), a *.Roslyn4_8 sibling that glob-links the same sources (Roslyn 4.8), and - for the source generators - a *.Pack project that ships both under analyzers/dotnet/roslyn./cs. Opc.Ua.MigrationAnalyzer, .CodeFixer and .Generator get the same treatment. Both delivery paths discover the payload by globbing the generator's output directory, so adding a package to a generator's dependency tree needs no project file, nuspec or validation script change. Also fixed while migrating: - Microsoft.CodeAnalysis.CSharp declares an exact [version] range on Microsoft.CodeAnalysis.Common, so with central transitive pinning the pins must move in lockstep or restore fails with NU1109. The central pins now derive from the Visual Studio 2026 Roslyn version. - LanguageVersion.CSharp13 does not exist in Roslyn 4.8; the stack generator now uses CompilationOptions.IsCSharp13OrLater. - MigrationGeneratorTests created its generator driver without parse options, which silently matched under Roslyn 4.14 but throws "Inconsistent language versions" under 5.0. - build/OPCFoundation.Opc.Ua.SourceGeneration.props was packed under a name that did not match the package id, so NuGet never auto-imported it. It is now packed as build/$(PackageId).props. - The migration analyzer props injected Analyzer items by hand from analyzers/dotnet/cs. NuGet registers analyzers itself and selects the matching Roslyn folder, so the injection was obsolete and would now force the wrong variant to load alongside the selected one. - GeneratePackageOnBuild is disabled for the generators; packing happens in CI or on an explicit dotnet pack. Because the repository's own projects consume the Visual Studio 2026 variant, building this repository now requires a Roslyn 5.x host (the .NET 10 SDK or Visual Studio 2026), which is already the documented prerequisite. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ed53262-5cfa-429a-820e-bf6146a00d8b --- .azurepipelines/preview.yml | 2 +- .../validate-source-generator-packages.ps1 | 54 ++++++++---- Directory.Build.targets | 24 ++++++ Directory.Packages.props | 13 +-- UA.slnx | 7 ++ docs/DeveloperGuide.md | 15 +++- docs/migrate/2.0.x/packages.md | 1 - .../Generators/MigrationGeneratorTests.cs | 8 +- .../SourceGeneratorDiagnosticTests.cs | 21 +++++ .../StackGenerationAssemblyTests.cs | 15 ++-- .../StackGeneratorTests.cs | 3 +- .../ModelDependencyScannerTests.cs | 6 +- .../ModelGeneratorTests.cs | 21 ++--- .../NodesetEventRecordTests.cs | 3 +- .../NodesetIdentifierSidecarTests.cs | 3 +- .../NodesetMethodArgumentTests.cs | 3 +- .../TypeGeneratorTests.cs | 6 +- tools/MigrationAnalyzer.slnx | 7 ++ tools/MigrationAnalyzerVariant.targets | 26 ++++++ ...grationAnalyzer.CodeFixer.Roslyn4_8.csproj | 13 +++ ...igrationAnalyzer.CodeFixer.Sources.targets | 51 +++++++++++ .../Opc.Ua.MigrationAnalyzer.CodeFixer.csproj | 51 +++-------- ...grationAnalyzer.Generator.Roslyn4_8.csproj | 13 +++ ...igrationAnalyzer.Generator.Sources.targets | 25 ++++++ .../Opc.Ua.MigrationAnalyzer.Generator.csproj | 31 +++---- .../Opc.Ua.MigrationAnalyzer.Roslyn4_8.csproj | 13 +++ tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md | 34 +++++--- ...NetStandard.Opc.Ua.MigrationAnalyzer.props | 26 +++--- .../Opc.Ua.MigrationAnalyzer.Sources.targets | 35 ++++++++ .../Opc.Ua.MigrationAnalyzer.csproj | 78 ++++++++--------- .../Opc.Ua.MigrationAnalyzer.nuspec | 9 +- .../NugetREADME.md | 10 +++ .../Opc.Ua.SourceGeneration.Pack.csproj | 30 +++++++ .../Opc.Ua.SourceGeneration.Roslyn4_8.csproj | 13 +++ .../NugetREADME.md | 10 +++ .../Opc.Ua.SourceGeneration.Stack.Pack.csproj | 23 +++++ ...Ua.SourceGeneration.Stack.Roslyn4_8.csproj | 13 +++ ....Ua.SourceGeneration.Stack.Sources.targets | 28 +++++++ .../Opc.Ua.SourceGeneration.Stack.csproj | 71 +++------------- .../SourceGenerator.cs | 2 +- .../StackGeneration.cs | 9 +- .../StackSourceGenerator.cs | 27 ++---- .../CompilationOptions.cs | 8 ++ .../DataTypeCompilation.cs | 1 - .../ModelCompilation.cs | 7 +- .../ModelSourceGenerator.cs | 46 +++++----- .../Opc.Ua.SourceGeneration.Sources.targets | 22 +++++ .../Opc.Ua.SourceGeneration.csproj | 68 +++------------ .../SourceGenerator.cs | 2 +- .../SourceGeneratorHelpers.cs | 84 +++++++++++++++++++ .../SourceGeneratorPackaging.targets | 38 --------- .../SourceGeneratorTelemetry.cs | 51 ++--------- tools/RoslynVariants.props | 21 +++++ tools/SourceGeneration.slnx | 4 + tools/SourceGeneratorPack.targets | 74 ++++++++++++++++ tools/SourceGeneratorVariant.targets | 40 +++++++++ 56 files changed, 867 insertions(+), 452 deletions(-) create mode 100644 tools/MigrationAnalyzerVariant.targets create mode 100644 tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8.csproj create mode 100644 tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.Sources.targets create mode 100644 tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8.csproj create mode 100644 tools/Opc.Ua.MigrationAnalyzer.Generator/Opc.Ua.MigrationAnalyzer.Generator.Sources.targets create mode 100644 tools/Opc.Ua.MigrationAnalyzer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Roslyn4_8.csproj create mode 100644 tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.Sources.targets rename tools/{Opc.Ua.SourceGeneration => Opc.Ua.SourceGeneration.Pack}/NugetREADME.md (82%) create mode 100644 tools/Opc.Ua.SourceGeneration.Pack/Opc.Ua.SourceGeneration.Pack.csproj create mode 100644 tools/Opc.Ua.SourceGeneration.Roslyn4_8/Opc.Ua.SourceGeneration.Roslyn4_8.csproj rename tools/{Opc.Ua.SourceGeneration.Stack => Opc.Ua.SourceGeneration.Stack.Pack}/NugetREADME.md (78%) create mode 100644 tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj create mode 100644 tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_8/Opc.Ua.SourceGeneration.Stack.Roslyn4_8.csproj create mode 100644 tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.Sources.targets create mode 100644 tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.Sources.targets create mode 100644 tools/Opc.Ua.SourceGeneration/SourceGeneratorHelpers.cs delete mode 100644 tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets create mode 100644 tools/RoslynVariants.props create mode 100644 tools/SourceGeneratorPack.targets create mode 100644 tools/SourceGeneratorVariant.targets diff --git a/.azurepipelines/preview.yml b/.azurepipelines/preview.yml index 03f2ba5cfd..4c3dfb91fe 100644 --- a/.azurepipelines/preview.yml +++ b/.azurepipelines/preview.yml @@ -133,7 +133,7 @@ jobs: inputs: command: 'pack' # Scoped to nuget/ — the MigrationAnalyzer nuspec at tools/Opc.Ua.MigrationAnalyzer/ - # uses MSBuild $(...) substitutions ($(shimBin), $(repoRoot), $(analyzerDll)) + # uses MSBuild $(...) substitutions ($(shimBin), $(repoRoot), $(analyzerDllVS2026)) # that only `dotnet pack`'s _SetMigrationAnalyzerNuspecProperties target can # resolve. Standalone `nuget.exe pack` leaves them literal and crashes with # DirectoryNotFoundException on `D:\net472`. The MigrationAnalyzer package is diff --git a/.azurepipelines/validate-source-generator-packages.ps1 b/.azurepipelines/validate-source-generator-packages.ps1 index 7aa227db3b..b5a8bcefcd 100644 --- a/.azurepipelines/validate-source-generator-packages.ps1 +++ b/.azurepipelines/validate-source-generator-packages.ps1 @@ -91,30 +91,47 @@ function Test-PackageContents [string] $GeneratorAssembly ) - $analyzerPath = "analyzers/dotnet/cs/" + $analyzerRoot = "analyzers/dotnet/" $requiredAssemblies = @( $GeneratorAssembly, "Opc.Ua.SourceGeneration.Core.dll", - "Opc.Ua.Types.dll", - "SourceGenerator.Foundations.Contracts.dll", - "SourceGenerator.Foundations.Windows.dll" + "Opc.Ua.Types.dll" ) - foreach ($assembly in $requiredAssemblies) - { - Assert-Condition ( - $Package.Entries -contains "$analyzerPath$assembly" - ) "Package '$($Package.Id)' is missing '$analyzerPath$assembly'." - } - $dllEntries = @($Package.Entries | Where-Object { $_.EndsWith(".dll", [StringComparison]::OrdinalIgnoreCase) }) Assert-Condition ($dllEntries.Count -gt 0) "Package '$($Package.Id)' contains no assemblies." Assert-Condition ( @($dllEntries | Where-Object { -not $_.StartsWith( - $analyzerPath, + $analyzerRoot, [StringComparison]::OrdinalIgnoreCase) }).Count -eq 0 - ) "Package '$($Package.Id)' contains assemblies outside '$analyzerPath'." + ) "Package '$($Package.Id)' contains assemblies outside '$analyzerRoot'." + + # One folder per supported Roslyn API version; the .NET SDK loads the + # highest one its compiler supports. + $roslynFolders = @($dllEntries | + ForEach-Object { $_.Substring($analyzerRoot.Length).Split("/")[0] } | + Sort-Object -Unique) + Assert-Condition ( + $roslynFolders.Count -ge 2 + ) ("Package '$($Package.Id)' must ship one analyzer folder per supported Roslyn " + + "version; found: $($roslynFolders -join ', ').") + Assert-Condition ( + @($roslynFolders | Where-Object { $_ -notmatch "^roslyn[0-9]+\.[0-9]+$" }).Count -eq 0 + ) ("Package '$($Package.Id)' analyzer folders must be named 'roslyn.'; " + + "found: $($roslynFolders -join ', ').") + + foreach ($roslynFolder in $roslynFolders) + { + $analyzerPath = "$analyzerRoot$roslynFolder/cs/" + foreach ($assembly in $requiredAssemblies) + { + Assert-Condition ( + $Package.Entries -contains "$analyzerPath$assembly" + ) "Package '$($Package.Id)' is missing '$analyzerPath$assembly'." + } + } + Assert-Condition ( @($dllEntries | Where-Object { [IO.Path]::GetFileName($_).StartsWith( @@ -122,6 +139,13 @@ function Test-PackageContents [StringComparison]::OrdinalIgnoreCase) }).Count -eq 0 ) "Package '$($Package.Id)' must not ship Microsoft.CodeAnalysis host assemblies." + Assert-Condition ( + @($dllEntries | Where-Object { + [IO.Path]::GetFileName($_).StartsWith( + "SourceGenerator.Foundations", + [StringComparison]::OrdinalIgnoreCase) + }).Count -eq 0 + ) "Package '$($Package.Id)' must not ship SourceGenerator.Foundations assemblies." Assert-Condition ( $Package.Dependencies.Count -eq 0 ) "Package '$($Package.Id)' must carry its analyzer runtime closure privately." @@ -257,9 +281,9 @@ $PackageDirectory = $resolvedPackageDirectory $repoRoot = Split-Path $PSScriptRoot -Parent $validationRoot = Join-Path (Join-Path $repoRoot "artifacts") "source-generator-consumer" Test-ConfigurationPackageIds ( - Join-Path $repoRoot "tools\Opc.Ua.SourceGeneration\Opc.Ua.SourceGeneration.csproj") + Join-Path $repoRoot "tools\Opc.Ua.SourceGeneration.Pack\Opc.Ua.SourceGeneration.Pack.csproj") Test-ConfigurationPackageIds ( - Join-Path $repoRoot "tools\Opc.Ua.SourceGeneration.Stack\Opc.Ua.SourceGeneration.Stack.csproj") + Join-Path $repoRoot "tools\Opc.Ua.SourceGeneration.Stack.Pack\Opc.Ua.SourceGeneration.Stack.Pack.csproj") $modelPackage = Get-PackageInfo "OPCFoundation.NetStandard.Opc.Ua.SourceGeneration" $stackPackage = Get-PackageInfo "OPCFoundation.NetStandard.Opc.Ua.SourceGeneration.Stack" diff --git a/Directory.Build.targets b/Directory.Build.targets index c0d9129dc0..f84837c6a8 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -77,4 +77,28 @@ Condition="$([System.String]::Copy('%(Analyzer.Identity)').Contains('Microsoft.Gen.Logging'))" /> + + + + <_SourceGeneratorAnalyzer Include="@(Analyzer)" + Condition="'%(Analyzer.MSBuildSourceProjectFile)' != '' AND + $([System.String]::Copy('%(Analyzer.Filename)').StartsWith('Opc.Ua.SourceGeneration'))" /> + <_SourceGeneratorAnalyzerDependency + Include="%(_SourceGeneratorAnalyzer.RootDir)%(_SourceGeneratorAnalyzer.Directory)*.dll" + Exclude="@(_SourceGeneratorAnalyzer);%(_SourceGeneratorAnalyzer.RootDir)%(_SourceGeneratorAnalyzer.Directory)Microsoft.CodeAnalysis*.dll" /> + + + diff --git a/Directory.Packages.props b/Directory.Packages.props index 254bbc830d..d65df08e14 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,4 +1,8 @@ + + true + + netstandard2.0 + false + true + false + + + + + + + + diff --git a/tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8.csproj b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8.csproj new file mode 100644 index 0000000000..1b463ec91d --- /dev/null +++ b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8.csproj @@ -0,0 +1,13 @@ + + + + + + $(RoslynApiVersionVS2022) + + + diff --git a/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.Sources.targets b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.Sources.targets new file mode 100644 index 0000000000..4868ba3de1 --- /dev/null +++ b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.Sources.targets @@ -0,0 +1,51 @@ + + + + $(AssemblyPrefix).MigrationAnalyzer.CodeFixer + Opc.Ua.MigrationAnalyzer.CodeFixer + OPC UA .NET Standard migration code-fix providers (1.5.378 to 2.0). Companion to Opc.Ua.MigrationAnalyzer analyzers. + $(MSBuildThisFileDirectory) + $(NoWarn);RS1007;RS1038;RS2008 + + + + + + + + + + + + + + + + + + + + + + diff --git a/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.csproj b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.csproj index 44bd41af1c..37d14622e4 100644 --- a/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.csproj +++ b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.csproj @@ -1,48 +1,17 @@ + + + - netstandard2.0 - false - true - $(AssemblyPrefix).MigrationAnalyzer.CodeFixer - Opc.Ua.MigrationAnalyzer.CodeFixer - OPC UA .NET Standard migration code-fix providers (1.5.378 to 2.0). Companion to Opc.Ua.MigrationAnalyzer analyzers. - - $(NoWarn);RS1007;RS1038;RS2008 + $(RoslynApiVersionVS2026) false - - - - - - - - - - - - - - - - - - - + - diff --git a/tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8.csproj b/tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8.csproj new file mode 100644 index 0000000000..11d2617212 --- /dev/null +++ b/tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8.csproj @@ -0,0 +1,13 @@ + + + + + + $(RoslynApiVersionVS2022) + + + diff --git a/tools/Opc.Ua.MigrationAnalyzer.Generator/Opc.Ua.MigrationAnalyzer.Generator.Sources.targets b/tools/Opc.Ua.MigrationAnalyzer.Generator/Opc.Ua.MigrationAnalyzer.Generator.Sources.targets new file mode 100644 index 0000000000..0456de770c --- /dev/null +++ b/tools/Opc.Ua.MigrationAnalyzer.Generator/Opc.Ua.MigrationAnalyzer.Generator.Sources.targets @@ -0,0 +1,25 @@ + + + + $(AssemblyPrefix).MigrationAnalyzer.Generator + Opc.Ua.MigrationAnalyzer.Generator + OPC UA .NET Standard migration source generator (1.5.378 to 2.0). Emits internal [Obsolete] shim subclasses of List<T> for any <Type>Collection name (including model-compiled user types) the consumer references but that 2.0 has removed, so the consumer code keeps compiling while UA0002 guides the eventual rewrite to List<T> / ArrayOf<T>. + $(MSBuildThisFileDirectory) + true + + $(NoWarn);RS1007;RS1038;RS2008 + + + + + + + + + diff --git a/tools/Opc.Ua.MigrationAnalyzer.Generator/Opc.Ua.MigrationAnalyzer.Generator.csproj b/tools/Opc.Ua.MigrationAnalyzer.Generator/Opc.Ua.MigrationAnalyzer.Generator.csproj index 7c13ae7632..fc3e2ded49 100644 --- a/tools/Opc.Ua.MigrationAnalyzer.Generator/Opc.Ua.MigrationAnalyzer.Generator.csproj +++ b/tools/Opc.Ua.MigrationAnalyzer.Generator/Opc.Ua.MigrationAnalyzer.Generator.csproj @@ -1,27 +1,16 @@ + + + - netstandard2.0 - false - true - true - $(AssemblyPrefix).MigrationAnalyzer.Generator - Opc.Ua.MigrationAnalyzer.Generator - OPC UA .NET Standard migration source generator (1.5.378 to 2.0). Emits internal [Obsolete] shim subclasses of List<T> for any <Type>Collection name (including model-compiled user types) the consumer references but that 2.0 has removed, so the consumer code keeps compiling while UA0002 guides the eventual rewrite to List<T> / ArrayOf<T>. - - $(NoWarn);RS1007;RS1038;RS2008 + $(RoslynApiVersionVS2026) false - - - - - - - - - - + diff --git a/tools/Opc.Ua.MigrationAnalyzer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Roslyn4_8.csproj b/tools/Opc.Ua.MigrationAnalyzer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Roslyn4_8.csproj new file mode 100644 index 0000000000..8f90faf94b --- /dev/null +++ b/tools/Opc.Ua.MigrationAnalyzer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Roslyn4_8.csproj @@ -0,0 +1,13 @@ + + + + + + $(RoslynApiVersionVS2022) + + + diff --git a/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md b/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md index c3886bef10..4ac8a11e92 100644 --- a/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md +++ b/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md @@ -137,21 +137,31 @@ whole block once the MigrationAnalyzer package is removed. ## Packaging note -The package ships **two analyzer DLLs** under `analyzers/dotnet/cs/`: - -- `Opc.Ua.MigrationAnalyzer.dll` — the analyzer assembly. Targets `Microsoft.CodeAnalysis 4.x` - (the stable analyzer API) and references **only** `Microsoft.CodeAnalysis.CSharp` so it - loads cleanly in csc.exe's analyzer host (which ships only `Microsoft.CodeAnalysis.dll` - + `CSharp.dll`, not `Workspaces`). All `DiagnosticAnalyzer` types live here. +The package ships **three Roslyn component DLLs** per supported Roslyn API version, +under `analyzers/dotnet/roslyn./cs/`. The .NET SDK loads the highest +folder its compiler supports: + +| Roslyn API | Minimum host | +| --- | --- | +| 4.8 | Visual Studio 2022 17.8 / .NET 8 SDK | +| 5.0 | Visual Studio 2026 18.0 / .NET 10 SDK | + +- `Opc.Ua.MigrationAnalyzer.dll` — the analyzer assembly. References **only** + `Microsoft.CodeAnalysis.CSharp` so it loads cleanly in csc.exe's analyzer host + (which ships only `Microsoft.CodeAnalysis.dll` + `CSharp.dll`, not `Workspaces`). + All `DiagnosticAnalyzer` types live here. - `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` — the code-fix assembly. References `Microsoft.CodeAnalysis.CSharp.Workspaces` and hosts all `CodeFixProvider` types. Loaded only by Workspaces-aware hosts (Visual Studio / `dotnet format`). - -This split is necessary because shipping a single DLL that references `Workspaces` -silently fails to load in csc.exe at command-line build time — csc loads the assembly -but JIT-resolution of `Workspaces` types fails (DLL not in bincore), and the analyzer -host swallows the load failure, producing zero diagnostics. Splitting keeps the -analyzer host happy while preserving full IDE/`dotnet format` code-fix functionality. +- `Opc.Ua.MigrationAnalyzer.Generator.dll` — the source generator that emits the + `Collection` shims. + +The analyzer / code-fix split is necessary because shipping a single DLL that +references `Workspaces` silently fails to load in csc.exe at command-line build time — +csc loads the assembly but JIT-resolution of `Workspaces` types fails (DLL not in +bincore), and the analyzer host swallows the load failure, producing zero diagnostics. +Splitting keeps the analyzer host happy while preserving full IDE/`dotnet format` +code-fix functionality. `RS1038` (suggesting separation) is the Roslyn rule that recommends this layout; it is satisfied implicitly by the two-DLL design. diff --git a/tools/Opc.Ua.MigrationAnalyzer/OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props b/tools/Opc.Ua.MigrationAnalyzer/OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props index 6f51f036f5..d13a983288 100644 --- a/tools/Opc.Ua.MigrationAnalyzer/OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props +++ b/tools/Opc.Ua.MigrationAnalyzer/OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props @@ -1,19 +1,15 @@ - - - - - diff --git a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.Sources.targets b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.Sources.targets new file mode 100644 index 0000000000..ba12644fb1 --- /dev/null +++ b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.Sources.targets @@ -0,0 +1,35 @@ + + + + $(AssemblyPrefix).MigrationAnalyzer + Opc.Ua.MigrationAnalyzer + OPC UA .NET Standard migration analyzers and code fixers (1.5.378 to 2.0). + $(MSBuildThisFileDirectory) + true + + $(NoWarn);RS1007;RS1038 + + + + + + + + + + + + + + + diff --git a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj index 63e778e2d8..43dbe6a7b9 100644 --- a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj +++ b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj @@ -1,16 +1,14 @@ + + + - netstandard2.0 - false - true - $(AssemblyPrefix).MigrationAnalyzer - Opc.Ua.MigrationAnalyzer - OPC UA .NET Standard migration analyzers and code fixers (1.5.378 to 2.0). - - $(NoWarn);RS1007;RS1038 + $(RoslynApiVersionVS2026) - - - - - - - - - - - - - + + + + + + + + @@ -109,11 +99,7 @@ Targets="Build" Properties="Configuration=$(Configuration)" RemoveProperties="TargetFramework" /> - - @@ -122,7 +108,17 @@ inside GetBuildVersion). Static evaluation in a PropertyGroup is too early. --> - version=$(PackageVersion);configuration=$(Configuration);repoRoot=$(MSBuildThisFileDirectory)..\..;analyzerDll=$(MSBuildThisFileDirectory)bin\$(Configuration)\netstandard2.0\Opc.Ua.MigrationAnalyzer.dll;codeFixesDll=$(MSBuildThisFileDirectory)..\Opc.Ua.MigrationAnalyzer.CodeFixer\bin\$(Configuration)\netstandard2.0\Opc.Ua.MigrationAnalyzer.CodeFixer.dll;generatorDll=$(MSBuildThisFileDirectory)..\Opc.Ua.MigrationAnalyzer.Generator\bin\$(Configuration)\netstandard2.0\Opc.Ua.MigrationAnalyzer.Generator.dll;shimBin=$(MSBuildThisFileDirectory)..\Opc.Ua.MigrationAnalyzer.Core\bin\$(Configuration);readme=$(MSBuildThisFileDirectory)NugetREADME.md;propsFile=$(MSBuildThisFileDirectory)OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props + <_MigrationAnalyzerToolsDir>$(MSBuildThisFileDirectory).. + <_MigrationAnalyzerBin50>$(MSBuildThisFileDirectory)bin\$(Configuration)\netstandard2.0 + <_MigrationAnalyzerBin48>$(_MigrationAnalyzerToolsDir)\Opc.Ua.MigrationAnalyzer.Roslyn4_8\bin\$(Configuration)\netstandard2.0 + <_MigrationCodeFixerBin50>$(_MigrationAnalyzerToolsDir)\Opc.Ua.MigrationAnalyzer.CodeFixer\bin\$(Configuration)\netstandard2.0 + <_MigrationCodeFixerBin48>$(_MigrationAnalyzerToolsDir)\Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8\bin\$(Configuration)\netstandard2.0 + <_MigrationGeneratorBin50>$(_MigrationAnalyzerToolsDir)\Opc.Ua.MigrationAnalyzer.Generator\bin\$(Configuration)\netstandard2.0 + <_MigrationGeneratorBin48>$(_MigrationAnalyzerToolsDir)\Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8\bin\$(Configuration)\netstandard2.0 + + + version=$(PackageVersion);configuration=$(Configuration);repoRoot=$(MSBuildThisFileDirectory)..\..;roslynFolderVS2022=$(RoslynAnalyzerFolderVS2022);roslynFolderVS2026=$(RoslynAnalyzerFolderVS2026);analyzerDllVS2026=$(_MigrationAnalyzerBin50)\Opc.Ua.MigrationAnalyzer.dll;analyzerDllVS2022=$(_MigrationAnalyzerBin48)\Opc.Ua.MigrationAnalyzer.dll;codeFixesDllVS2026=$(_MigrationCodeFixerBin50)\Opc.Ua.MigrationAnalyzer.CodeFixer.dll;codeFixesDllVS2022=$(_MigrationCodeFixerBin48)\Opc.Ua.MigrationAnalyzer.CodeFixer.dll;generatorDllVS2026=$(_MigrationGeneratorBin50)\Opc.Ua.MigrationAnalyzer.Generator.dll;generatorDllVS2022=$(_MigrationGeneratorBin48)\Opc.Ua.MigrationAnalyzer.Generator.dll;shimBin=$(MSBuildThisFileDirectory)..\Opc.Ua.MigrationAnalyzer.Core\bin\$(Configuration);readme=$(MSBuildThisFileDirectory)NugetREADME.md;propsFile=$(MSBuildThisFileDirectory)OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer.props + diff --git a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.nuspec b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.nuspec index c41dc7ffa1..f291f5994c 100644 --- a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.nuspec +++ b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.nuspec @@ -68,9 +68,12 @@ - - - + + + + + + diff --git a/tools/Opc.Ua.SourceGeneration/NugetREADME.md b/tools/Opc.Ua.SourceGeneration.Pack/NugetREADME.md similarity index 82% rename from tools/Opc.Ua.SourceGeneration/NugetREADME.md rename to tools/Opc.Ua.SourceGeneration.Pack/NugetREADME.md index c2ab0103c0..a30259c2db 100644 --- a/tools/Opc.Ua.SourceGeneration/NugetREADME.md +++ b/tools/Opc.Ua.SourceGeneration.Pack/NugetREADME.md @@ -14,6 +14,16 @@ the generator to emit C# proxies at build time. The generator participates in the standard `dotnet build` pipeline; no separate tool invocation is required. +## Supported hosts + +The package ships one build of the generator per Roslyn API version; +the .NET SDK automatically loads the one matching its compiler. + +| Roslyn API | Minimum host | +| --- | --- | +| 4.8 | Visual Studio 2022 17.8 / .NET 8 SDK | +| 5.0 | Visual Studio 2026 18.0 / .NET 10 SDK | + ## Getting started Reference the generator as an **analyzer** (no runtime dependency): diff --git a/tools/Opc.Ua.SourceGeneration.Pack/Opc.Ua.SourceGeneration.Pack.csproj b/tools/Opc.Ua.SourceGeneration.Pack/Opc.Ua.SourceGeneration.Pack.csproj new file mode 100644 index 0000000000..a5cb5f9752 --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration.Pack/Opc.Ua.SourceGeneration.Pack.csproj @@ -0,0 +1,30 @@ + + + + + + netstandard2.0 + $(PackagePrefix).Opc.Ua.SourceGeneration + $(AssemblyPrefix).SourceGeneration.Pack + Opc.Ua.SourceGeneration + OPC UA model source generator + + + + + + + + + + + diff --git a/tools/Opc.Ua.SourceGeneration.Roslyn4_8/Opc.Ua.SourceGeneration.Roslyn4_8.csproj b/tools/Opc.Ua.SourceGeneration.Roslyn4_8/Opc.Ua.SourceGeneration.Roslyn4_8.csproj new file mode 100644 index 0000000000..96bcb7a8f3 --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration.Roslyn4_8/Opc.Ua.SourceGeneration.Roslyn4_8.csproj @@ -0,0 +1,13 @@ + + + + + + $(RoslynApiVersionVS2022) + + + diff --git a/tools/Opc.Ua.SourceGeneration.Stack/NugetREADME.md b/tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md similarity index 78% rename from tools/Opc.Ua.SourceGeneration.Stack/NugetREADME.md rename to tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md index 0a64455d34..0dcceb8069 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack/NugetREADME.md +++ b/tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md @@ -19,6 +19,16 @@ just reference `Opc.Ua.Core` and `Opc.Ua.Types`). `netstandard2.0` (Roslyn analyzer host TFM). +## Supported hosts + +The package ships one build of the generator per Roslyn API version; +the .NET SDK automatically loads the one matching its compiler. + +| Roslyn API | Minimum host | +| --- | --- | +| 4.8 | Visual Studio 2022 17.8 / .NET 8 SDK | +| 5.0 | Visual Studio 2026 18.0 / .NET 10 SDK | + ## Additional documentation See the [main repository README](https://github.com/OPCFoundation/UA-.NETStandard) diff --git a/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj b/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj new file mode 100644 index 0000000000..c034de68b2 --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj @@ -0,0 +1,23 @@ + + + + + + netstandard2.0 + $(PackagePrefix).Opc.Ua.SourceGeneration.Stack + $(AssemblyPrefix).SourceGeneration.Stack.Pack + Opc.Ua.SourceGeneration + OPC UA stack source generator + + + + + + + diff --git a/tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_8/Opc.Ua.SourceGeneration.Stack.Roslyn4_8.csproj b/tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_8/Opc.Ua.SourceGeneration.Stack.Roslyn4_8.csproj new file mode 100644 index 0000000000..5cf38e1964 --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_8/Opc.Ua.SourceGeneration.Stack.Roslyn4_8.csproj @@ -0,0 +1,13 @@ + + + + + + $(RoslynApiVersionVS2022) + + + diff --git a/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.Sources.targets b/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.Sources.targets new file mode 100644 index 0000000000..bee4f5f867 --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.Sources.targets @@ -0,0 +1,28 @@ + + + + $(AssemblyPrefix).SourceGeneration.Stack + $(MSBuildThisFileDirectory) + + + + + + + + + + + + + + + + diff --git a/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj b/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj index bd7ef460a7..a51353b631 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj +++ b/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj @@ -1,63 +1,16 @@ + + + - netstandard2.0 - $(AssemblyPrefix).SourceGeneration.Stack - Opc.Ua.SourceGeneration - OPC UA source generation Library - - $(NoWarn);CS8002 + $(RoslynApiVersionVS2026) - - - $(PackagePrefix).Opc.Ua.SourceGeneration.Stack - true - true - NugetREADME.md - MIT - - true - true - - - $(PackageId).Debug - - - true - true - false - - - - - - - - - - - - - all - Analyzer - - - - - - - - all - - - - - - - - - - - + diff --git a/tools/Opc.Ua.SourceGeneration.Stack/SourceGenerator.cs b/tools/Opc.Ua.SourceGeneration.Stack/SourceGenerator.cs index 1384242879..2898538377 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack/SourceGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Stack/SourceGenerator.cs @@ -32,7 +32,7 @@ namespace Opc.Ua.SourceGeneration { - internal static class SourceGenerator + internal static partial class SourceGenerator { /// /// The namespace prefix for generated code diff --git a/tools/Opc.Ua.SourceGeneration.Stack/StackGeneration.cs b/tools/Opc.Ua.SourceGeneration.Stack/StackGeneration.cs index bff8f178db..6caa70b95c 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack/StackGeneration.cs +++ b/tools/Opc.Ua.SourceGeneration.Stack/StackGeneration.cs @@ -33,8 +33,6 @@ using System.Threading; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; -using ILogger = SGF.Diagnostics.ILogger; -using SourceProductionContext = SGF.SgfSourceProductionContext; namespace Opc.Ua.SourceGeneration { @@ -49,13 +47,12 @@ internal sealed class StackGeneration public StackGeneration( SourceProductionContext context, CompilationOptions compilationOptions, - StackGenerationOptions options, - ILogger logger) + StackGenerationOptions options) { m_context = context; m_options = options; m_compilationOptions = compilationOptions; - m_telemetry = SourceGeneratorTelemetry.Create(logger, m_context); + m_telemetry = SourceGeneratorTelemetry.Create(m_context); } /// @@ -126,7 +123,7 @@ public void Emit(CancellationToken cancellationToken) /// private bool CheckCompilationOptions(out StackGenerationType type) { - if (m_compilationOptions.LanguageVersion < LanguageVersion.CSharp13) + if (!m_compilationOptions.IsCSharp13OrLater) { type = StackGenerationType.None; m_context.ReportDiagnostic( diff --git a/tools/Opc.Ua.SourceGeneration.Stack/StackSourceGenerator.cs b/tools/Opc.Ua.SourceGeneration.Stack/StackSourceGenerator.cs index a52ab4f3aa..7f7063e69f 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack/StackSourceGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Stack/StackSourceGenerator.cs @@ -28,30 +28,20 @@ * ======================================================================*/ using Microsoft.CodeAnalysis; -using IIncrementalGenerator = SGF.IncrementalGenerator; -using IncrementalGeneratorAttribute = SGF.IncrementalGeneratorAttribute; -using IncrementalGeneratorInitializationContext = SGF.SgfInitializationContext; namespace Opc.Ua.SourceGeneration { /// /// Generates server and client models using the model generator library /// - [IncrementalGenerator] - public class StackSourceGenerator : IIncrementalGenerator + [Generator(LanguageNames.CSharp)] + public sealed class StackSourceGenerator : IIncrementalGenerator { /// - public StackSourceGenerator() - : base(SourceGenerator.Name) + public void Initialize(IncrementalGeneratorInitializationContext context) { - } + SourceGenerator.AttachDebuggerIfRequested(); - /// - public override void OnInitialize(IncrementalGeneratorInitializationContext context) - { -#if DEBUGX - AttachDebugger(); -#endif // Collect options IncrementalValueProvider options = context.AnalyzerConfigOptionsProvider @@ -62,11 +52,12 @@ public override void OnInitialize(IncrementalGeneratorInitializationContext cont context.RegisterSourceOutput( settings.Combine(options), - (context, combination) => new StackGeneration( + static (context, combination) => SourceGenerator.Guard( context, - combination.Left, - combination.Right, - Logger).Emit(context.CancellationToken)); + () => new StackGeneration( + context, + combination.Left, + combination.Right).Emit(context.CancellationToken))); } } } diff --git a/tools/Opc.Ua.SourceGeneration/CompilationOptions.cs b/tools/Opc.Ua.SourceGeneration/CompilationOptions.cs index 2e6266978e..b841453922 100644 --- a/tools/Opc.Ua.SourceGeneration/CompilationOptions.cs +++ b/tools/Opc.Ua.SourceGeneration/CompilationOptions.cs @@ -42,6 +42,14 @@ internal record struct CompilationOptions( Platform Platform, OutputKind OutputKind) { + /// + /// True when the compilation targets C# 13 or newer. The underlying + /// value is compared because the LanguageVersion.CSharp13 member + /// only exists in Roslyn 4.12 and later, while the generator is also + /// built against the Visual Studio 2022 baseline. + /// + public readonly bool IsCSharp13OrLater => (int)LanguageVersion >= 1300; + /// /// Create options from a compilation. /// diff --git a/tools/Opc.Ua.SourceGeneration/DataTypeCompilation.cs b/tools/Opc.Ua.SourceGeneration/DataTypeCompilation.cs index e6981a5df5..52107df508 100644 --- a/tools/Opc.Ua.SourceGeneration/DataTypeCompilation.cs +++ b/tools/Opc.Ua.SourceGeneration/DataTypeCompilation.cs @@ -36,7 +36,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; -using SourceProductionContext = SGF.SgfSourceProductionContext; namespace Opc.Ua.SourceGeneration { diff --git a/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs b/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs index aaf6d6c891..c844d9a7c5 100644 --- a/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs +++ b/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs @@ -36,8 +36,6 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using Opc.Ua.SourceGeneration.Dependency; -using ILogger = SGF.Diagnostics.ILogger; -using SourceProductionContext = SGF.SgfSourceProductionContext; namespace Opc.Ua.SourceGeneration { @@ -63,8 +61,7 @@ public ModelCompilation( CompilationOptions compilationOptions, ImmutableArray referencedModels, ImmutableArray nodeManagerBindings, - ImmutableHashSet availableStateTypeNames, - ILogger logger) + ImmutableHashSet availableStateTypeNames) { m_context = context; m_input = inputFiles; @@ -75,7 +72,7 @@ public ModelCompilation( m_nodeManagerBindings = nodeManagerBindings; m_referencedModels = referencedModels; m_availableStateTypeNames = availableStateTypeNames; - m_telemetry = SourceGeneratorTelemetry.Create(logger, m_context); + m_telemetry = SourceGeneratorTelemetry.Create(m_context); } /// diff --git a/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs b/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs index fed1637afd..8dc806b7fa 100644 --- a/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs @@ -29,30 +29,19 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; -using IIncrementalGenerator = SGF.IncrementalGenerator; -using IncrementalGeneratorAttribute = SGF.IncrementalGeneratorAttribute; -using IncrementalGeneratorInitializationContext = SGF.SgfInitializationContext; namespace Opc.Ua.SourceGeneration { /// /// Generates server and client models using the model generator library /// - [IncrementalGenerator] - public class ModelSourceGenerator : IIncrementalGenerator + [Generator(LanguageNames.CSharp)] + public sealed class ModelSourceGenerator : IIncrementalGenerator { /// - public ModelSourceGenerator() - : base(SourceGenerator.Name) + public void Initialize(IncrementalGeneratorInitializationContext context) { - } - - /// - public override void OnInitialize(IncrementalGeneratorInitializationContext context) - { -#if DEBUGX - AttachDebugger(); -#endif + SourceGenerator.AttachDebuggerIfRequested(); IncrementalValueProvider> inputFiles = context.AdditionalTextsProvider .Where(f => f.IsDesignOrNodeset2File()) @@ -155,17 +144,18 @@ public override void OnInitialize(IncrementalGeneratorInitializationContext cont context.RegisterSourceOutput( modelCompilationInput, - (context, input) => new ModelCompilation( + static (context, input) => SourceGenerator.Guard( context, - input.InputFiles, - input.CsvFiles, - input.IdentifierFiles, - input.Options, - input.CompilationOptions, - input.ReferencedModels, - input.NodeManagerBindings, - input.AvailableStateTypeNames, - Logger).Emit(context.CancellationToken)); + () => new ModelCompilation( + context, + input.InputFiles, + input.CsvFiles, + input.IdentifierFiles, + input.Options, + input.CompilationOptions, + input.ReferencedModels, + input.NodeManagerBindings, + input.AvailableStateTypeNames).Emit(context.CancellationToken))); IncrementalValueProvider publicDataTypeExtensions = context.AnalyzerConfigOptionsProvider @@ -179,8 +169,10 @@ public override void OnInitialize(IncrementalGeneratorInitializationContext cont .Where(static m => m is not null) .Collect() .Combine(publicDataTypeExtensions), - static (spc, pair) => DataTypeCompilation.EmitBatch( - spc, pair.Left, pair.Right)); + static (spc, pair) => SourceGenerator.Guard( + spc, + () => DataTypeCompilation.EmitBatch( + spc, pair.Left, pair.Right))); } private readonly record struct ModelCompilationInput( diff --git a/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.Sources.targets b/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.Sources.targets new file mode 100644 index 0000000000..fb15bc8732 --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.Sources.targets @@ -0,0 +1,22 @@ + + + + $(AssemblyPrefix).SourceGeneration + $(MSBuildThisFileDirectory) + + + + + + + + + + diff --git a/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj b/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj index 4a1eda9a27..ec57a77bc9 100644 --- a/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj +++ b/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj @@ -1,61 +1,15 @@ + + + - netstandard2.0 - $(AssemblyPrefix).SourceGeneration - Opc.Ua.SourceGeneration - OPC UA source generation Library - - $(NoWarn);CS8002 + $(RoslynApiVersionVS2026) - - - $(PackagePrefix).Opc.Ua.SourceGeneration - true - true - NugetREADME.md - MIT - - true - true - - - $(PackageId).Debug - - - true - true - false - - - - - - - - - - - - - all - Analyzer - - - - - - - - all - - - - - - - - - + diff --git a/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs b/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs index 9502f48c1c..3e919aaeaa 100644 --- a/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs @@ -32,7 +32,7 @@ namespace Opc.Ua.SourceGeneration { - internal static class SourceGenerator + internal static partial class SourceGenerator { /// /// The namespace prefix for generated code diff --git a/tools/Opc.Ua.SourceGeneration/SourceGeneratorHelpers.cs b/tools/Opc.Ua.SourceGeneration/SourceGeneratorHelpers.cs new file mode 100644 index 0000000000..67fb8eee6f --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration/SourceGeneratorHelpers.cs @@ -0,0 +1,84 @@ +/* ======================================================================== + * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Diagnostics; +using Microsoft.CodeAnalysis; + +namespace Opc.Ua.SourceGeneration +{ + /// + /// Shared helpers for the source generator entry points. The file is + /// linked into every generator assembly, each of which supplies its own + /// diagnostic descriptors in the other half of the partial class. + /// + internal static partial class SourceGenerator + { + /// + /// Runs a source output callback and converts an unhandled exception + /// into a diagnostic. Roslyn fails the whole compilation with a bare + /// stack trace when a generator throws, so every callback funnels + /// through here to report a well-formed diagnostic instead. + /// + public static void Guard(SourceProductionContext context, Action action) + { + try + { + action(); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + context.ReportDiagnostic( + Diagnostic.Create( + Exception, + Location.None, + ex.Message, + ex.StackTrace)); + } + } + + /// + /// Attaches a debugger to the compiler process hosting the generator. + /// Compiled away unless the generator is built with the DEBUGX + /// constant defined. + /// + [Conditional("DEBUGX")] + public static void AttachDebuggerIfRequested() + { + if (!Debugger.IsAttached) + { + Debugger.Launch(); + } + } + } +} diff --git a/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets b/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets deleted file mode 100644 index 9002af118b..0000000000 --- a/tools/Opc.Ua.SourceGeneration/SourceGeneratorPackaging.targets +++ /dev/null @@ -1,38 +0,0 @@ - - - false - - false - true - true - - $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorRuntimeAssets - - - Compile;CopyFilesToOutputDirectory;ResolveReferences - - - - - <_SourceGeneratorRuntimeAsset Include="@(ReferenceCopyLocalPaths)" - Condition="'$(NoBuild)' != 'true' AND - '%(ReferenceCopyLocalPaths.Extension)' == '.dll' AND - !$([System.String]::Copy('%(Filename)').StartsWith('Microsoft.CodeAnalysis')) AND - '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.Common' AND - '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.CSharp' AND - '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.Workspaces.Common' AND - '%(ReferenceCopyLocalPaths.NuGetPackageId)' != 'Microsoft.CodeAnalysis.CSharp.Workspaces'" /> - - <_SourceGeneratorRuntimeAsset Include="$(TargetDir)*.dll" - Exclude="$(TargetPath);$(TargetDir)Microsoft.CodeAnalysis*.dll" - Condition="'$(NoBuild)' == 'true'" /> - - analyzers/dotnet/cs/$(TargetFileName) - - - analyzers/dotnet/cs/%(_SourceGeneratorRuntimeAsset.Filename)%(_SourceGeneratorRuntimeAsset.Extension) - - - - diff --git a/tools/Opc.Ua.SourceGeneration/SourceGeneratorTelemetry.cs b/tools/Opc.Ua.SourceGeneration/SourceGeneratorTelemetry.cs index 0f02a53684..d6394422cd 100644 --- a/tools/Opc.Ua.SourceGeneration/SourceGeneratorTelemetry.cs +++ b/tools/Opc.Ua.SourceGeneration/SourceGeneratorTelemetry.cs @@ -31,9 +31,6 @@ using System.Diagnostics; using Microsoft.CodeAnalysis; using Microsoft.Extensions.Logging; -using ExternalLogLevel = SGF.Diagnostics.LogLevel; -using IExternalLogger = SGF.Diagnostics.ILogger; -using SourceProductionContext = SGF.SgfSourceProductionContext; namespace Opc.Ua.SourceGeneration { @@ -60,17 +57,16 @@ public void Dispose() } /// - /// Create telemetry context + /// Create telemetry context. Warnings and errors raised by the + /// generator libraries surface as compiler diagnostics; anything + /// below is dropped because a generator has no build output sink. /// - /// - public static SourceGeneratorTelemetry Create( - IExternalLogger logger, - SourceProductionContext context) + public static SourceGeneratorTelemetry Create(SourceProductionContext context) { ILoggerFactory factory = null; try { - factory = new LoggerFactoryAdapter(logger, context); + factory = new LoggerFactoryAdapter(context); var result = new SourceGeneratorTelemetry(factory); factory = null; return result; @@ -89,13 +85,9 @@ private sealed class LoggerFactoryAdapter : ILoggerFactory, ILoggerProvider /// /// Create adapter /// - /// /// - public LoggerFactoryAdapter( - IExternalLogger logger, - SourceProductionContext context) + public LoggerFactoryAdapter(SourceProductionContext context) { - m_logger = logger; m_context = context; } @@ -107,7 +99,7 @@ public void AddProvider(ILoggerProvider provider) /// public ILogger CreateLogger(string categoryName) { - return new LoggerAdapter(categoryName, m_context, m_logger); + return new LoggerAdapter(categoryName, m_context); } /// @@ -115,7 +107,6 @@ public void Dispose() { } - private readonly IExternalLogger m_logger; private readonly SourceProductionContext m_context; } @@ -129,12 +120,10 @@ private sealed class LoggerAdapter : ILogger, IDisposable /// public LoggerAdapter( string categoryName, - SourceProductionContext context, - IExternalLogger logger) + SourceProductionContext context) { m_categoryName = categoryName; m_context = context; - m_logger = logger; } /// @@ -146,9 +135,6 @@ public void Dispose() public bool IsEnabled(LogLevel logLevel) { return logLevel > LogLevel.Information; -#if UNUSED - return m_logger.IsEnabled(GetLogLevel(logLevel)); -#endif } /// @@ -175,13 +161,7 @@ public void Log( return; } - message = $"[{m_categoryName}] {message}"; - m_logger.Log( - GetLogLevel(logLevel), - exception, - message); - - Debug.WriteLine(message); + Debug.WriteLine($"[{m_categoryName}] {message}"); } /// @@ -190,20 +170,7 @@ public IDisposable BeginScope(TState state) where TState : notnull return this; } - private static ExternalLogLevel GetLogLevel(LogLevel logLevel) - { - return logLevel switch - { - LogLevel.Trace or LogLevel.Debug => ExternalLogLevel.Debug, - LogLevel.Information => ExternalLogLevel.Information, - LogLevel.Warning => ExternalLogLevel.Warning, - LogLevel.Error or LogLevel.Critical => ExternalLogLevel.Error, - _ => ExternalLogLevel.Information - }; - } - private readonly string m_categoryName; - private readonly IExternalLogger m_logger; private readonly SourceProductionContext m_context; } diff --git a/tools/RoslynVariants.props b/tools/RoslynVariants.props new file mode 100644 index 0000000000..41fedda03a --- /dev/null +++ b/tools/RoslynVariants.props @@ -0,0 +1,21 @@ + + + + + 4.8.0 + + 5.0.0 + + + roslyn$(RoslynApiVersionVS2022.Substring(0, $(RoslynApiVersionVS2022.LastIndexOf('.')))) + roslyn$(RoslynApiVersionVS2026.Substring(0, $(RoslynApiVersionVS2026.LastIndexOf('.')))) + + diff --git a/tools/SourceGeneration.slnx b/tools/SourceGeneration.slnx index cf7a1ca639..41fbfaf31b 100644 --- a/tools/SourceGeneration.slnx +++ b/tools/SourceGeneration.slnx @@ -3,7 +3,11 @@ + + + + diff --git a/tools/SourceGeneratorPack.targets b/tools/SourceGeneratorPack.targets new file mode 100644 index 0000000000..d8daa4dcce --- /dev/null +++ b/tools/SourceGeneratorPack.targets @@ -0,0 +1,74 @@ + + + + netstandard2.0 + true + false + + false + true + true + true + false + MIT + + NugetREADME.md + false + false + false + + false + false + true + + $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorAnalyzerAssets + + + + $(PackageId).Debug + + + + false + all + + + + + + + + <_SourceGeneratorVariantTargetFramework>netstandard2.0 + + + <_SourceGeneratorVariantAssembly + Include="%(SourceGeneratorVariant.RootDir)%(SourceGeneratorVariant.Directory)bin\$(Configuration)\$(_SourceGeneratorVariantTargetFramework)\*.dll" + Exclude="%(SourceGeneratorVariant.RootDir)%(SourceGeneratorVariant.Directory)bin\$(Configuration)\$(_SourceGeneratorVariantTargetFramework)\Microsoft.CodeAnalysis*.dll"> + %(SourceGeneratorVariant.RoslynAnalyzerFolder) + + + analyzers/dotnet/%(_SourceGeneratorVariantAssembly.RoslynAnalyzerFolder)/cs/%(_SourceGeneratorVariantAssembly.Filename)%(_SourceGeneratorVariantAssembly.Extension) + + + + + diff --git a/tools/SourceGeneratorVariant.targets b/tools/SourceGeneratorVariant.targets new file mode 100644 index 0000000000..5b2019e383 --- /dev/null +++ b/tools/SourceGeneratorVariant.targets @@ -0,0 +1,40 @@ + + + + netstandard2.0 + Opc.Ua.SourceGeneration + OPC UA source generation Library + true + true + + false + false + true + true + + + + + + + + + + all + + + From 3be6607ac422ffbf963945e9dac1f38abea2ce64 Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 30 Jul 2026 15:02:20 +0200 Subject: [PATCH 2/6] Validate the shipped package set and generator packages end to end Audit of the packages preview.yml produces, plus the CI parallelism that the SourceGenerator.Foundations removal unblocks. Package audit: moving the two generator packages onto dedicated *.Pack projects leaves the shipped set unchanged. Evaluating PackageId/IsPackable across every project in preview-pack.slnx yields the same 47 package ids on master and on this branch, and a full Release build + `dotnet pack --no-build` produces exactly those 47 packages. Only the owning project changed, which is invisible to consumers. To keep it that way, .azurepipelines/expected-packages.txt pins the expected set and validate-source-generator-packages.ps1 fails when the packed output does not match, so adding, removing or renaming a shipped package has to happen deliberately in the same pull request. The script gains two further checks: - The model generator's props file has to be named build/.props. NuGet only auto-imports that exact name, and the file declares every CompilerVisibleProperty and CompilerVisibleItemMetadata the generator relies on. Until this branch it was packed as build/OPCFoundation.Opc.Ua.SourceGeneration.props, which does not match the package id OPCFoundation.NetStandard.Opc.Ua.SourceGeneration, so no NuGet consumer ever imported it and every ModelSourceGenerator* property and AdditionalFiles metadata was silently invisible to the generator. In-repo projects were unaffected because they import the props by relative path. - A standalone consumer that actually drives the generator. The existing clean consumer only proves the analyzer loads; this one feeds a real NodeSet2 in as an AdditionalFile, pins a ModelSourceGeneratorPrefix that cannot be derived from the model itself, and then references the emitted identifier tables and NodeState type from hand-written code. A generator that does not run, or runs without seeing its options, becomes a compile error. Deliberately does not import the props by path, so the package's auto-import is what is under test. Both new checks were verified to fail against a package rebuilt with the historical misnamed props. CI: remove /p:UseSharedCompilation=false and -maxcpucount:1 from buildandtest.yml. `dotnet build` defaults to all logical processors, so -maxcpucount:1 actively disabled parallelism. Both flags were added silently in unrelated pull requests (f358078b1, 208c601b0) and suppress exactly the conditions the SourceGenerator. Foundations resolver race needed; that race is gone, and disabling the compiler server now costs more than before because the analyzer closure registered per compilation grew to 19 assemblies. Compilation is roughly 72 percent of what the workflow executes (233 of 323 job-minutes in run 29916768976), so the Build step durations there are the baseline to compare against. codeql-analysis.yml keeps the flag because the CodeQL tracer needs it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ed53262-5cfa-429a-820e-bf6146a00d8b --- .azurepipelines/expected-packages.txt | 63 +++++ .../validate-source-generator-packages.ps1 | 216 ++++++++++++++++++ .github/workflows/buildandtest.yml | 13 +- docs/DeveloperGuide.md | 2 + 4 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 .azurepipelines/expected-packages.txt diff --git a/.azurepipelines/expected-packages.txt b/.azurepipelines/expected-packages.txt new file mode 100644 index 0000000000..a26ac92070 --- /dev/null +++ b/.azurepipelines/expected-packages.txt @@ -0,0 +1,63 @@ +# Every NuGet package the preview pipeline is expected to produce from +# `dotnet pack preview-pack.slnx`, one package id per line. +# +# `validate-source-generator-packages.ps1` fails the build when the packed output +# does not match this list exactly. The point is that adding, removing or renaming +# a shipped package has to be a conscious, reviewed act - a package silently +# disappearing (or a private build-time project accidentally becoming packable) +# is otherwise invisible until consumers notice. +# +# Lines starting with '#' and blank lines are ignored. The list covers the modern +# `dotnet pack` output only; the hand-authored `nuget/*.nuspec` packages are packed +# in a later pipeline step and are not part of this check. +# +# To update: run `dotnet pack preview-pack.slnx` and reconcile the produced +# package ids with this file in the same pull request that changes them. + +OPCFoundation.NetStandard.Opc.Ua.Bindings.Https +OPCFoundation.NetStandard.Opc.Ua.Client +OPCFoundation.NetStandard.Opc.Ua.Client.ComplexTypes +OPCFoundation.NetStandard.Opc.Ua.Configuration +OPCFoundation.NetStandard.Opc.Ua.Core +OPCFoundation.NetStandard.Opc.Ua.Core.Diagnostics +OPCFoundation.NetStandard.Opc.Ua.Core.Schema +OPCFoundation.NetStandard.Opc.Ua.Core.Types +OPCFoundation.NetStandard.Opc.Ua.Di +OPCFoundation.NetStandard.Opc.Ua.Di.Client +OPCFoundation.NetStandard.Opc.Ua.Di.Server +OPCFoundation.NetStandard.Opc.Ua.Gds.Client.Common +OPCFoundation.NetStandard.Opc.Ua.Gds.Common +OPCFoundation.NetStandard.Opc.Ua.Gds.Server.Common +OPCFoundation.NetStandard.Opc.Ua.ISA95 +OPCFoundation.NetStandard.Opc.Ua.ISA95.Client +OPCFoundation.NetStandard.Opc.Ua.ISA95.Server +OPCFoundation.NetStandard.Opc.Ua.Lds.Server +OPCFoundation.NetStandard.Opc.Ua.Mcp +OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer +OPCFoundation.NetStandard.Opc.Ua.PubSub +OPCFoundation.NetStandard.Opc.Ua.PubSub.Adapter +OPCFoundation.NetStandard.Opc.Ua.PubSub.Diagnostics +OPCFoundation.NetStandard.Opc.Ua.PubSub.Eth +OPCFoundation.NetStandard.Opc.Ua.PubSub.Kafka +OPCFoundation.NetStandard.Opc.Ua.PubSub.Mqtt +OPCFoundation.NetStandard.Opc.Ua.PubSub.Schema +OPCFoundation.NetStandard.Opc.Ua.PubSub.Server +OPCFoundation.NetStandard.Opc.Ua.PubSub.Udp +OPCFoundation.NetStandard.Opc.Ua.Quickstarts.Servers +OPCFoundation.NetStandard.Opc.Ua.Redundancy +OPCFoundation.NetStandard.Opc.Ua.Redundancy.Client +OPCFoundation.NetStandard.Opc.Ua.Redundancy.Kubernetes +OPCFoundation.NetStandard.Opc.Ua.Redundancy.PubSub +OPCFoundation.NetStandard.Opc.Ua.Redundancy.Server +OPCFoundation.NetStandard.Opc.Ua.Security.Certificates +OPCFoundation.NetStandard.Opc.Ua.Server +OPCFoundation.NetStandard.Opc.Ua.SourceGeneration +OPCFoundation.NetStandard.Opc.Ua.SourceGeneration.Core +OPCFoundation.NetStandard.Opc.Ua.SourceGeneration.Stack +OPCFoundation.NetStandard.Opc.Ua.Types +OPCFoundation.NetStandard.Opc.Ua.WotCon +OPCFoundation.NetStandard.Opc.Ua.WotCon.Client +OPCFoundation.NetStandard.Opc.Ua.WotCon.Server +OPCFoundation.NetStandard.Opc.Ua.XRegistry +OPCFoundation.NetStandard.Opc.Ua.XRegistry.Client +OPCFoundation.NetStandard.Opc.Ua.XRegistry.Server diff --git a/.azurepipelines/validate-source-generator-packages.ps1 b/.azurepipelines/validate-source-generator-packages.ps1 index b5a8bcefcd..617060b561 100644 --- a/.azurepipelines/validate-source-generator-packages.ps1 +++ b/.azurepipelines/validate-source-generator-packages.ps1 @@ -151,6 +151,200 @@ function Test-PackageContents ) "Package '$($Package.Id)' must carry its analyzer runtime closure privately." } +function Test-PackageBuildProps +{ + <# + .SYNOPSIS + Asserts the auto-imported MSBuild props file is named after the package id. + + .DESCRIPTION + NuGet only auto-imports `build/.props`. The model generator's props + declares every CompilerVisibleProperty / CompilerVisibleItemMetadata the generator + needs, so a name that does not track the package id (including the `.Debug` + configuration suffix) silently strips every `ModelSourceGenerator*` setting from + package consumers without any build error. + #> + param( + [Parameter(Mandatory = $true)] + [PSCustomObject] $Package, + + [switch] $RequiresProps + ) + + $expected = "build/$($Package.Id).props" + if ($RequiresProps) + { + Assert-Condition ( + $Package.Entries -contains $expected + ) ("Package '$($Package.Id)' is missing '$expected'. NuGet only auto-imports " + + "build/.props, so any other name is never imported by consumers.") + } + + $strayBuildFiles = @($Package.Entries | + Where-Object { $_.StartsWith("build/", [StringComparison]::OrdinalIgnoreCase) } | + Where-Object { $_ -ne $expected }) + Assert-Condition ( + $strayBuildFiles.Count -eq 0 + ) ("Package '$($Package.Id)' ships unexpected build/ entries that NuGet will never " + + "auto-import: $($strayBuildFiles -join ', ').") +} + +function Test-ExpectedPackageSet +{ + <# + .SYNOPSIS + Asserts the packed output matches the checked-in expected package list. + #> + param( + [Parameter(Mandatory = $true)] + [string] $ManifestPath + ) + + Assert-Condition (Test-Path $ManifestPath) "Expected package manifest '$ManifestPath' not found." + + $expected = @(Get-Content $ManifestPath | + ForEach-Object { $_.Trim() } | + Where-Object { $_ -and -not $_.StartsWith("#") } | + Sort-Object -Unique) + + $actual = @(Get-ChildItem -Path $PackageDirectory -Filter "*.nupkg" -File -Recurse | + Where-Object { $_.Name -notlike "*.snupkg" } | + ForEach-Object { $_.Name -replace "\.[0-9].*\.nupkg$", "" } | + Sort-Object -Unique) + + $missing = @($expected | Where-Object { $actual -notcontains $_ }) + $unexpected = @($actual | Where-Object { $expected -notcontains $_ }) + + Assert-Condition ($missing.Count -eq 0) ( + "Packages listed in '$ManifestPath' were not produced: $($missing -join ', '). " + + "If a package was intentionally removed or renamed, update the manifest in the " + + "same pull request.") + Assert-Condition ($unexpected.Count -eq 0) ( + "Unexpected packages were produced: $($unexpected -join ', '). If a package was " + + "intentionally added, add it to '$ManifestPath'; otherwise a build-time-only " + + "project has become packable by accident.") + + Write-Host "Package set matches the expected manifest ($($expected.Count) packages)." +} + +function Test-SourceGeneratingConsumer +{ + <# + .SYNOPSIS + Builds a standalone project that actually drives the packaged model generator. + + .DESCRIPTION + Test-CleanConsumer only proves the analyzer loads. This test proves the packaged + generator *generates*: it feeds a real NodeSet2 in as an AdditionalFile and then + references the emitted types from hand-written code, so a generator that fails to + run, or runs with the wrong options, becomes a compile error. + + The consumer deliberately does NOT import the generator's props file by path the + way the in-repo projects do. It relies on NuGet auto-importing + `build/.props`, and it pins a custom `ModelSourceGeneratorPrefix` that + cannot be derived from the NodeSet itself. If that props file is ever misnamed + again the metadata becomes invisible to the compiler, the emitted namespace falls + back to the model-derived default, and the references below stop compiling. + #> + param( + [Parameter(Mandatory = $true)] + [PSCustomObject] $Package, + + [Parameter(Mandatory = $true)] + [string] $ValidationRoot, + + [Parameter(Mandatory = $true)] + [string] $RepoRoot + ) + + $nodeSet = Join-Path $RepoRoot "samples\MinimalBoilerServer\Model\Boiler.NodeSet2.xml" + Assert-Condition (Test-Path $nodeSet) "NodeSet '$nodeSet' used by the generator consumer test not found." + + $consumerDirectory = Join-Path $ValidationRoot "SourceGenerating" + New-Item -ItemType Directory -Path $consumerDirectory | Out-Null + $projectPath = Join-Path $consumerDirectory "SourceGenerating.csproj" + $generatedRoot = "generated" + # Namespace prefix that the generator can only learn from the AdditionalFiles + # metadata, i.e. only when build/.props was auto-imported. + $prefix = "PackagedGeneratorProbe" + $escapedNodeSet = [System.Security.SecurityElement]::Escape($nodeSet) + + @" + + + net10.0 + enable + true + true + $generatedRoot + v105 + + + + + + + + http://opcfoundation.org/UA/Boiler/ + Boiler + $prefix + + + +"@ | Set-Content -Path $projectPath -Encoding utf8 + + # Referencing the generated identifier tables and a generated NodeState type is the + # assertion: without a successful generation run under the requested prefix none of + # these resolve and the consumer does not compile. + @" +using $prefix; + +namespace SourceGeneratingConsumer; + +public static class GeneratedModelProbe +{ + public static uint BoilerTypeIdentifier => ObjectTypes.BoilerType; + + public static string BoilerTypeBrowseName => BrowseNames.BoilerType; + + public static System.Type BoilerStateType => typeof(BoilerState); +} +"@ | Set-Content -Path (Join-Path $consumerDirectory "GeneratedModelProbe.cs") -Encoding utf8 + + $nugetConfig = Join-Path $ValidationRoot "NuGet.WithUpstream.Config" + $packagesPath = Join-Path $ValidationRoot "packages" + Invoke-DotNet @( + "restore", + $projectPath, + "--configfile", + $nugetConfig, + "--packages", + $packagesPath, + "--nologo" + ) + Invoke-DotNet @( + "build", + $projectPath, + "--configuration", + "Release", + "--no-restore", + "--nologo" + ) + + $generatedFiles = @(Get-ChildItem -Path (Join-Path $consumerDirectory $generatedRoot) ` + -Filter "*.cs" -File -Recurse -ErrorAction SilentlyContinue) + Assert-Condition ( + $generatedFiles.Count -gt 0 + ) ("The packaged generator produced no source for '$($Package.Id)'. The consumer " + + "compiled, but nothing was emitted under '$generatedRoot'.") + + Write-Host ( + "Packaged model generator emitted $($generatedFiles.Count) file(s) for a " + + "standalone NodeSet consumer.") +} + function Invoke-DotNet { param( @@ -290,6 +484,13 @@ $stackPackage = Get-PackageInfo "OPCFoundation.NetStandard.Opc.Ua.SourceGenerati Test-PackageContents $modelPackage "Opc.Ua.SourceGeneration.dll" Test-PackageContents $stackPackage "Opc.Ua.SourceGeneration.Stack.dll" +# Only the model generator exposes MSBuild settings to consumers, so only it ships a +# build/.props; the stack generator must not smuggle in stray build/ content. +Test-PackageBuildProps $modelPackage -RequiresProps +Test-PackageBuildProps $stackPackage + +Test-ExpectedPackageSet (Join-Path $PSScriptRoot "expected-packages.txt") + Remove-Item -Path $validationRoot -Recurse -Force -ErrorAction SilentlyContinue New-Item -ItemType Directory -Path $validationRoot | Out-Null try @@ -313,9 +514,24 @@ try "@ | Set-Content -Path (Join-Path $validationRoot "NuGet.Config") -Encoding utf8 + # The generator packages carry their whole closure privately, so the clean consumers + # restore from the artifact directory alone - that isolation is part of what they + # assert. The source-generating consumer additionally pulls Opc.Ua.Server, whose + # Microsoft.Extensions.* graph has to come from upstream. + @" + + + + + + + + +"@ | Set-Content -Path (Join-Path $validationRoot "NuGet.WithUpstream.Config") -Encoding utf8 Test-CleanConsumer $modelPackage $validationRoot Test-CleanConsumer $stackPackage $validationRoot + Test-SourceGeneratingConsumer $modelPackage $validationRoot $repoRoot } finally { diff --git a/.github/workflows/buildandtest.yml b/.github/workflows/buildandtest.yml index d6bd941acd..7a16bd8d01 100644 --- a/.github/workflows/buildandtest.yml +++ b/.github/workflows/buildandtest.yml @@ -129,7 +129,14 @@ jobs: run: ./.azurepipelines/set-version.ps1 - name: Build - run: dotnet build ${{ env.CSPROJECT }} --framework ${{ matrix.framework }} --configuration ${{ matrix.configuration }} /p:CustomTestTarget=${{ matrix.customtesttarget }} /p:UseSharedCompilation=false -maxcpucount:1 + # Build in parallel with the shared compiler (both `dotnet build` defaults). + # These were previously pinned to `-maxcpucount:1 /p:UseSharedCompilation=false` + # to work around a race in the SourceGenerator.Foundations assembly resolver that + # surfaced as intermittent CS8784; that dependency was removed, so the generators + # now resolve through Roslyn's own analyzer loader and the workaround only cost + # build time. Do not re-add without evidence - re-loading the ~19-assembly + # analyzer closure for every project is expensive. + run: dotnet build ${{ env.CSPROJECT }} --framework ${{ matrix.framework }} --configuration ${{ matrix.configuration }} /p:CustomTestTarget=${{ matrix.customtesttarget }} - name: Test # note: /p:CollectCoverage=true is only used to disable deterministic builds @@ -270,7 +277,7 @@ jobs: $failed = @() foreach ($tfm in $tfms) { Write-Host "::group::Build UA.slnx ($tfm)" - dotnet build UA.slnx --configuration Release /p:CustomTestTarget=$tfm /p:UseSharedCompilation=false + dotnet build UA.slnx --configuration Release /p:CustomTestTarget=$tfm if ($LASTEXITCODE -ne 0) { $failed += $tfm Write-Host "::error::UA.slnx build failed for $tfm" @@ -313,7 +320,7 @@ jobs: $failed = @() foreach ($tfm in $tfms) { Write-Host "::group::Build UA.slnx ($tfm)" - dotnet build UA.slnx --configuration Release /p:CustomTestTarget=$tfm /p:UseSharedCompilation=false + dotnet build UA.slnx --configuration Release /p:CustomTestTarget=$tfm if ($LASTEXITCODE -ne 0) { $failed += $tfm Write-Host "::error::UA.slnx build failed for $tfm" diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index 0ed342ac24..3a68307eae 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -217,6 +217,8 @@ The following NuGet packages are released on a monthly cadence (with hot fixes f For improved source-level debugging, symbol packages are published on nuget.org in `snupkg` format, and `Debug`-compiled packages are available with a `.Debug` suffix. In addition, every successful `master` build publishes preview packages to the [Azure DevOps preview feed](https://opcfoundation.visualstudio.com/opcua-netstandard/_artifacts/feed/opcua-preview). +The full set of packages the preview pipeline produces is pinned in [`.azurepipelines/expected-packages.txt`](../.azurepipelines/expected-packages.txt). `.azurepipelines/validate-source-generator-packages.ps1` fails the build when the packed output does not match it, so adding, removing or renaming a shipped package has to be done deliberately in the same pull request. That script also validates the analyzer packages: their `analyzers/dotnet/roslyn./cs` layout, that they carry their runtime closure privately, that the model generator's auto-imported `build/.props` is named after the package id, and — end to end — that a standalone project consuming the packed generator with a NodeSet actually gets code generated. + ### Supported target frameworks The class libraries currently target: From b2e7a0903617e09f6b736c4084f70af97fb82099 Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 30 Jul 2026 15:23:54 +0200 Subject: [PATCH 3/6] Address review feedback: preserve exception detail in generator diagnostics Copilot's review left no inline threads, only four low-confidence notes. Two were valid and are fixed; two describe load-bearing settings and are now documented so they are not "fixed" into breakage later. Fixed: - SourceGeneratorTelemetry dropped the exception when a log entry had no matching diagnostic descriptor. The SGF-based code passed it to the external sink, so removing SGF silently lost it. The debug fallback now appends the exception. Uses a literal separator rather than Environment.NewLine, which RS1035 bans in analyzers. - Exception diagnostics (MODELGEN003 / STACKGEN003) reported ex.StackTrace, which is null for an exception that never left its throw site and carries neither the exception type nor the inner-exception chain. All three call sites - SourceGenerator.Guard, StackGeneration.Emit and ModelCompilation.Emit - now report ex.ToString() so the detail is identical wherever the failure originates. Documented, not changed: - NoRoslynatorAnalzers is misspelled, but common.props is the only reader and it uses the same spelling, so the setting does take effect. Renaming only here would silently disable it. - PackageLicenseFile must stay empty. common.props sets it to LICENSE.txt for every packable project, and NuGet rejects a package declaring both a license expression and a license file (NU5035). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ed53262-5cfa-429a-820e-bf6146a00d8b --- tools/Opc.Ua.SourceGeneration.Stack/StackGeneration.cs | 2 +- tools/Opc.Ua.SourceGeneration/ModelCompilation.cs | 2 +- tools/Opc.Ua.SourceGeneration/SourceGeneratorHelpers.cs | 5 ++++- tools/Opc.Ua.SourceGeneration/SourceGeneratorTelemetry.cs | 6 +++++- tools/SourceGeneratorPack.targets | 7 +++++++ 5 files changed, 18 insertions(+), 4 deletions(-) diff --git a/tools/Opc.Ua.SourceGeneration.Stack/StackGeneration.cs b/tools/Opc.Ua.SourceGeneration.Stack/StackGeneration.cs index 6caa70b95c..254df4dc8f 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack/StackGeneration.cs +++ b/tools/Opc.Ua.SourceGeneration.Stack/StackGeneration.cs @@ -113,7 +113,7 @@ public void Emit(CancellationToken cancellationToken) SourceGenerator.Exception, Location.None, ex.Message, - ex.StackTrace)); + ex.ToString())); } } diff --git a/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs b/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs index c844d9a7c5..f975f200ee 100644 --- a/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs +++ b/tools/Opc.Ua.SourceGeneration/ModelCompilation.cs @@ -274,7 +274,7 @@ [.. m_identifierFiles.Select(i => i.Path)], SourceGenerator.Exception, Location.None, ex.Message, - ex.StackTrace)); + ex.ToString())); } } diff --git a/tools/Opc.Ua.SourceGeneration/SourceGeneratorHelpers.cs b/tools/Opc.Ua.SourceGeneration/SourceGeneratorHelpers.cs index 67fb8eee6f..c5ec888f37 100644 --- a/tools/Opc.Ua.SourceGeneration/SourceGeneratorHelpers.cs +++ b/tools/Opc.Ua.SourceGeneration/SourceGeneratorHelpers.cs @@ -58,12 +58,15 @@ public static void Guard(SourceProductionContext context, Action action) } catch (Exception ex) { + // ex.ToString() rather than ex.StackTrace: it carries the exception + // type and the inner-exception chain, and is never null - a bare + // StackTrace is null for an exception that never left its throw site. context.ReportDiagnostic( Diagnostic.Create( Exception, Location.None, ex.Message, - ex.StackTrace)); + ex.ToString())); } } diff --git a/tools/Opc.Ua.SourceGeneration/SourceGeneratorTelemetry.cs b/tools/Opc.Ua.SourceGeneration/SourceGeneratorTelemetry.cs index d6394422cd..1f0952a17f 100644 --- a/tools/Opc.Ua.SourceGeneration/SourceGeneratorTelemetry.cs +++ b/tools/Opc.Ua.SourceGeneration/SourceGeneratorTelemetry.cs @@ -161,7 +161,11 @@ public void Log( return; } - Debug.WriteLine($"[{m_categoryName}] {message}"); + // Anything without a descriptor only reaches a debugger-attached + // host, so keep the full exception rather than just its message. + Debug.WriteLine(exception is null + ? $"[{m_categoryName}] {message}" + : $"[{m_categoryName}] {message}\n{exception}"); } /// diff --git a/tools/SourceGeneratorPack.targets b/tools/SourceGeneratorPack.targets index d8daa4dcce..ad0d78e564 100644 --- a/tools/SourceGeneratorPack.targets +++ b/tools/SourceGeneratorPack.targets @@ -28,6 +28,11 @@ true false MIT + NugetREADME.md false @@ -37,6 +42,8 @@ there is nothing for the analyzers to inspect. --> false false + true $(TargetsForTfmSpecificContentInPackage);IncludeSourceGeneratorAnalyzerAssets From a306422ba982c816e5985d4e44bae422aa538c07 Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 30 Jul 2026 18:19:39 +0200 Subject: [PATCH 4/6] Ship a single Roslyn band: the roslyn4.8 payload never loaded Code review flagged, and a run against a real Roslyn 4.8 compiler confirmed, that the roslyn4.8 analyzer payload does not work at all. Directory.Packages.props pins System.Collections.Immutable and System.Reflection.Metadata centrally with transitive pinning on, so the whole analyzer closure - the generator, Opc.Ua.SourceGeneration.Core and Opc.Ua.Types - references 10.0.0.0. Roslyn 4.8 supplies 7.0.0.0. Running csc from Microsoft.Net.Compilers.Toolset 4.8.0 over the packed roslyn4.8 folder gives: warning CS8784: Generator 'ModelSourceGenerator' failed to initialize ... MissingMethodException: Method not found: 'IncrementalValueProvider`1> IncrementalValueProviderExtensions.Collect(IncrementalValuesProvider`1)' because the shipped System.Collections.Immutable 10.x binds a second ImmutableArray identity. Dropping that copy from the payload only changes the failure to FileNotFoundException for 10.0.0.0. Both surface as a warning, so the consumer silently gets no generated code. Fixing it properly means building the entire closure, Opc.Ua.Types included, against the band's package versions - a change to a core shipping library that does not belong in this pull request. Note master had the same defect: it was pinned to Roslyn 4.14 while referencing System.Collections.Immutable 10.0.0.0, so no 4.x host ever actually worked. Shipping only roslyn5.0 is therefore not a regression, it is an accurate statement of what has ever worked, and the versioned folder means an older host now skips the analyzer instead of failing inside it. - Remove the five *.Roslyn4_8 variant projects, their solution entries, and the 4.8 half of the pack projects and the MigrationAnalyzer nuspec. - Never ship Microsoft.CodeAnalysis*, System.Collections.Immutable or System.Reflection.Metadata: excluded from both the package payload (SourceGeneratorPack.targets) and the in-repo analyzer closure (Directory.Build.targets), and asserted in validate-source-generator-packages.ps1 so the trap cannot come back. - Document in RoslynVariants.props and DeveloperGuide.md exactly what adding a down-level band requires. Also from the review: the pack guard tested the flattened assembly list, so it only fired when *no* variant produced output - with N variants declared it would silently ship a package missing a band. It now checks each declared variant contributed assemblies (verified by hiding one variant's output and confirming the error fires). And the migration skill doc still described the old analyzers/dotnet/cs layout. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ed53262-5cfa-429a-820e-bf6146a00d8b --- .agents/skills/opcua-v20-migration/SKILL.md | 6 ++-- .../validate-source-generator-packages.ps1 | 35 +++++++++++++------ Directory.Build.targets | 11 ++++-- UA.slnx | 5 --- docs/DeveloperGuide.md | 11 +++--- tools/MigrationAnalyzer.slnx | 5 --- ...grationAnalyzer.CodeFixer.Roslyn4_8.csproj | 13 ------- ...grationAnalyzer.Generator.Roslyn4_8.csproj | 13 ------- .../Opc.Ua.MigrationAnalyzer.Roslyn4_8.csproj | 13 ------- tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md | 8 ++--- .../Opc.Ua.MigrationAnalyzer.csproj | 8 +---- .../Opc.Ua.MigrationAnalyzer.nuspec | 3 -- .../NugetREADME.md | 6 ++-- .../Opc.Ua.SourceGeneration.Pack.csproj | 2 -- .../Opc.Ua.SourceGeneration.Roslyn4_8.csproj | 13 ------- .../NugetREADME.md | 6 ++-- .../Opc.Ua.SourceGeneration.Stack.Pack.csproj | 2 -- ...Ua.SourceGeneration.Stack.Roslyn4_8.csproj | 13 ------- tools/RoslynVariants.props | 31 ++++++++++++---- tools/SourceGeneration.slnx | 2 -- tools/SourceGeneratorPack.targets | 32 +++++++++++++---- 21 files changed, 103 insertions(+), 135 deletions(-) delete mode 100644 tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8.csproj delete mode 100644 tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8.csproj delete mode 100644 tools/Opc.Ua.MigrationAnalyzer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Roslyn4_8.csproj delete mode 100644 tools/Opc.Ua.SourceGeneration.Roslyn4_8/Opc.Ua.SourceGeneration.Roslyn4_8.csproj delete mode 100644 tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_8/Opc.Ua.SourceGeneration.Stack.Roslyn4_8.csproj diff --git a/.agents/skills/opcua-v20-migration/SKILL.md b/.agents/skills/opcua-v20-migration/SKILL.md index 3418d30c26..6da44699a9 100644 --- a/.agents/skills/opcua-v20-migration/SKILL.md +++ b/.agents/skills/opcua-v20-migration/SKILL.md @@ -166,9 +166,9 @@ The single `OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer` NuGet contains | Component | Where | Loaded by | Purpose | |---|---|---|---| -| `Opc.Ua.MigrationAnalyzer.dll` | `analyzers/dotnet/cs/` | csc.exe and IDE | 19 `DiagnosticAnalyzer`s (UA0001–UA0022). Targets stable Roslyn 4.14 API, no `Workspaces` reference, csc-safe. | -| `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` | `analyzers/dotnet/cs/` | Workspaces-aware hosts only (Visual Studio, Rider, `dotnet format analyzers`) | 14 `CodeFixProvider`s. | -| `Opc.Ua.MigrationAnalyzer.Generator.dll` | `analyzers/dotnet/cs/` | csc.exe and IDE | `IIncrementalGenerator` that emits `internal sealed [Obsolete] class Collection : List` shims into the consumer compilation for every `Collection` reference that fails to bind. | +| `Opc.Ua.MigrationAnalyzer.dll` | `analyzers/dotnet/roslyn5.0/cs/` | csc.exe and IDE | 19 `DiagnosticAnalyzer`s (UA0001–UA0022). Targets the Roslyn 5.0 API, no `Workspaces` reference, csc-safe. | +| `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` | `analyzers/dotnet/roslyn5.0/cs/` | Workspaces-aware hosts only (Visual Studio, Rider, `dotnet format analyzers`) | 14 `CodeFixProvider`s. | +| `Opc.Ua.MigrationAnalyzer.Generator.dll` | `analyzers/dotnet/roslyn5.0/cs/` | csc.exe and IDE | `IIncrementalGenerator` that emits `internal sealed [Obsolete] class Collection : List` shims into the consumer compilation for every `Collection` reference that fails to bind. | | `Opc.Ua.MigrationAnalyzer.Core.dll` | `lib//` × 6 TFMs (`net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`, `net10.0`) | Runtime | Re-supplies the obsolete extension surface 2.0 moved or removed so 1.5.378 call sites continue to compile with `[Obsolete]` warnings. | ### The 19 analyzer rules at a glance diff --git a/.azurepipelines/validate-source-generator-packages.ps1 b/.azurepipelines/validate-source-generator-packages.ps1 index 617060b561..b0618b38ec 100644 --- a/.azurepipelines/validate-source-generator-packages.ps1 +++ b/.azurepipelines/validate-source-generator-packages.ps1 @@ -108,14 +108,13 @@ function Test-PackageContents ) "Package '$($Package.Id)' contains assemblies outside '$analyzerRoot'." # One folder per supported Roslyn API version; the .NET SDK loads the - # highest one its compiler supports. + # highest one its compiler supports and ignores folders above it. $roslynFolders = @($dllEntries | ForEach-Object { $_.Substring($analyzerRoot.Length).Split("/")[0] } | Sort-Object -Unique) Assert-Condition ( - $roslynFolders.Count -ge 2 - ) ("Package '$($Package.Id)' must ship one analyzer folder per supported Roslyn " + - "version; found: $($roslynFolders -join ', ').") + $roslynFolders.Count -ge 1 + ) "Package '$($Package.Id)' ships no analyzer folder." Assert-Condition ( @($roslynFolders | Where-Object { $_ -notmatch "^roslyn[0-9]+\.[0-9]+$" }).Count -eq 0 ) ("Package '$($Package.Id)' analyzer folders must be named 'roslyn.'; " + @@ -132,13 +131,27 @@ function Test-PackageContents } } - Assert-Condition ( - @($dllEntries | Where-Object { - [IO.Path]::GetFileName($_).StartsWith( - "Microsoft.CodeAnalysis", - [StringComparison]::OrdinalIgnoreCase) - }).Count -eq 0 - ) "Package '$($Package.Id)' must not ship Microsoft.CodeAnalysis host assemblies." + # Assemblies the Roslyn host supplies itself must never be shipped alongside an + # analyzer. A second copy makes the analyzer load context bind a different + # identity for types that cross Roslyn's own API surface - most visibly + # ImmutableArray - so the generator dies on its first API call with + # MissingMethodException, surfaced only as warning CS8784. + $hostProvided = @( + "Microsoft.CodeAnalysis", + "System.Collections.Immutable", + "System.Reflection.Metadata" + ) + foreach ($forbidden in $hostProvided) + { + Assert-Condition ( + @($dllEntries | Where-Object { + [IO.Path]::GetFileName($_).StartsWith( + $forbidden, + [StringComparison]::OrdinalIgnoreCase) + }).Count -eq 0 + ) ("Package '$($Package.Id)' must not ship '$forbidden*' - the Roslyn host " + + "provides it, and a second copy breaks generator initialization.") + } Assert-Condition ( @($dllEntries | Where-Object { [IO.Path]::GetFileName($_).StartsWith( diff --git a/Directory.Build.targets b/Directory.Build.targets index f84837c6a8..8c8e80b0b4 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -88,7 +88,11 @@ payload the generator NuGet packages ship (see tools/SourceGeneratorPack.targets), which NuGet registers the same way for package consumers. The closure is discovered by globbing the generator's output directory, so a new dependency never needs a project file change. - Roslyn host assemblies are excluded because the compiler already provides them. + + Assemblies the compiler itself supplies are excluded. Handing the compiler a second copy + of Microsoft.CodeAnalysis*, System.Collections.Immutable or System.Reflection.Metadata + splits the identity of types that cross Roslyn's own API surface and breaks generator + initialization. See tools/RoslynVariants.props. --> @@ -97,7 +101,10 @@ $([System.String]::Copy('%(Analyzer.Filename)').StartsWith('Opc.Ua.SourceGeneration'))" /> <_SourceGeneratorAnalyzerDependency Include="%(_SourceGeneratorAnalyzer.RootDir)%(_SourceGeneratorAnalyzer.Directory)*.dll" - Exclude="@(_SourceGeneratorAnalyzer);%(_SourceGeneratorAnalyzer.RootDir)%(_SourceGeneratorAnalyzer.Directory)Microsoft.CodeAnalysis*.dll" /> + Exclude="@(_SourceGeneratorAnalyzer); + %(_SourceGeneratorAnalyzer.RootDir)%(_SourceGeneratorAnalyzer.Directory)Microsoft.CodeAnalysis*.dll; + %(_SourceGeneratorAnalyzer.RootDir)%(_SourceGeneratorAnalyzer.Directory)System.Collections.Immutable.dll; + %(_SourceGeneratorAnalyzer.RootDir)%(_SourceGeneratorAnalyzer.Directory)System.Reflection.Metadata.dll" /> diff --git a/UA.slnx b/UA.slnx index a9afd2f8ff..6e6f753e15 100644 --- a/UA.slnx +++ b/UA.slnx @@ -274,20 +274,15 @@ - - - - - diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index 3a68307eae..d948f54b97 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -20,7 +20,7 @@ The C# language version is pinned (`LangVersion` 14) and analyzer/style rules ar | `src/` | The core stack and higher-level libraries: `Opc.Ua.Types`, `Opc.Ua.Core*`, `Opc.Ua.Client`, `Opc.Ua.Server`, `Opc.Ua.Configuration`, `Opc.Ua.PubSub` (+ transports), the GDS / DI / LDS / WoT libraries, and the `Opc.Ua.Redundancy*` family. | | `samples/` | Reference and sample apps: `ConsoleReferenceServer`, `ConsoleReferenceClient`, `Quickstarts.Servers`, the `Minimal*` / `PumpDeviceIntegrationServer` NativeAOT samples, `Redundant*`, etc. | | `tests/` | Unit and integration test projects, mirroring the library structure, plus shared test frameworks. | -| `tools/` | Source generators, migration analyzers, and the installable `Opc.Ua.Mcp` tool. Each analyzer and generator has a primary project (Visual Studio 2026 Roslyn API), a `*.Roslyn4_8` sibling that glob-links the same sources for the Visual Studio 2022 API, and — for the source generators — a `*.Pack` project that packages both. | +| `tools/` | Source generators, migration analyzers, and the installable `Opc.Ua.Mcp` tool. Each analyzer and generator has a build project and — for the source generators — a `*.Pack` project that packages it under a Roslyn-versioned analyzer folder. | | `docs/` | This documentation set (indexed by [docs/README.md](README.md)). | | `fuzzing/` | SharpFuzz / libFuzzer fuzz targets (see [Fuzzing.md](../fuzzing/Fuzzing.md)). | @@ -52,7 +52,7 @@ Notes: - **Offline / restricted networks.** `NuGetAudit` is enabled and fails the build with `NU1900` when it cannot reach the audit service. If you build offline, pass `-p:NuGetAudit=false`. - **Source generators are consumed as project references.** Projects that use the in-repo generators reference `tools/Opc.Ua.SourceGeneration[.Stack]` with `OutputItemType=Analyzer`. MSBuild only hands the compiler the generator assembly itself, so `Directory.Build.targets` adds the generator's runtime closure (its output directory, minus the Roslyn host assemblies) as `Analyzer` items — the same payload the generator NuGet packages ship under `analyzers/dotnet//cs`. Without it the generators cannot resolve their dependencies and fail to initialise with `CS8784`. -- **Analyzers and generators are built once per supported Roslyn API version.** `tools/RoslynVariants.props` pins the two versions (Visual Studio 2022 and Visual Studio 2026); each analyzer has a primary project plus a `*.Roslyn4_8` sibling that glob-links the same sources, and a `*.Pack` project that ships both under `analyzers/dotnet/roslyn./cs`. See [Repository layout](#repository-layout) and the [support matrix](#supported-target-frameworks). Because the repository's own projects consume the Visual Studio 2026 variant, **building this repository requires a Roslyn 5.x host** (the .NET 10 SDK or Visual Studio 2026). +- **Analyzers and generators are shipped under a Roslyn-versioned analyzer folder.** `tools/RoslynVariants.props` pins the Roslyn API version, and each generator has a `*.Pack` project that ships it under `analyzers/dotnet/roslyn./cs`. See [Repository layout](#repository-layout) and the [support matrix](#supported-analyzer-and-source-generator-hosts). Because the repository's own projects consume that same build, **building this repository requires a Roslyn 5.x host** (the .NET 10 SDK or Visual Studio 2026). ## Running tests @@ -235,14 +235,15 @@ To keep pull-request CI fast, only (4) and (6) are part of the qualifying build; ### Supported analyzer and source generator hosts -The analyzer and source generator packages ship one assembly per Roslyn API version under `analyzers/dotnet/roslyn./cs`; the .NET SDK loads the highest folder its compiler supports. +The analyzer and source generator packages ship under `analyzers/dotnet/roslyn./cs`. The .NET SDK loads the highest folder its compiler supports and **ignores** folders above it, so an older host cleanly skips the analyzer instead of loading it and failing at generator-initialization time. | Roslyn API | Package folder | Minimum host | | --- | --- | --- | -| 4.8 | `analyzers/dotnet/roslyn4.8/cs` | Visual Studio 2022 17.8 / .NET 8 SDK | | 5.0 | `analyzers/dotnet/roslyn5.0/cs` | Visual Studio 2026 18.0 / .NET 10 SDK | -The versions are declared once in `tools/RoslynVariants.props`. Source that has to compile against both must avoid APIs added after the lower baseline — for example `LanguageVersion.CSharp13` only exists from Roslyn 4.12, so `CompilationOptions.IsCSharp13OrLater` compares the underlying value instead. +The version is declared once in `tools/RoslynVariants.props`. + +> **Adding a down-level band is not just another entry in that file.** The analyzer closure — the generator, `Opc.Ua.SourceGeneration.Core` **and** `Opc.Ua.Types` — must bind against the Roslyn host's own `System.Collections.Immutable` and `System.Reflection.Metadata`, whose assembly versions are fixed per band (4.8 → 7.0.0.0, 4.14 → 8.0.0.0, 5.0 → 9.0.0.0). `Directory.Packages.props` pins both centrally with transitive pinning on, so today the whole closure references 10.0.0.0. On an older band the compiler cannot satisfy that: the generator either fails to load (`FileNotFoundException`) or, if a copy is shipped alongside, binds a second `ImmutableArray` identity and dies with `MissingMethodException` on its first Roslyn call. Both surface only as warning `CS8784`, so the consumer silently gets no generated code. A down-level band therefore requires building that entire closure against the band's package versions, which is why `validate-source-generator-packages.ps1` fails any package that ships `Microsoft.CodeAnalysis*`, `System.Collections.Immutable` or `System.Reflection.Metadata`. ### Versioning diff --git a/tools/MigrationAnalyzer.slnx b/tools/MigrationAnalyzer.slnx index 9c37df7c06..6aab4f5046 100644 --- a/tools/MigrationAnalyzer.slnx +++ b/tools/MigrationAnalyzer.slnx @@ -3,20 +3,15 @@ - - - - - diff --git a/tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8.csproj b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8.csproj deleted file mode 100644 index 1b463ec91d..0000000000 --- a/tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_8.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - $(RoslynApiVersionVS2022) - - - diff --git a/tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8.csproj b/tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8.csproj deleted file mode 100644 index 11d2617212..0000000000 --- a/tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_8.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - $(RoslynApiVersionVS2022) - - - diff --git a/tools/Opc.Ua.MigrationAnalyzer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Roslyn4_8.csproj b/tools/Opc.Ua.MigrationAnalyzer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Roslyn4_8.csproj deleted file mode 100644 index 8f90faf94b..0000000000 --- a/tools/Opc.Ua.MigrationAnalyzer.Roslyn4_8/Opc.Ua.MigrationAnalyzer.Roslyn4_8.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - $(RoslynApiVersionVS2022) - - - diff --git a/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md b/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md index 4ac8a11e92..b73a9ac6c8 100644 --- a/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md +++ b/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md @@ -137,13 +137,13 @@ whole block once the MigrationAnalyzer package is removed. ## Packaging note -The package ships **three Roslyn component DLLs** per supported Roslyn API version, -under `analyzers/dotnet/roslyn./cs/`. The .NET SDK loads the highest -folder its compiler supports: +The package ships **three Roslyn component DLLs** under +`analyzers/dotnet/roslyn./cs/`. The .NET SDK loads that folder when its +compiler supports the Roslyn API and ignores it otherwise, so an older host cleanly +skips the analyzer rather than failing to load it: | Roslyn API | Minimum host | | --- | --- | -| 4.8 | Visual Studio 2022 17.8 / .NET 8 SDK | | 5.0 | Visual Studio 2026 18.0 / .NET 10 SDK | - `Opc.Ua.MigrationAnalyzer.dll` — the analyzer assembly. References **only** diff --git a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj index 43dbe6a7b9..3a61a8e78f 100644 --- a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj +++ b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj @@ -73,11 +73,8 @@ - - - - - - - $(RoslynApiVersionVS2022) - - - diff --git a/tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md b/tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md index 0dcceb8069..51d169bbed 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md +++ b/tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md @@ -21,12 +21,12 @@ just reference `Opc.Ua.Core` and `Opc.Ua.Types`). ## Supported hosts -The package ships one build of the generator per Roslyn API version; -the .NET SDK automatically loads the one matching its compiler. +The package ships the generator under a Roslyn-versioned analyzer folder. +The .NET SDK loads it when its compiler supports that Roslyn API and +ignores it otherwise, so an older host cleanly skips the generator. | Roslyn API | Minimum host | | --- | --- | -| 4.8 | Visual Studio 2022 17.8 / .NET 8 SDK | | 5.0 | Visual Studio 2026 18.0 / .NET 10 SDK | ## Additional documentation diff --git a/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj b/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj index c034de68b2..36d37102ba 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj +++ b/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj @@ -16,8 +16,6 @@ - diff --git a/tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_8/Opc.Ua.SourceGeneration.Stack.Roslyn4_8.csproj b/tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_8/Opc.Ua.SourceGeneration.Stack.Roslyn4_8.csproj deleted file mode 100644 index 5cf38e1964..0000000000 --- a/tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_8/Opc.Ua.SourceGeneration.Stack.Roslyn4_8.csproj +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - $(RoslynApiVersionVS2022) - - - diff --git a/tools/RoslynVariants.props b/tools/RoslynVariants.props index 41fedda03a..2346783674 100644 --- a/tools/RoslynVariants.props +++ b/tools/RoslynVariants.props @@ -1,21 +1,38 @@ - - 4.8.0 5.0.0 - roslyn$(RoslynApiVersionVS2022.Substring(0, $(RoslynApiVersionVS2022.LastIndexOf('.')))) roslyn$(RoslynApiVersionVS2026.Substring(0, $(RoslynApiVersionVS2026.LastIndexOf('.')))) diff --git a/tools/SourceGeneration.slnx b/tools/SourceGeneration.slnx index 41fbfaf31b..835838f9f0 100644 --- a/tools/SourceGeneration.slnx +++ b/tools/SourceGeneration.slnx @@ -3,10 +3,8 @@ - - diff --git a/tools/SourceGeneratorPack.targets b/tools/SourceGeneratorPack.targets index ad0d78e564..733d508c2c 100644 --- a/tools/SourceGeneratorPack.targets +++ b/tools/SourceGeneratorPack.targets @@ -9,8 +9,15 @@ The payload is collected by globbing each variant's output directory rather than by enumerating assemblies, so any package or project added to a generator's dependency tree flows into every Roslyn folder without touching - a project file. Roslyn host assemblies are excluded because the compiler - already provides them and loading a second copy breaks the analyzer host. + a project file. + + Assemblies the Roslyn host itself supplies are excluded. Shipping a second + copy of Microsoft.CodeAnalysis*, System.Collections.Immutable or + System.Reflection.Metadata makes the analyzer load context bind a different + identity for types the compiler passes across its own API surface (most + visibly ImmutableArray), which fails at generator-initialization time with + MissingMethodException - reported as warning CS8784, i.e. silently no + generated code. See tools/RoslynVariants.props for the full rationale. The `ProjectReference` entries exist purely for build ordering and NuGet graph correctness. `ReferenceOutputAssembly` is off, the assemblies are @@ -64,18 +71,31 @@ <_SourceGeneratorVariantTargetFramework>netstandard2.0 + <_SourceGeneratorVariantBin>bin\$(Configuration)\$(_SourceGeneratorVariantTargetFramework) <_SourceGeneratorVariantAssembly - Include="%(SourceGeneratorVariant.RootDir)%(SourceGeneratorVariant.Directory)bin\$(Configuration)\$(_SourceGeneratorVariantTargetFramework)\*.dll" - Exclude="%(SourceGeneratorVariant.RootDir)%(SourceGeneratorVariant.Directory)bin\$(Configuration)\$(_SourceGeneratorVariantTargetFramework)\Microsoft.CodeAnalysis*.dll"> + Include="%(SourceGeneratorVariant.RootDir)%(SourceGeneratorVariant.Directory)$(_SourceGeneratorVariantBin)\*.dll" + Exclude="%(SourceGeneratorVariant.RootDir)%(SourceGeneratorVariant.Directory)$(_SourceGeneratorVariantBin)\Microsoft.CodeAnalysis*.dll; + %(SourceGeneratorVariant.RootDir)%(SourceGeneratorVariant.Directory)$(_SourceGeneratorVariantBin)\System.Collections.Immutable.dll; + %(SourceGeneratorVariant.RootDir)%(SourceGeneratorVariant.Directory)$(_SourceGeneratorVariantBin)\System.Reflection.Metadata.dll"> %(SourceGeneratorVariant.RoslynAnalyzerFolder) analyzers/dotnet/%(_SourceGeneratorVariantAssembly.RoslynAnalyzerFolder)/cs/%(_SourceGeneratorVariantAssembly.Filename)%(_SourceGeneratorVariantAssembly.Extension) - + + + <_SourceGeneratorProducedFolders>@(_SourceGeneratorVariantAssembly->'%(RoslynAnalyzerFolder)'->Distinct(), ';') + + + <_SourceGeneratorVariantWithoutAssets Include="@(SourceGeneratorVariant)" + Condition="!$(_SourceGeneratorProducedFolders.Contains('%(SourceGeneratorVariant.RoslynAnalyzerFolder)'))" /> + + From f2f10692cd8d50957a6bdda4fa97d958df55e866 Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 08:20:06 +0200 Subject: [PATCH 5/6] Fix docker restore and bind the analyzer closure to the Roslyn 5.0 runtime Two fixes. Docker build (refserver job on run 30560951546) failed with NuGet.targets(198,5): error : Invalid framework identifier ''. [/src/samples/ConsoleReferenceServer/ConsoleReferenceServer.csproj] The Dockerfile restores from a hand-listed set of csproj files plus `COPY *.*` for the repository root. The generator projects now import shared MSBuild logic that was in neither list, so their TargetFramework evaluated empty and every project referencing them failed to restore. Reproduced by replaying the Dockerfile's copy list into a scratch tree, and confirmed by deleting the three files again afterwards. - Copy tools/SourceGeneratorVariant.targets and both *.Sources.targets into the restore layer. - Move tools/RoslynVariants.props to the repository root as roslyn.props. Directory.Packages.props imports it, so keeping it under tools/ meant any partial-copy context that takes only the root files - which is exactly what the sample Dockerfiles do - could not restore. It now sits with common.props, targets.props and version.props where the other shared props live. Second, the roslyn5.0 payload only worked by accident. Microsoft.CodeAnalysis 5.0.0 depends on System.Collections.Immutable 9.0.0, but the central pin put 10.0.0.0 into the whole analyzer closure - the generator, Opc.Ua.SourceGeneration.Core and Opc.Ua.Types. .NET satisfies a reference from a higher assembly version but never from a lower one, so the band worked only because every host that exists today happens to carry 10.x: the .NET 10 SDK supplies it from the shared framework (it is not in Roslyn/bincore) and Visual Studio 2026's csc carries 10.0.0.1. A host on the band's declared floor would have failed the same way the removed roslyn4.8 payload did. The pin now derives from RoslynRuntimeVersionVS2026 in roslyn.props, so the closure is 9.0.0.0 throughout and matches what Microsoft.CodeAnalysis 5.0.0 itself references. Verified by scanning assembly references in the packed payload. Deriving the pin from the band rather than leaving a loose literal is what stops the closure drifting above the host floor again. Opc.Ua.Core.Schema.Tests and Opc.Ua.PubSub.Schema.Tests pull System.Collections.Immutable >= 10.0.5 transitively through JsonSchema.Net; they are not part of the analyzer closure, so they override the pin locally. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ed53262-5cfa-429a-820e-bf6146a00d8b --- Directory.Build.targets | 2 +- Directory.Packages.props | 6 +-- docs/DeveloperGuide.md | 4 +- roslyn.props | 46 +++++++++++++++++++ samples/ConsoleReferenceServer/Dockerfile | 5 ++ .../Opc.Ua.Core.Schema.Tests.csproj | 4 ++ .../Opc.Ua.PubSub.Schema.Tests.csproj | 4 ++ .../Opc.Ua.MigrationAnalyzer.CodeFixer.csproj | 2 +- .../Opc.Ua.MigrationAnalyzer.Generator.csproj | 2 +- .../Opc.Ua.MigrationAnalyzer.csproj | 2 +- .../Opc.Ua.SourceGeneration.Pack.csproj | 2 +- .../Opc.Ua.SourceGeneration.Stack.Pack.csproj | 2 +- .../Opc.Ua.SourceGeneration.Stack.csproj | 2 +- .../Opc.Ua.SourceGeneration.csproj | 2 +- tools/RoslynVariants.props | 38 --------------- tools/SourceGeneratorPack.targets | 2 +- 16 files changed, 73 insertions(+), 52 deletions(-) create mode 100644 roslyn.props delete mode 100644 tools/RoslynVariants.props diff --git a/Directory.Build.targets b/Directory.Build.targets index 8c8e80b0b4..011a5b5e17 100644 --- a/Directory.Build.targets +++ b/Directory.Build.targets @@ -92,7 +92,7 @@ Assemblies the compiler itself supplies are excluded. Handing the compiler a second copy of Microsoft.CodeAnalysis*, System.Collections.Immutable or System.Reflection.Metadata splits the identity of types that cross Roslyn's own API surface and breaks generator - initialization. See tools/RoslynVariants.props. + initialization. See roslyn.props. --> diff --git a/Directory.Packages.props b/Directory.Packages.props index d65df08e14..8a5ad32376 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ - + true - - + + diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index d948f54b97..d2e0a77024 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -52,7 +52,7 @@ Notes: - **Offline / restricted networks.** `NuGetAudit` is enabled and fails the build with `NU1900` when it cannot reach the audit service. If you build offline, pass `-p:NuGetAudit=false`. - **Source generators are consumed as project references.** Projects that use the in-repo generators reference `tools/Opc.Ua.SourceGeneration[.Stack]` with `OutputItemType=Analyzer`. MSBuild only hands the compiler the generator assembly itself, so `Directory.Build.targets` adds the generator's runtime closure (its output directory, minus the Roslyn host assemblies) as `Analyzer` items — the same payload the generator NuGet packages ship under `analyzers/dotnet//cs`. Without it the generators cannot resolve their dependencies and fail to initialise with `CS8784`. -- **Analyzers and generators are shipped under a Roslyn-versioned analyzer folder.** `tools/RoslynVariants.props` pins the Roslyn API version, and each generator has a `*.Pack` project that ships it under `analyzers/dotnet/roslyn./cs`. See [Repository layout](#repository-layout) and the [support matrix](#supported-analyzer-and-source-generator-hosts). Because the repository's own projects consume that same build, **building this repository requires a Roslyn 5.x host** (the .NET 10 SDK or Visual Studio 2026). +- **Analyzers and generators are shipped under a Roslyn-versioned analyzer folder.** `roslyn.props` pins the Roslyn API version, and each generator has a `*.Pack` project that ships it under `analyzers/dotnet/roslyn./cs`. See [Repository layout](#repository-layout) and the [support matrix](#supported-analyzer-and-source-generator-hosts). Because the repository's own projects consume that same build, **building this repository requires a Roslyn 5.x host** (the .NET 10 SDK or Visual Studio 2026). ## Running tests @@ -241,7 +241,7 @@ The analyzer and source generator packages ship under `analyzers/dotnet/roslyn **Adding a down-level band is not just another entry in that file.** The analyzer closure — the generator, `Opc.Ua.SourceGeneration.Core` **and** `Opc.Ua.Types` — must bind against the Roslyn host's own `System.Collections.Immutable` and `System.Reflection.Metadata`, whose assembly versions are fixed per band (4.8 → 7.0.0.0, 4.14 → 8.0.0.0, 5.0 → 9.0.0.0). `Directory.Packages.props` pins both centrally with transitive pinning on, so today the whole closure references 10.0.0.0. On an older band the compiler cannot satisfy that: the generator either fails to load (`FileNotFoundException`) or, if a copy is shipped alongside, binds a second `ImmutableArray` identity and dies with `MissingMethodException` on its first Roslyn call. Both surface only as warning `CS8784`, so the consumer silently gets no generated code. A down-level band therefore requires building that entire closure against the band's package versions, which is why `validate-source-generator-packages.ps1` fails any package that ships `Microsoft.CodeAnalysis*`, `System.Collections.Immutable` or `System.Reflection.Metadata`. diff --git a/roslyn.props b/roslyn.props new file mode 100644 index 0000000000..a78f063a3d --- /dev/null +++ b/roslyn.props @@ -0,0 +1,46 @@ + + + + + 5.0.0 + + 9.0.0 + + + roslyn$(RoslynApiVersionVS2026.Substring(0, $(RoslynApiVersionVS2026.LastIndexOf('.')))) + + diff --git a/samples/ConsoleReferenceServer/Dockerfile b/samples/ConsoleReferenceServer/Dockerfile index f7912932e7..a6d3d2c7da 100644 --- a/samples/ConsoleReferenceServer/Dockerfile +++ b/samples/ConsoleReferenceServer/Dockerfile @@ -7,9 +7,14 @@ WORKDIR /src # copy csproj and restore as distinct layers COPY *.* . COPY ["samples/ConsoleReferenceServer/ConsoleReferenceServer.csproj", "samples/ConsoleReferenceServer/"] +# The generator projects import shared MSBuild logic; restore fails with +# "Invalid framework identifier ''" if these are not part of the restore layer. +COPY ["tools/SourceGeneratorVariant.targets", "tools/"] COPY ["tools/Opc.Ua.SourceGeneration.Core/Opc.Ua.SourceGeneration.Core.csproj", "tools/Opc.Ua.SourceGeneration.Core/"] COPY ["tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj", "tools/Opc.Ua.SourceGeneration.Stack/"] +COPY ["tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.Sources.targets", "tools/Opc.Ua.SourceGeneration.Stack/"] COPY ["tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj", "tools/Opc.Ua.SourceGeneration/"] +COPY ["tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.Sources.targets", "tools/Opc.Ua.SourceGeneration/"] COPY ["src/Opc.Ua.Core.Types/Opc.Ua.Core.Types.csproj", "src/Opc.Ua.Core.Types/"] COPY ["src/Opc.Ua.Core/Opc.Ua.Core.csproj", "src/Opc.Ua.Core/"] COPY ["src/Opc.Ua.Core.Schema/Opc.Ua.Core.Schema.csproj", "src/Opc.Ua.Core.Schema/"] diff --git a/tests/Opc.Ua.Core.Schema.Tests/Opc.Ua.Core.Schema.Tests.csproj b/tests/Opc.Ua.Core.Schema.Tests/Opc.Ua.Core.Schema.Tests.csproj index 8f6d6521b7..026b1fe8e2 100644 --- a/tests/Opc.Ua.Core.Schema.Tests/Opc.Ua.Core.Schema.Tests.csproj +++ b/tests/Opc.Ua.Core.Schema.Tests/Opc.Ua.Core.Schema.Tests.csproj @@ -24,6 +24,10 @@ + + diff --git a/tests/Opc.Ua.PubSub.Schema.Tests/Opc.Ua.PubSub.Schema.Tests.csproj b/tests/Opc.Ua.PubSub.Schema.Tests/Opc.Ua.PubSub.Schema.Tests.csproj index 20bb9e9b52..3b38ef4d95 100644 --- a/tests/Opc.Ua.PubSub.Schema.Tests/Opc.Ua.PubSub.Schema.Tests.csproj +++ b/tests/Opc.Ua.PubSub.Schema.Tests/Opc.Ua.PubSub.Schema.Tests.csproj @@ -11,6 +11,10 @@ + + diff --git a/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.csproj b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.csproj index 37d14622e4..8e48372885 100644 --- a/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.csproj +++ b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer/Opc.Ua.MigrationAnalyzer.CodeFixer.csproj @@ -6,7 +6,7 @@ Studio 2022 payload from the same sources. --> - + $(RoslynApiVersionVS2026) - + $(RoslynApiVersionVS2026) diff --git a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj index 3a61a8e78f..8b2fa8a504 100644 --- a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj +++ b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj @@ -6,7 +6,7 @@ payload from the same sources. --> - + $(RoslynApiVersionVS2026) diff --git a/tools/Opc.Ua.SourceGeneration.Pack/Opc.Ua.SourceGeneration.Pack.csproj b/tools/Opc.Ua.SourceGeneration.Pack/Opc.Ua.SourceGeneration.Pack.csproj index c506dd717c..ebe105babe 100644 --- a/tools/Opc.Ua.SourceGeneration.Pack/Opc.Ua.SourceGeneration.Pack.csproj +++ b/tools/Opc.Ua.SourceGeneration.Pack/Opc.Ua.SourceGeneration.Pack.csproj @@ -5,7 +5,7 @@ its compiler supports. --> - + netstandard2.0 $(PackagePrefix).Opc.Ua.SourceGeneration diff --git a/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj b/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj index 36d37102ba..df173182c6 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj +++ b/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj @@ -5,7 +5,7 @@ its compiler supports. --> - + netstandard2.0 $(PackagePrefix).Opc.Ua.SourceGeneration.Stack diff --git a/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj b/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj index a51353b631..a9c3b51b40 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj +++ b/tools/Opc.Ua.SourceGeneration.Stack/Opc.Ua.SourceGeneration.Stack.csproj @@ -8,7 +8,7 @@ ships both. --> - + $(RoslynApiVersionVS2026) diff --git a/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj b/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj index ec57a77bc9..8178b24299 100644 --- a/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj +++ b/tools/Opc.Ua.SourceGeneration/Opc.Ua.SourceGeneration.csproj @@ -7,7 +7,7 @@ payload from the same sources, and Opc.Ua.SourceGeneration.Pack ships both. --> - + $(RoslynApiVersionVS2026) diff --git a/tools/RoslynVariants.props b/tools/RoslynVariants.props deleted file mode 100644 index 2346783674..0000000000 --- a/tools/RoslynVariants.props +++ /dev/null @@ -1,38 +0,0 @@ - - - - - 5.0.0 - - - roslyn$(RoslynApiVersionVS2026.Substring(0, $(RoslynApiVersionVS2026.LastIndexOf('.')))) - - diff --git a/tools/SourceGeneratorPack.targets b/tools/SourceGeneratorPack.targets index 733d508c2c..bf33b187f9 100644 --- a/tools/SourceGeneratorPack.targets +++ b/tools/SourceGeneratorPack.targets @@ -17,7 +17,7 @@ identity for types the compiler passes across its own API surface (most visibly ImmutableArray), which fails at generator-initialization time with MissingMethodException - reported as warning CS8784, i.e. silently no - generated code. See tools/RoslynVariants.props for the full rationale. + generated code. See roslyn.props for the full rationale. The `ProjectReference` entries exist purely for build ordering and NuGet graph correctness. `ReferenceOutputAssembly` is off, the assemblies are From 349a0c1f8534b773c212d4e552065ca0a69208ea Mon Sep 17 00:00:00 2001 From: Marc Date: Fri, 31 Jul 2026 10:14:07 +0200 Subject: [PATCH 6/6] Add a Roslyn 4.14 analyzer band for Visual Studio 2022 The generator and migration-analyzer packages shipped only `analyzers/dotnet/roslyn5.0/cs`. The .NET SDK loads the highest band its compiler supports and silently skips anything above it, so every consumer on Visual Studio 2022 / .NET 9 SDK got no diagnostics and no generated code, with only warning CS9057 to show for it. Ship a second band built against Microsoft.CodeAnalysis 4.14. That version depends on the same System.Collections.Immutable 9.0.0 as 5.0.0 and its csc runs on net9.0, so the two bands share $(RoslynRuntimeVersion) and only the five Roslyn-referencing assemblies need a second build - Opc.Ua.Types and Opc.Ua.SourceGeneration.Core are compiled once and serve both. Nothing else in the repository exercises the down-level payload, and every way it can be wrong is reported as a compiler *warning*, so a broken band would ship silently. validate-source-generator-packages.ps1 therefore now runs the packed roslyn4.14 payload through a real Microsoft.Net.Compilers.Toolset 4.14.0 csc and asserts both that no CS9057/CS8784/CS8032/CS8034 is emitted and that /reportanalyzer lists the generator as having executed - absence of complaints alone would also pass if the generator were never handed to the compiler. The migration analyzer, whose payload comes from a hand-written nuspec rather than SourceGeneratorPack.targets, gets the same treatment plus a band/assembly manifest check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ed53262-5cfa-429a-820e-bf6146a00d8b --- .agents/skills/opcua-v20-migration/SKILL.md | 9 +- .../references/compatibility-matrix.md | 34 +-- .../references/known-gaps.md | 6 +- .../validate-source-generator-packages.ps1 | 208 +++++++++++++++++- Directory.Packages.props | 4 +- UA.slnx | 5 + docs/DeveloperGuide.md | 5 +- roslyn.props | 25 ++- tools/MigrationAnalyzer.slnx | 5 + ...rationAnalyzer.CodeFixer.Roslyn4_14.csproj | 12 + ...rationAnalyzer.Generator.Roslyn4_14.csproj | 12 + ...Opc.Ua.MigrationAnalyzer.Roslyn4_14.csproj | 12 + tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md | 1 + .../Opc.Ua.MigrationAnalyzer.csproj | 8 +- .../Opc.Ua.MigrationAnalyzer.nuspec | 3 + .../NugetREADME.md | 1 + .../Opc.Ua.SourceGeneration.Pack.csproj | 2 + .../Opc.Ua.SourceGeneration.Roslyn4_14.csproj | 14 ++ .../NugetREADME.md | 1 + .../Opc.Ua.SourceGeneration.Stack.Pack.csproj | 2 + ...a.SourceGeneration.Stack.Roslyn4_14.csproj | 12 + tools/SourceGeneration.slnx | 2 + 22 files changed, 351 insertions(+), 32 deletions(-) create mode 100644 tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_14/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_14.csproj create mode 100644 tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_14/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_14.csproj create mode 100644 tools/Opc.Ua.MigrationAnalyzer.Roslyn4_14/Opc.Ua.MigrationAnalyzer.Roslyn4_14.csproj create mode 100644 tools/Opc.Ua.SourceGeneration.Roslyn4_14/Opc.Ua.SourceGeneration.Roslyn4_14.csproj create mode 100644 tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_14/Opc.Ua.SourceGeneration.Stack.Roslyn4_14.csproj diff --git a/.agents/skills/opcua-v20-migration/SKILL.md b/.agents/skills/opcua-v20-migration/SKILL.md index 6da44699a9..8d813806de 100644 --- a/.agents/skills/opcua-v20-migration/SKILL.md +++ b/.agents/skills/opcua-v20-migration/SKILL.md @@ -17,7 +17,8 @@ description: | errors after upgrading to v20". license: MIT compatibility: | - Requires .NET SDK 10.0.300+, a C# project, and resolvable access to the + Requires .NET SDK 9.0.100+ (10.0.300+ for the `dotnet format analyzers` + auto-fix pass), a C# project, and resolvable access to the OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer NuGet package (v2.0.*-*). IDE auto-fixes need a Workspaces-aware host (Visual Studio, Rider, or `dotnet format`). Generator + analyzers load in csc.exe too. @@ -166,9 +167,9 @@ The single `OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer` NuGet contains | Component | Where | Loaded by | Purpose | |---|---|---|---| -| `Opc.Ua.MigrationAnalyzer.dll` | `analyzers/dotnet/roslyn5.0/cs/` | csc.exe and IDE | 19 `DiagnosticAnalyzer`s (UA0001–UA0022). Targets the Roslyn 5.0 API, no `Workspaces` reference, csc-safe. | -| `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` | `analyzers/dotnet/roslyn5.0/cs/` | Workspaces-aware hosts only (Visual Studio, Rider, `dotnet format analyzers`) | 14 `CodeFixProvider`s. | -| `Opc.Ua.MigrationAnalyzer.Generator.dll` | `analyzers/dotnet/roslyn5.0/cs/` | csc.exe and IDE | `IIncrementalGenerator` that emits `internal sealed [Obsolete] class Collection : List` shims into the consumer compilation for every `Collection` reference that fails to bind. | +| `Opc.Ua.MigrationAnalyzer.dll` | `analyzers/dotnet/roslyn4.14/cs/` and `roslyn5.0/cs/` | csc.exe and IDE | 19 `DiagnosticAnalyzer`s (UA0001–UA0022). No `Workspaces` reference, csc-safe. | +| `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` | `analyzers/dotnet/roslyn4.14/cs/` and `roslyn5.0/cs/` | Workspaces-aware hosts only (Visual Studio, Rider, `dotnet format analyzers`) | 14 `CodeFixProvider`s. | +| `Opc.Ua.MigrationAnalyzer.Generator.dll` | `analyzers/dotnet/roslyn4.14/cs/` and `roslyn5.0/cs/` | csc.exe and IDE | `IIncrementalGenerator` that emits `internal sealed [Obsolete] class Collection : List` shims into the consumer compilation for every `Collection` reference that fails to bind. | | `Opc.Ua.MigrationAnalyzer.Core.dll` | `lib//` × 6 TFMs (`net472`, `net48`, `netstandard2.1`, `net8.0`, `net9.0`, `net10.0`) | Runtime | Re-supplies the obsolete extension surface 2.0 moved or removed so 1.5.378 call sites continue to compile with `[Obsolete]` warnings. | ### The 19 analyzer rules at a glance diff --git a/.agents/skills/opcua-v20-migration/references/compatibility-matrix.md b/.agents/skills/opcua-v20-migration/references/compatibility-matrix.md index f433a16e3c..e9ac561dc7 100644 --- a/.agents/skills/opcua-v20-migration/references/compatibility-matrix.md +++ b/.agents/skills/opcua-v20-migration/references/compatibility-matrix.md @@ -8,8 +8,8 @@ correctly. | Component | Minimum | Recommended | Notes | |---|---|---|---| -| .NET SDK | 10.0.300 | latest 10.x | Earlier SDKs ship older Roslyn that has known incremental-generator bugs | -| `dotnet format` | bundled with SDK 10.0.300+ | latest 10.x | The `analyzers` subcommand is what applies UA0002…UA0022 fixes | +| .NET SDK | 9.0.100 | latest 10.x | 9.0.100 ships Roslyn 4.14, which the package's `roslyn4.14` band targets. Earlier SDKs ship older Roslyn that has known incremental-generator bugs | +| `dotnet format` | bundled with SDK 10.0.300+ | latest 10.x | The `analyzers` subcommand is what applies UA0002…UA0022 fixes. Diagnostics alone need only SDK 9.0.100; the auto-fix pass needs 10.0.300+ | | C# language version | 13 | 14 (default in SDK 10) | Required for `extension` keyword the runtime shim uses | | Consumer project SDK | `Microsoft.NET.Sdk` (SDK-style) | same | Pre-SDK MSBuild XML projects (`xmlns="…/2003"`) cannot install the analyzer — see [`known-gaps.md` G1](known-gaps.md#g1--legacy-net-framework-winforms-projects-in-pre-sdk-msbuild-xml) | @@ -37,19 +37,27 @@ and SDK-style csproj rewrites. ## Roslyn API targeting (internal) -The migration package's Roslyn components are built against the **stable -analyzer API surface**: +Every Roslyn component ships twice, once per band, under +`analyzers/dotnet//cs/`. The .NET SDK picks the highest band its compiler +supports and ignores the rest, so a single package serves both hosts: -| DLL | Roslyn API target | Why | +| Band | Built against | Loaded by | |---|---|---| -| `Opc.Ua.MigrationAnalyzer.dll` | `Microsoft.CodeAnalysis.CSharp 4.14.0` | csc-safe (loads in `csc.exe`); Workspaces-free | -| `Opc.Ua.MigrationAnalyzer.Generator.dll` | `Microsoft.CodeAnalysis.CSharp 4.14.0` | csc-safe; needed for `IIncrementalGenerator` | -| `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` | `Microsoft.CodeAnalysis.CSharp 4.14.0` + `Microsoft.CodeAnalysis.CSharp.Workspaces 4.14.0` | Loaded only in Workspaces-aware hosts (Visual Studio, `dotnet format`) | - -> The repo's `Directory.Packages.props` pins all `Microsoft.CodeAnalysis.*` -> packages to `4.14.0`. This is the **stable analyzer API**, not the -> csc-internal version that the .NET SDK ships (which is `5.x` in SDK 10). -> Analyzers built against 5.x silently fail to load in csc.exe — see +| `roslyn4.14` | `Microsoft.CodeAnalysis.CSharp 4.14.0` | Visual Studio 2022 17.14+ / .NET 9 SDK | +| `roslyn5.0` | `Microsoft.CodeAnalysis.CSharp 5.0.0` | Visual Studio 2026 18.0+ / .NET 10 SDK | + +Within a band, the three components differ only in what else they reference: + +| DLL | Extra reference | Why | +|---|---|---| +| `Opc.Ua.MigrationAnalyzer.dll` | none | csc-safe (loads in `csc.exe`); Workspaces-free | +| `Opc.Ua.MigrationAnalyzer.Generator.dll` | none | csc-safe; needed for `IIncrementalGenerator` | +| `Opc.Ua.MigrationAnalyzer.CodeFixer.dll` | `Microsoft.CodeAnalysis.CSharp.Workspaces` | Loaded only in Workspaces-aware hosts (Visual Studio, `dotnet format`) | + +> An analyzer built against a **newer** Roslyn than the host is skipped silently +> with warning `CS9057` — the consumer simply gets no diagnostics and no +> generated shims. That is why the package ships a band per supported host +> rather than a single `analyzers/dotnet/cs/` folder; see > [`known-gaps.md` G9](known-gaps.md#g9--analyzer-silently-doesnt-load-under-cscexe-historical-fixed). ## Verifying analyzer + generator loaded under csc.exe diff --git a/.agents/skills/opcua-v20-migration/references/known-gaps.md b/.agents/skills/opcua-v20-migration/references/known-gaps.md index 5c8883a151..624d52616e 100644 --- a/.agents/skills/opcua-v20-migration/references/known-gaps.md +++ b/.agents/skills/opcua-v20-migration/references/known-gaps.md @@ -148,8 +148,10 @@ code-fixers in one assembly, which transitively referenced swallowed the Workspaces load failure → zero diagnostics across all samples even though `/analyzer:` was on the csc command line. -**Status:** fixed (commit `861fa6ee1`). Analyzer split into two DLLs; analyzer -DLL is Workspaces-free + targets stable Roslyn 4.14 API. +**Status:** fixed (commit `861fa6ee1`). Analyzer split into two DLLs; the analyzer +DLL is Workspaces-free. Each DLL now ships once per Roslyn band (`roslyn4.14`, +`roslyn5.0`) so the host always loads a build it can run — see +[`compatibility-matrix.md`](compatibility-matrix.md#roslyn-api-targeting-internal). **Verification:** if you ever suspect the analyzer isn't firing, run with `/p:ReportAnalyzer=true` and confirm `Opc.Ua.MigrationAnalyzer` and diff --git a/.azurepipelines/validate-source-generator-packages.ps1 b/.azurepipelines/validate-source-generator-packages.ps1 index b0618b38ec..c3dc5629bd 100644 --- a/.azurepipelines/validate-source-generator-packages.ps1 +++ b/.azurepipelines/validate-source-generator-packages.ps1 @@ -113,8 +113,9 @@ function Test-PackageContents ForEach-Object { $_.Substring($analyzerRoot.Length).Split("/")[0] } | Sort-Object -Unique) Assert-Condition ( - $roslynFolders.Count -ge 1 - ) "Package '$($Package.Id)' ships no analyzer folder." + $roslynFolders.Count -ge 2 + ) ("Package '$($Package.Id)' must ship one analyzer folder per supported Roslyn " + + "band; found: $($roslynFolders -join ', ').") Assert-Condition ( @($roslynFolders | Where-Object { $_ -notmatch "^roslyn[0-9]+\.[0-9]+$" }).Count -eq 0 ) ("Package '$($Package.Id)' analyzer folders must be named 'roslyn.'; " + @@ -164,6 +165,54 @@ function Test-PackageContents ) "Package '$($Package.Id)' must carry its analyzer runtime closure privately." } +function Test-AnalyzerBands +{ + <# + .SYNOPSIS + Asserts a package ships the expected analyzer assemblies in every Roslyn band. + + .DESCRIPTION + For packages that Test-PackageContents cannot check because they also ship lib/ + assemblies and carry dependencies - the migration analyzer, whose payload comes + from a hand-written nuspec rather than SourceGeneratorPack.targets. A band added + to one half of that nuspec and not the other is invisible at pack time. + #> + param( + [Parameter(Mandatory = $true)] + [PSCustomObject] $Package, + + [Parameter(Mandatory = $true)] + [string[]] $ExpectedFolders, + + [Parameter(Mandatory = $true)] + [string[]] $ExpectedAssemblies + ) + + $analyzerRoot = "analyzers/dotnet/" + foreach ($folder in $ExpectedFolders) + { + foreach ($assembly in $ExpectedAssemblies) + { + $path = "$analyzerRoot$folder/cs/$assembly" + Assert-Condition ( + $Package.Entries -contains $path + ) "Package '$($Package.Id)' is missing '$path'." + } + } + + $actualFolders = @($Package.Entries | + Where-Object { + $_.StartsWith($analyzerRoot, [StringComparison]::OrdinalIgnoreCase) -and + $_.EndsWith(".dll", [StringComparison]::OrdinalIgnoreCase) + } | + ForEach-Object { $_.Substring($analyzerRoot.Length).Split("/")[0] } | + Sort-Object -Unique) + Assert-Condition ( + @(Compare-Object $actualFolders @($ExpectedFolders | Sort-Object -Unique)).Count -eq 0 + ) ("Package '$($Package.Id)' analyzer bands are '$($actualFolders -join ', ')' but " + + "'$(($ExpectedFolders | Sort-Object -Unique) -join ', ')' was expected.") +} + function Test-PackageBuildProps { <# @@ -358,6 +407,147 @@ public static class GeneratedModelProbe "standalone NodeSet consumer.") } +function Test-DownlevelAnalyzerHost +{ + <# + .SYNOPSIS + Loads the down-level analyzer payload in a real compiler of that Roslyn band. + + .DESCRIPTION + The repository builds against the newest band, so nothing else here ever executes + the down-level payload. Every way it can be wrong is reported by the compiler as a + *warning*, which means a broken band ships silently and the consumer simply gets no + generated code: + + CS9057 - built against a newer compiler than the host, so it is skipped entirely. + CS8784 - loaded but failed to initialize, e.g. MissingMethodException because a + shipped System.Collections.Immutable bound a second ImmutableArray. + CS8032 - the analyzer instance could not be created at all. + + So run the matching csc over the packed payload and fail on any of them. Absence of + diagnostics is necessary but not sufficient - a generator that is never handed to + the compiler also produces none - so /reportanalyzer is used to additionally assert + that the generator positively executed. + #> + param( + [Parameter(Mandatory = $true)] + [PSCustomObject] $Package, + + [Parameter(Mandatory = $true)] + [string] $ValidationRoot, + + [Parameter(Mandatory = $true)] + [string] $RoslynFolder, + + [Parameter(Mandatory = $true)] + [string] $CompilerToolsetVersion, + + [Parameter(Mandatory = $true)] + [string] $GeneratorAssembly + ) + + $root = Join-Path $ValidationRoot "downlevel-$RoslynFolder-$($Package.Id)" + $analyzerDirectory = Join-Path $root "analyzers" + # Per package, and emptied first: a shared directory would leak the previous + # package's assemblies into this compilation and the assertion below would pass on + # someone else's generator. + Remove-Item -Path $analyzerDirectory -Recurse -Force -ErrorAction SilentlyContinue + New-Item -ItemType Directory -Path $analyzerDirectory -Force | Out-Null + + $archive = [IO.Compression.ZipFile]::OpenRead($Package.Path) + try + { + $prefix = "analyzers/dotnet/$RoslynFolder/cs/" + $entries = @($archive.Entries | + Where-Object { $_.FullName.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase) } | + Where-Object { $_.FullName.EndsWith(".dll", [StringComparison]::OrdinalIgnoreCase) }) + Assert-Condition ( + $entries.Count -gt 0 + ) "Package '$($Package.Id)' has no assemblies under '$prefix'." + foreach ($entry in $entries) + { + [IO.Compression.ZipFileExtensions]::ExtractToFile( + $entry, (Join-Path $analyzerDirectory ([IO.Path]::GetFileName($entry.FullName))), $true) + } + } + finally + { + $archive.Dispose() + } + + # `dotnet tool`-free way to get a specific csc: restore the toolset package. + $toolsetProject = Join-Path $root "toolset.csproj" + @" + + + net10.0 + false + + + + + +"@ | Set-Content -Path $toolsetProject -Encoding utf8 + + $packagesPath = Join-Path $ValidationRoot "packages" + Invoke-DotNet @( + "restore", + $toolsetProject, + "--configfile", + (Join-Path $ValidationRoot "NuGet.WithUpstream.Config"), + "--packages", + $packagesPath, + "--nologo" + ) + + $csc = Join-Path $packagesPath ` + "microsoft.net.compilers.toolset/$CompilerToolsetVersion/tasks/netcore/bincore/csc.dll" + Assert-Condition (Test-Path $csc) "csc from Microsoft.Net.Compilers.Toolset $CompilerToolsetVersion not found at '$csc'." + + $sourceFile = Join-Path $root "Probe.cs" + "namespace DownlevelProbe { public class Marker { } }" | Set-Content -Path $sourceFile -Encoding utf8 + + $referenceDirectory = @(Get-ChildItem -Path ( + Join-Path $env:ProgramFiles "dotnet\packs\Microsoft.NETCore.App.Ref") -Directory -ErrorAction SilentlyContinue | + Sort-Object Name -Descending | + ForEach-Object { Get-ChildItem -Path (Join-Path $_.FullName "ref") -Directory -ErrorAction SilentlyContinue } | + Select-Object -First 1) + Assert-Condition ( + $referenceDirectory.Count -eq 1 + ) "Could not locate a Microsoft.NETCore.App reference assembly directory." + + $arguments = @( + $csc, "/noconfig", "/nostdlib+", "/target:library", + "/out:$(Join-Path $root 'probe.dll')", "/reportanalyzer" + ) + $arguments += @(Get-ChildItem (Join-Path $referenceDirectory[0].FullName "*.dll") | + ForEach-Object { "/r:$($_.FullName)" }) + $arguments += @(Get-ChildItem (Join-Path $analyzerDirectory "*.dll") | + ForEach-Object { "/analyzer:$($_.FullName)" }) + $arguments += $sourceFile + + $output = & dotnet @arguments 2>&1 + $loadDiagnostics = @($output | Where-Object { $_ -match "CS9057|CS8784|CS8032|CS8034" }) + Assert-Condition ( + $loadDiagnostics.Count -eq 0 + ) ("Package '$($Package.Id)' analyzer folder '$RoslynFolder' does not load in " + + "Microsoft.Net.Compilers.Toolset $CompilerToolsetVersion. A consumer on that " + + "compiler silently gets no generated code:`n" + ($loadDiagnostics -join "`n")) + + # /reportanalyzer lists every generator the compiler actually ran, so this turns + # "nothing complained" into "the generator executed". + $ran = @($output | Where-Object { $_ -match "(^|\s)$([Regex]::Escape($GeneratorAssembly)), Version=" }) + Assert-Condition ( + $ran.Count -gt 0 + ) ("Package '$($Package.Id)' analyzer folder '$RoslynFolder' reported no execution of " + + "'$GeneratorAssembly' under Microsoft.Net.Compilers.Toolset $CompilerToolsetVersion. " + + "The payload loaded without complaint but the generator never ran:`n" + + ($output -join "`n")) + + Write-Host "Analyzer folder '$RoslynFolder' of '$($Package.Id)' runs in csc $CompilerToolsetVersion." +} + function Invoke-DotNet { param( @@ -493,9 +683,14 @@ Test-ConfigurationPackageIds ( Join-Path $repoRoot "tools\Opc.Ua.SourceGeneration.Stack.Pack\Opc.Ua.SourceGeneration.Stack.Pack.csproj") $modelPackage = Get-PackageInfo "OPCFoundation.NetStandard.Opc.Ua.SourceGeneration" $stackPackage = Get-PackageInfo "OPCFoundation.NetStandard.Opc.Ua.SourceGeneration.Stack" +$migrationPackage = Get-PackageInfo "OPCFoundation.NetStandard.Opc.Ua.MigrationAnalyzer" Test-PackageContents $modelPackage "Opc.Ua.SourceGeneration.dll" Test-PackageContents $stackPackage "Opc.Ua.SourceGeneration.Stack.dll" +Test-AnalyzerBands $migrationPackage @("roslyn4.14", "roslyn5.0") @( + "Opc.Ua.MigrationAnalyzer.dll", + "Opc.Ua.MigrationAnalyzer.CodeFixer.dll", + "Opc.Ua.MigrationAnalyzer.Generator.dll") # Only the model generator exposes MSBuild settings to consumers, so only it ships a # build/.props; the stack generator must not smuggle in stray build/ content. @@ -545,6 +740,15 @@ try Test-CleanConsumer $modelPackage $validationRoot Test-CleanConsumer $stackPackage $validationRoot Test-SourceGeneratingConsumer $modelPackage $validationRoot $repoRoot + # The repository builds against the newest band, so the down-level payload is only + # ever exercised here. Every failure mode is a compiler *warning*, so without this + # a broken band ships silently. + Test-DownlevelAnalyzerHost $modelPackage $validationRoot "roslyn4.14" "4.14.0" "Opc.Ua.SourceGeneration" + Test-DownlevelAnalyzerHost $stackPackage $validationRoot "roslyn4.14" "4.14.0" "Opc.Ua.SourceGeneration.Stack" + # The migration analyzer ships the same two bands from a hand-written nuspec rather + # than SourceGeneratorPack.targets, so its down-level payload has its own way to rot. + Test-DownlevelAnalyzerHost $migrationPackage $validationRoot "roslyn4.14" "4.14.0" ` + "Opc.Ua.MigrationAnalyzer.Generator" } finally { diff --git a/Directory.Packages.props b/Directory.Packages.props index 8a5ad32376..10897fcf1e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -121,8 +121,8 @@ - System.Text.RegularExpressions 4.3.1 fixes GHSA-cmhx-cq75-c4mj --> - - + + diff --git a/UA.slnx b/UA.slnx index 6e6f753e15..5a2706e8e7 100644 --- a/UA.slnx +++ b/UA.slnx @@ -277,6 +277,9 @@ + + + @@ -285,5 +288,7 @@ + + diff --git a/docs/DeveloperGuide.md b/docs/DeveloperGuide.md index d2e0a77024..3ff997063e 100644 --- a/docs/DeveloperGuide.md +++ b/docs/DeveloperGuide.md @@ -239,11 +239,14 @@ The analyzer and source generator packages ship under `analyzers/dotnet/roslyn **Adding a down-level band is not just another entry in that file.** The analyzer closure — the generator, `Opc.Ua.SourceGeneration.Core` **and** `Opc.Ua.Types` — must bind against the Roslyn host's own `System.Collections.Immutable` and `System.Reflection.Metadata`, whose assembly versions are fixed per band (4.8 → 7.0.0.0, 4.14 → 8.0.0.0, 5.0 → 9.0.0.0). `Directory.Packages.props` pins both centrally with transitive pinning on, so today the whole closure references 10.0.0.0. On an older band the compiler cannot satisfy that: the generator either fails to load (`FileNotFoundException`) or, if a copy is shipped alongside, binds a second `ImmutableArray` identity and dies with `MissingMethodException` on its first Roslyn call. Both surface only as warning `CS8784`, so the consumer silently gets no generated code. A down-level band therefore requires building that entire closure against the band's package versions, which is why `validate-source-generator-packages.ps1` fails any package that ships `Microsoft.CodeAnalysis*`, `System.Collections.Immutable` or `System.Reflection.Metadata`. +> **Adding a band below 4.14 is not just another entry in that file.** The analyzer closure — the generator, `Opc.Ua.SourceGeneration.Core` **and** `Opc.Ua.Types` — must bind against the Roslyn host's own `System.Collections.Immutable` and `System.Reflection.Metadata`. .NET satisfies a reference from a *higher* assembly version but never from a lower one, and those assemblies are supplied by the compiler, so the closure must reference the lowest version across every supported band and must never ship a copy of its own. Roslyn 4.14 and 5.0 both depend on 9.0.0, which is why `$(RoslynRuntimeVersion)` in `roslyn.props` drives the central pin and one build of the non-Roslyn closure serves both bands. Going lower — Roslyn 4.8 wants 7.x — would mean building that whole closure, `Opc.Ua.Types` included, a second time. +> +> Get it wrong and the failure is silent: the generator is skipped (`CS9057`), fails to load (`CS8032`) or throws `MissingMethodException` while initializing (`CS8784`) — all *warnings*, so the consumer just gets no generated code. `validate-source-generator-packages.ps1` therefore refuses any package that ships `Microsoft.CodeAnalysis*`, `System.Collections.Immutable` or `System.Reflection.Metadata`, and runs the packed down-level payload through a real compiler of that band. ### Versioning diff --git a/roslyn.props b/roslyn.props index a78f063a3d..05e1dbfa17 100644 --- a/roslyn.props +++ b/roslyn.props @@ -18,29 +18,40 @@ RoslynRuntimeVersion is the critical part. The compiler supplies System.Collections.Immutable and System.Reflection.Metadata itself, and the version it - supplies is fixed by its Roslyn band (4.8 -> 7.x, 4.14 -> 8.x, 5.0 -> 9.x). An analyzer + supplies is fixed by its Roslyn band (4.8 -> 7.x, 4.14 -> 9.x, 5.0 -> 9.x). An analyzer must therefore reference a version at or BELOW its band's - .NET satisfies a reference from a higher assembly version but never from a lower one - and must never ship a copy of its own, because a second copy binds a separate ImmutableArray identity and the generator dies with MissingMethodException on its first Roslyn call. Either failure surfaces only as warning CS8784, so the consumer silently gets no generated code. - Directory.Packages.props pins both packages to RoslynRuntimeVersion and central + Directory.Packages.props pins both packages to $(RoslynRuntimeVersion) and central transitive pinning propagates that through the whole analyzer closure - the generator, Opc.Ua.SourceGeneration.Core and Opc.Ua.Types. Keeping the pin here rather than as a loose version literal is what stops the closure drifting above the band again. - Adding a down-level band is therefore not just another entry in this file: the whole - closure, Opc.Ua.Types included, has to be built against that band's runtime version. + Adding a band below the current floor is therefore not just another entry in this + file. It only stays cheap while the new band shares $(RoslynRuntimeVersion): Roslyn + 4.14 and 5.0 both depend on System.Collections.Immutable 9.0.0, so one build of the + non-Roslyn part of the closure serves both. Going lower - Roslyn 4.8 wants 7.x - + would require building that whole closure, Opc.Ua.Types included, a second time + against the older runtime. --> 5.0.0 - - 9.0.0 + + 4.14.0 + + 9.0.0 roslyn$(RoslynApiVersionVS2026.Substring(0, $(RoslynApiVersionVS2026.LastIndexOf('.')))) + roslyn$(RoslynApiVersionVS2022.Substring(0, $(RoslynApiVersionVS2022.LastIndexOf('.')))) diff --git a/tools/MigrationAnalyzer.slnx b/tools/MigrationAnalyzer.slnx index 6aab4f5046..a57b812439 100644 --- a/tools/MigrationAnalyzer.slnx +++ b/tools/MigrationAnalyzer.slnx @@ -3,15 +3,20 @@ + + + + + diff --git a/tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_14/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_14.csproj b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_14/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_14.csproj new file mode 100644 index 0000000000..a6a0d5624f --- /dev/null +++ b/tools/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_14/Opc.Ua.MigrationAnalyzer.CodeFixer.Roslyn4_14.csproj @@ -0,0 +1,12 @@ + + + + + $(RoslynApiVersionVS2022) + false + + + \ No newline at end of file diff --git a/tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_14/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_14.csproj b/tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_14/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_14.csproj new file mode 100644 index 0000000000..1e80ebe9ce --- /dev/null +++ b/tools/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_14/Opc.Ua.MigrationAnalyzer.Generator.Roslyn4_14.csproj @@ -0,0 +1,12 @@ + + + + + $(RoslynApiVersionVS2022) + false + + + \ No newline at end of file diff --git a/tools/Opc.Ua.MigrationAnalyzer.Roslyn4_14/Opc.Ua.MigrationAnalyzer.Roslyn4_14.csproj b/tools/Opc.Ua.MigrationAnalyzer.Roslyn4_14/Opc.Ua.MigrationAnalyzer.Roslyn4_14.csproj new file mode 100644 index 0000000000..8166158df3 --- /dev/null +++ b/tools/Opc.Ua.MigrationAnalyzer.Roslyn4_14/Opc.Ua.MigrationAnalyzer.Roslyn4_14.csproj @@ -0,0 +1,12 @@ + + + + + $(RoslynApiVersionVS2022) + false + + + \ No newline at end of file diff --git a/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md b/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md index b73a9ac6c8..577ec62525 100644 --- a/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md +++ b/tools/Opc.Ua.MigrationAnalyzer/NugetREADME.md @@ -144,6 +144,7 @@ skips the analyzer rather than failing to load it: | Roslyn API | Minimum host | | --- | --- | +| 4.14 | Visual Studio 2022 17.14 / .NET 9 SDK | | 5.0 | Visual Studio 2026 18.0 / .NET 10 SDK | - `Opc.Ua.MigrationAnalyzer.dll` — the analyzer assembly. References **only** diff --git a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj index 8b2fa8a504..f2452bfbed 100644 --- a/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj +++ b/tools/Opc.Ua.MigrationAnalyzer/Opc.Ua.MigrationAnalyzer.csproj @@ -73,8 +73,11 @@ + + + + + + $(RoslynApiVersionVS2022) + + + diff --git a/tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md b/tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md index 51d169bbed..6a2528b114 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md +++ b/tools/Opc.Ua.SourceGeneration.Stack.Pack/NugetREADME.md @@ -27,6 +27,7 @@ ignores it otherwise, so an older host cleanly skips the generator. | Roslyn API | Minimum host | | --- | --- | +| 4.14 | Visual Studio 2022 17.14 / .NET 9 SDK | | 5.0 | Visual Studio 2026 18.0 / .NET 10 SDK | ## Additional documentation diff --git a/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj b/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj index df173182c6..6104f988ff 100644 --- a/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj +++ b/tools/Opc.Ua.SourceGeneration.Stack.Pack/Opc.Ua.SourceGeneration.Stack.Pack.csproj @@ -16,6 +16,8 @@ + diff --git a/tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_14/Opc.Ua.SourceGeneration.Stack.Roslyn4_14.csproj b/tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_14/Opc.Ua.SourceGeneration.Stack.Roslyn4_14.csproj new file mode 100644 index 0000000000..92b0845500 --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration.Stack.Roslyn4_14/Opc.Ua.SourceGeneration.Stack.Roslyn4_14.csproj @@ -0,0 +1,12 @@ + + + + + $(RoslynApiVersionVS2022) + + + diff --git a/tools/SourceGeneration.slnx b/tools/SourceGeneration.slnx index 835838f9f0..3560284329 100644 --- a/tools/SourceGeneration.slnx +++ b/tools/SourceGeneration.slnx @@ -3,8 +3,10 @@ + +