# Description The two OPC UA source generators were built on `SourceGenerator.Foundations` (SGF), whose only job was getting the generator's dependency closure loaded inside the compiler. It does so with a **process-wide, first-generator-wins `AppDomain.AssemblyResolve` hook backed by a non-concurrent `Dictionary` mutated from `AssemblyLoad` callbacks**. Concurrent compilations in a shared `VBCSCompiler` race it, which is the root cause of the intermittent clean-build failure: ``` CSC : error CS8784: Generator 'StackSourceGeneratorHoist' failed to initialize. ... Exception was of type 'FileNotFoundException' with message 'Could not load file or assembly 'SourceGenerator.Foundations.Contracts, Version=2.0.14.0, ...' ``` The repository already solves that problem deterministically — #4041 made the NuGet packages ship the full closure under `analyzers/dotnet/.../cs`, and `Directory.Build.targets` registers the same closure as `Analyzer` items for in-repo `ProjectReference` consumers. SGF was therefore pure liability and is removed. ## What changed **1. SourceGenerator.Foundations removed.** `ModelSourceGenerator` and `StackSourceGenerator` are plain `[Generator(LanguageNames.CSharp)] IIncrementalGenerator` implementations again. SGF's implicit crash handling is replaced by an explicit `SourceGenerator.Guard` that reports `MODELGEN003` / `STACKGEN003` and rethrows cancellation. Its logger sink was already dead code and is dropped. `Opc.Ua.SourceGeneration.Stack.dll` shrank from **18 MB** (embedded closure) to **29 KB**. **2. Analyzers now ship under Roslyn-versioned folders.** `analyzers/dotnet/roslyn4.14/cs` and `analyzers/dotnet/roslyn5.0/cs` instead of `analyzers/dotnet/cs`, driven by `roslyn.props` at the repository root, with new `*.Pack` projects owning the package identity. The versioned folder matters: a host older than the band **skips** the analyzer instead of loading it and failing inside generator initialization. **3. Dependencies flow automatically.** Both delivery paths discover the payload by globbing the generator's output directory, so adding a package to a generator's dependency tree requires **no** `.csproj`, `.targets`, `.nuspec` or validation-script edit. Verified by temporarily adding a `PackageReference` and watching it appear in the payload with no other file touched. ## Two Roslyn bands: VS2022 and VS2026 Analyzers ship twice, once per band. The .NET SDK loads the highest band its compiler supports and **silently skips** anything above it, so a single-band package leaves every consumer below that band with no diagnostics and no generated code. | Band | Built against | Loaded by | | --- | --- | --- | | `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 | Getting this wrong is invisible, which is most of why it took three attempts. The rule: > An analyzer must reference the **lowest** `System.Collections.Immutable` / `System.Reflection.Metadata` across all hosts it claims to support, and must never ship them. .NET binds a reference *upward* but never downward. Every failure mode is a compiler **warning**: | Diagnostic | Cause | | --- | --- | | `CS9057` | Built against a newer Roslyn than the host, so it is skipped entirely | | `CS8784` | Loaded, then `MissingMethodException` - a shipped S.C.I bound a second `ImmutableArray<T>` | | `CS8032` | Analyzer instance could not be created | This PR first shipped a `roslyn4.8` band that **never loaded** - confirmed against a real Roslyn 4.8 compiler: ``` warning CS8784: Generator 'ModelSourceGenerator' failed to initialize ... MissingMethodException: Method not found: 'IncrementalValueProvider`1<ImmutableArray`1<!!0>> IncrementalValueProviderExtensions.Collect(IncrementalValuesProvider`1<!!0>)' ``` That band was removed, and **this is not a regression**: master had the same defect, pinned to Roslyn 4.14 while referencing `System.Collections.Immutable 10.0.0.0`, so no 4.x host ever worked there either. The band that landed instead is `4.14`, and it is nearly free: `Microsoft.CodeAnalysis 4.14` depends on the **same S.C.I 9.0.0 as 5.0.0** and its `csc` runs on `net9.0`. Both bands therefore share `$(RoslynRuntimeVersion)`, so `Opc.Ua.Types` and `Opc.Ua.SourceGeneration.Core` are compiled **once** and serve both; only the five Roslyn-referencing assemblies get a second build, each from a 12-line project that glob-links the primary sources and overrides `RoslynApiVersion`. Going lower still - 4.8 wants S.C.I 7.x - would mean rebuilding that whole closure, `Opc.Ua.Types` included, and remains out of scope. (`System.Collections.Immutable 7.0.0` also does not resolve in this repository: `NU1603`, 8.0.0 substituted.) **Nothing else in the repository exercises the down-level payload**, and since every failure is a warning, a broken band would ship silently - exactly as the 4.8 one did. So `validate-source-generator-packages.ps1` now restores a real `Microsoft.Net.Compilers.Toolset 4.14.0`, runs its `csc.dll` over the packed `roslyn4.14` payload, and asserts **both** that none of `CS9057`/`CS8784`/`CS8032`/`CS8034` is emitted **and** that `/reportanalyzer` lists the generator as having executed - silence 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 check plus a band/assembly manifest assertion. To stop the trap recurring, `Microsoft.CodeAnalysis*`, `System.Collections.Immutable` and `System.Reflection.Metadata` are excluded from both the package payload and the in-repo analyzer closure, and asserted against in the validation script. `roslyn.props` and `docs/DeveloperGuide.md` document what adding a band requires. ## The shipped package set is unchanged Evaluating `PackageId`/`IsPackable` across every project in `preview-pack.slnx` gives the **same 47 package ids** on `master` and on this branch, and a full Release build plus `dotnet pack --no-build` produces exactly those 47. Only the owning project changed: | Package | master | this PR | | --- | --- | --- | | `…Opc.Ua.SourceGeneration` | `Opc.Ua.SourceGeneration.csproj` | `Opc.Ua.SourceGeneration.Pack.csproj` | | `…Opc.Ua.SourceGeneration.Stack` | `Opc.Ua.SourceGeneration.Stack.csproj` | `Opc.Ua.SourceGeneration.Stack.Pack.csproj` | `.azurepipelines/expected-packages.txt` pins the expected set and the validation script fails when the packed output drifts. ## Two new package checks **`build/<PackageId>.props` naming.** NuGet only auto-imports that exact name, and the file declares every `CompilerVisibleProperty` / `CompilerVisibleItemMetadata` the model generator relies on. It was previously packed as `build/OPCFoundation.Opc.Ua.SourceGeneration.props`, which does **not** match the package id — so **no NuGet consumer ever imported it**, and every `ModelSourceGenerator*` property and `AdditionalFiles` metadata was silently invisible. In-repo projects were unaffected because they import it by relative path. **An end-to-end consumer that actually drives the generator.** The pre-existing clean-consumer check only proved the analyzer *loads*. `Test-SourceGeneratingConsumer` builds a standalone project that feeds a real NodeSet2 in as an `AdditionalFile`, pins a `ModelSourceGeneratorPrefix` that cannot be derived from the model, and references the emitted types from hand-written code. It deliberately does **not** import the props by path, so the package's auto-import is what is under test. Both checks were verified to fail against a package rebuilt with the misnamed props. ## CI: parallelism re-enabled `buildandtest.yml` pinned three `dotnet build` invocations to `/p:UseSharedCompilation=false` and `-maxcpucount:1`. `dotnet build` defaults to all logical processors, so `-maxcpucount:1` actively disabled parallelism. Both flags were introduced silently in unrelated PRs (`f358078b1`, `208c601b0`) and suppress exactly the conditions the SGF race needed — now structurally gone. Compilation is ~72 % of what that workflow executes (233 of 323 job-minutes in run `29916768976`), so the queue-independent `Build` step durations are the baseline: | Measurement | Baseline | | --- | --- | | `Build` step, 26 `test-*` jobs | mean 5.53 min, sum 143.9 min | | `build-linux-all-tfm` | 56.3 min | | `build-windows-all-tfm` | 32.5 min | `codeql-analysis.yml` keeps the flag — the CodeQL tracer requires it. ## Incidental fixes - `Microsoft.CodeAnalysis.CSharp` declares an exact `[version]` range on `Microsoft.CodeAnalysis.Common`, so with `CentralPackageTransitivePinningEnabled` the central pins must move in lockstep or restore fails with `NU1109`. They now derive from `$(RoslynApiVersionVS2026)`. - `MigrationGeneratorTests` created its generator driver without parse options — silently fine under Roslyn 4.14, `Inconsistent language versions` under 5.0. - The migration analyzer props injected `<Analyzer>` items by hand from `analyzers/dotnet/cs`; NuGet registers analyzers itself and selects the matching folder, so the injection was obsolete and would now force the wrong assembly to load. - Exception diagnostics reported `ex.StackTrace` (null for an exception that never left its throw site, and carrying neither type nor inner chain) — now `ex.ToString()` at all three call sites. - The pack guard tested the flattened assembly list, so it only fired when *no* variant produced output; it now checks each declared variant contributed assemblies. - `GeneratePackageOnBuild` is disabled for the generators; packing happens in CI or on an explicit `dotnet pack`. ## Note for reviewers **Building this repository requires a Roslyn 5.x host** (the .NET 10 SDK or Visual Studio 2026). That is already the documented prerequisite and every CI leg installs `10.0.x` last, but it is now a hard requirement. ## Related Issues _No tracking issue: this is a follow-up to the CS8784 clean-build failure reported against #4041, which fixed the packaged delivery path but left the in-repo project-reference path relying on the SGF resolver._ - Follow-up to #4041 ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [x] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. ### Verification detail | Check | Result | | --- | --- | | `dotnet build UA.slnx` (clean, and after merging master) | 0 errors; 18 warnings, all pre-existing `CA1873` in `Opc.Ua.Client` | | `Opc.Ua.SourceGeneration.Tests` | 81/81 on net10.0 and net48 | | `Opc.Ua.SourceGeneration.Stack.Tests` | 92/92 on net10.0, 89/89 on net48 | | `Opc.Ua.SourceGeneration.Core.Tests` | 3722 passed, 8 skipped | | `Opc.Ua.MigrationAnalyzer.Tests` | 121/121 on net10.0 and net48 | | Package id set, master vs branch | identical, 47 packages | | Release build + `dotnet pack --no-build` | 47 packages, matching the manifest; both `roslyn4.14` and `roslyn5.0` present; no host-provided assemblies shipped | | `validate-source-generator-packages.ps1` | passes, including manifest, props-naming, end-to-end generator consumer and down-level host checks | | Down-level band under real csc 4.14.0 | all three analyzer packages load **and run** | | Negative tests | misnamed props → both checks fail as designed; hidden variant output → pack guard fires; 5.0 build placed in `roslyn4.14` → real `CS9057`; non-running generator name → execution assertion fires; expecting a `roslyn4.8` band → manifest check fires | --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 9ed53262-5cfa-429a-820e-bf6146a00d8b
28 KiB
Developer Guide
This guide is the starting point for contributing to the OPC UA .NET Standard stack. It covers what to install, how to build and test, the coding standards ("dos and don'ts"), and task-oriented "how to" recipes (starting with how to add logging). It links out to the topic-specific documents in docs/README.md rather than repeating them.
If you are new here, read the sections in order: Prerequisites → Repository layout → Building → Running tests → Coding standards. The How-to guides and Packages, platform support, and versioning sections are reference material you can jump to as needed.
Prerequisites
- .NET SDK 10.0 — the whole repository builds and restores with the .NET 10 SDK. Older SDKs are not supported for building
main. The class libraries still target older frameworks (see Packages, platform support, and versioning), but you build them with the .NET 10 SDK. - An IDE (optional but recommended) — Visual Studio 2026, Visual Studio Code with the C# Dev Kit, or JetBrains Rider. Everything can also be done from the command line with
dotnet. - git — to clone and to create feature branches.
- Docker Desktop (optional) — only needed to run the containerized reference server; see ContainerReferenceServer.md.
The C# language version is pinned (LangVersion 14) and analyzer/style rules are enforced by the build, so no extra tooling install is required to get the same diagnostics locally that CI produces.
Repository layout
| Path | Contents |
|---|---|
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 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). |
fuzzing/ |
SharpFuzz / libFuzzer fuzz targets (see Fuzzing.md). |
Central build configuration lives at the repository root and is imported by every project:
UA.slnx— the solution containing all projects.Directory.Build.props/Directory.Build.targets— global MSBuild properties and targets.Directory.Packages.props— Central Package Management: every NuGet version is declared here.common.props/targets.props— shared properties, analyzer settings, and the target-framework matrix..editorconfig— the authoritative code-style and analyzer-severity rules (enforced at build time).
Building
From the repository root:
dotnet restore UA.slnx
dotnet build UA.slnx
Notes:
-
Warnings are errors.
TreatWarningsAsErrorsis enabled, so compiler (CSxxxx) and Roslynator (RCSxxxx) diagnostics fail the build. Microsoft Code Analysis (CAxxxx) diagnostics are emitted as non-fatal warnings unless a rule is promoted to error in.editorconfig. Fix all of them before opening a pull request. -
Building a single target framework. By default the libraries multi-target the whole matrix (see Packages, platform support, and versioning). To restrict a local build to one framework, pass
-p:CustomTargetFrameworks, for example:dotnet build src/Opc.Ua.Core/Opc.Ua.Core.csproj -f net10.0 -p:CustomTargetFrameworks=net10.0 -
Offline / restricted networks.
NuGetAuditis enabled and fails the build withNU1900when 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]withOutputItemType=Analyzer. MSBuild only hands the compiler the generator assembly itself, soDirectory.Build.targetsadds the generator's runtime closure (its output directory, minus the Roslyn host assemblies) asAnalyzeritems — the same payload the generator NuGet packages ship underanalyzers/dotnet/<roslyn>/cs. Without it the generators cannot resolve their dependencies and fail to initialise withCS8784. -
Analyzers and generators are shipped under a Roslyn-versioned analyzer folder.
roslyn.propspins the Roslyn API version, and each generator has a*.Packproject that ships it underanalyzers/dotnet/roslyn<major>.<minor>/cs. See Repository layout and the support matrix. 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
Run the whole suite from the solution:
dotnet test UA.slnx
Conventions and requirements:
- Frameworks. Test projects use either NUnit (with
Assert.Thatassertions and Moq for mocking) or TUnit (with its own assertions and mock helpers). Do not mix the two in one project, and do not use the classic NUnit asserts (Assert.AreEqual, …). - Coverage. Coverage is measured with Coverlet and must not regress; every non-application, non-test project should stay at or above 80 %.
- Before a pull request the
UA.slnxsuite must pass on at least .NET Framework 4.8 and .NET 10.0. - Testing a specific target framework. The libraries multi-target, but the test executables run on one framework at a time. To run the suite against a non-default framework, set
CustomTestTarget(supported values:netstandard2.0,netstandard2.1,net472,net48,net8.0,net9.0,net10.0). The batch filetests/customtest.batcleans, restores, and runs the tests for a chosen target; in Visual Studio, uncomment and set theCustomTestTargetproperty intargets.props. A clean build for the target is recommended when switching. - CI matrix. To keep pull-request builds fast, only net48 and net8.0 are exercised in the qualifying CI build; the other frameworks run in scheduled/manual CI. Fix all failing, flaky, and CodeQL findings in the pipelines.
Coding standards (dos and don'ts)
All rules apply to new code and to existing code you touch. The .editorconfig is authoritative and enforced at build time; the highlights below are the ones most often missed.
Formatting and style
- Add the OPC Foundation MIT license header to every new source file.
- 4-space indentation, max line length 120, CRLF line endings, UTF-8, final newline, no trailing whitespace.
- Allman braces; always specify access modifiers explicitly; member order is constructors → properties/events → methods → fields, each
public→protected→internal→private. - Do not use
#region/#endregionor comment-only section dividers. Do not add#nullable enableto a file when the project already sets<Nullable>enable</Nullable>. - Put every XML-doc
<summary>text on its own line (never a single-line/// <summary> … </summary>). - Follow standard C# naming; no underscores in method or test-method names (tests use PascalCase).
API and language
- Async only. New code uses
async/await(TAP). Do not add APM or sync-over-async (.Result,.Wait(),GetAwaiter().GetResult()) unless explicitly requested. - No
objectin public API (except when overridingEquals). For OPC UA values useVariant. INullabletypes must not be wrapped inSystem.Nullable<T>(T?); use.IsNull/.Nullinstead. On struct types preferTryGet/TryGetValueover casting; never useVariant.AsBoxedValueorIUnion.Value.- Prefer
ArrayOf<T>over read-only collection types /IReadOnlyList<T>/ arrays in new public API; preferByteStringoverbyte[]; preferSpan<byte>/ReadOnlySpan<byte>overbyte[]. - Do not use
[Obsolete]API (outside test code) and do not add API that is not NativeAOT-compatible. - Maintain backward compatibility with 1.5.378; mark replaced API
[Obsolete]rather than removing it.
Concurrency
- Never expose locks in any API surface. For a synchronous lock use
System.Threading.Lock(a polyfill is provided for older TFMs) — neverprivate readonly object m_lock = new(). PreferSemaphoreSlimwhere async coordination is needed.
Architecture
- Make non-abstract public classes
sealedby default; prefer a provider model with injectable providers over inheritance. - Wire new functionality into the dependency-injection infrastructure (with a direct "construct it yourself" fallback) and expose it through the fluent API where possible.
- Reuse the existing base services (telemetry, file system, certificate/secret stores, state machines, sessions, source generators, …) instead of re-implementing them.
Security
- Never hardcode credentials, certificates, or secrets. Manage certificates through the certificate store system and secrets through the secret store (see CertificateManager.md and Certificates.md).
- Use only SHA-2 or stronger hash algorithms; use the audit and redaction APIs for sensitive data.
Logging — use source-generated logging; never call ILogger.LogInformation/LogError/… directly. See Add a log message (source-generated).
How-to guides
Add a log message (source-generated)
The stack uses LoggerMessageAttribute source-generated logging everywhere. It avoids boxing value-type arguments, caches the message formatter, and emits an IsEnabled check so a disabled level costs nothing. Direct ILogger.LogInformation/LogError/… calls are not allowed. The runtime/observability side (how the ILogger is created from ITelemetryContext) is documented in Diagnostics.md; this section is the authoring recipe.
Recipe
- Get a logger. Obtain an
ILoggerfrom the ambientITelemetryContext(telemetry.CreateLogger<T>()); most types already hold one in anm_loggerfield. - Find or create the log class. Each file that logs has, at its end, an
internal static partial class <PrimaryClass>Logholding[LoggerMessage]extension methods onILogger. Add your message there. If several closely-related files emit the same messages, use one shared<Area>Logclass instead of duplicating (for example the encoders/decoders inOpc.Ua.TypesshareEncodingLog). - Reserve an event id. Each project has one
internal static class <AssemblyToken>EventIdsat its root (see Event-id convention). Use the existing per-class offset. - Declare the message. Add a partial method with
[LoggerMessage(EventId = <AssemblyToken>EventIds.<Class> + <index>, Level = LogLevel.<Level>, Message = "…")](see Log class convention). - Call it. Replace the old
logger.LogXxx(...)call withlogger.<MethodName>(args).
Event-id convention
Each project owns exactly one event-id class, named <AssemblyToken>EventIds, in namespace Opc.Ua, in a file EventIds.cs at the project root. <AssemblyToken> is the assembly name with the Opc.Ua. prefix removed and dots dropped — for example Opc.Ua.Core → CoreEventIds, Opc.Ua.Core.Types → CoreTypesEventIds, Opc.Ua.Client → ClientEventIds.
The token prefix is required because the stack uses InternalsVisibleTo: two internal classes with the same name in the same namespace collide across an IVT boundary (CS0436). The class holds one public const int offset per log class. Offsets are assigned in class-alphabetical order starting at 0; each block reserves at least five spare slots for future messages and is then rounded up to the next multiple of ten, so ids stay documented and managed in one place. Every log method sets EventId = <AssemblyToken>EventIds.<Class> + <zero-based message index within that class>.
Narrow exception: retained EventSource-compatibility ids
The four legacy System.Diagnostics.Tracing.EventSource providers (OPC-UA-Core, OPC-UA-Client, OPC-UA-Server, Opc.Ua.ChannelManager) were removed and replaced with [LoggerMessage] equivalents. Their compatibility log methods are a deliberate, narrow exception to the convention above: each keeps the exact numeric id, event name, level, message template, and structured fields the corresponding ETW event had, so consumers can preserve event identity when they migrate from ETW to ILogger. Concretely:
- The compatibility log class uses the old provider name as its
ILoggercategory (e.g."OPC-UA-Core","Opc.Ua.ChannelManager") instead of the typed, per-class category used elsewhere in the project. EventIdresolves to the literal legacy numeric id (e.g.10forOPC-UA-Core's formerServiceCallStart) rather than a normal per-class offset. Keep these values in the affected project'sEventIds.cs; compatibility ids are scoped to their own logger category, so they may intentionally overlap ordinary per-assembly values.- Every compatibility method sets
EventNameexplicitly ([LoggerMessage(EventId = 10, EventName = "ServiceCallStart", Level = LogLevel.Trace, Message = "...")]) soEventId.Namematches the original ETW event name exactly. - ETW-only metadata (provider GUID,
Task,Keywords, manifest) is not retained because there is noILoggerequivalent. - Do not use this pattern for new log messages. It exists only to preserve the event identities that previously shipped through the four EventSource providers; see Diagnostics.md and migrate/2.0.x/telemetry.md for the full removal/compatibility mapping.
Log class convention
- One log class per file, named
<PrimaryClass>Log,internal static partial, appended at the end of the file inside the same namespace. - Methods are extension methods on
ILogger(public static partial void <Name>(this ILogger logger, …)) so call sites read naturally aslogger.<Name>(…). - Identical
this ILoggeroverloads (same name and parameter types) declared in more than one class of the same namespace collide (CS0121) — deduplicate them into a single shared<Area>Logclass. Overloads that differ by name or by parameter type are fine.
Message, level, and parameter rules
- Message text is exact and static; use named placeholders (
{ChannelId}) that match a parameter of the same name. Never interpolate ($"…"). AnExceptionargument is detected by its type and does not need a placeholder. - Parameter types must match the real argument type. Do not use
object/object?, and do not call.ToString()on an argument (type the parameter instead, e.g. an enum orint); an unnecessary.ToString()tripsRCS1097/CA1305. Declare a parameter nullable (string?,Uri?,Exception?) only when the argument can actually be null, otherwise the compiler reportsCS8604. - Guard only expensive arguments. If a call passes an expensive computed argument (
string.Join(...), a LINQ projection,.ToString()on a complex object) wrap it inif (logger.IsEnabled(<level>)); source generation does not suppress eager evaluation of the arguments, andCA1873flags it. Do not guard cheap arguments (locals, fields, ids) — over-guarding tripsRCS1006/RCS1061. A guard must never gate an expression that has an observable side effect. - Dynamic levels stay hand-written.
[LoggerMessage]needs a compile-timeLevel. A call whose level is only known at runtime keeps the structuredlogger.Log(logLevel, "{Template}", args)form wrapped inif (logger.IsEnabled(logLevel)). These are the only remaining directILogger.Logcalls. - Shared/linked source files that are
<Compile Include>-d into more than one project (for example a sample file linked into a test project) cannot reference another assembly's<AssemblyToken>EventIdsclass — give their log class literalEventIdintegers in a high, dedicated range instead. - Duplicate generator on netstandard. A project that also references an R9 package (
Microsoft.Extensions.Http.Resilience,.Compliance,.Telemetry, …) gets theMicrosoft.Gen.Logginggenerator in addition to the in-box one; onnetstandardboth implement every partial method (CS0757). The repo'sDirectory.Build.targetsremoves the R9 analyzer onnetstandardonly — no per-project action is needed.
Worked example
// EventIds.cs (project root) — the assembly-token prefix avoids CS0436 across
// InternalsVisibleTo boundaries.
namespace Opc.Ua
{
internal static class TypesEventIds
{
public const int Encoding = 20; // shared codec block (reserves 20)
public const int Matrix = 50; // per-file block (reserves 10)
}
}
// end of Matrix.cs
internal static partial class MatrixLog
{
[LoggerMessage(EventId = TypesEventIds.Matrix + 0, Level = LogLevel.Debug,
Message = "ReadArray read dimensions[{Index}] = {Dimensions}. Matrix will have 0 elements.")]
public static partial void ReadArrayZeroDimension(this ILogger logger, int index, int[] dimensions);
}
// call site
logger.ReadArrayZeroDimension(index, dimensions);
Checklist
- Message text and level are unchanged from the original call (behavior-preserving).
- Placeholders are named and match parameter names; no interpolation.
- Parameter types match the arguments; nullable only where needed; no
object. - Expensive arguments are guarded with
IsEnabled; cheap ones are not. EventIduses the project's<AssemblyToken>EventIdsoffset (or a literal range for a shared/linked file, or a literal legacy id with an explicitEventNamefor a retained EventSource-compatibility message — see the narrow exception).- When testing with a mocked
ILogger, stubIsEnabled(...) => trueand match onEventId.Name, not the (empty) source-generated stateToString().
Other common tasks
- Add a new feature — implement it in the right library, add unit and (for client/server/pubsub) integration tests, update or add a doc under
docs/, and keep backward compatibility (see Coding standards). - Add a document — put it in
docs/and link it from docs/README.md. - Add a dependency — declare the version in
Directory.Packages.props(Central Package Management), prefer AOT/trimmable and permissively licensed packages, and get maintainer approval first. - Certificates and secrets — see Certificates.md and CertificateManager.md.
- Source-generated node managers / data types — see NodeManagers.md and SourceGeneratedDataTypes.md.
- Server namespace metadata / history advertisement — see NodeManagers.md.
- Dependency injection — see DependencyInjection.md.
- NativeAOT — see NativeAoT.md.
Packages, platform support, and versioning
Released packages
The following NuGet packages are released on a monthly cadence (with hot fixes for security issues). The OPCFoundation prefix is reserved, and the assemblies and packages are signed by the OPC Foundation.
- OPCFoundation.NetStandard.Opc.Ua — a convenience meta-package that pulls in everything except PubSub. Prefer referencing the individual packages below to reduce your dependency surface.
- OPCFoundation.NetStandard.Opc.Ua.Types
- OPCFoundation.NetStandard.Opc.Ua.Core.Types — the generated OPC UA NodeSet models and state classes.
- OPCFoundation.NetStandard.Opc.Ua.Core and OPCFoundation.NetStandard.Opc.Ua.Security.Certificates — required by both client and server projects.
- OPCFoundation.NetStandard.Opc.Ua.Configuration — configure a UA application from file or with the fluent API.
- OPCFoundation.NetStandard.Opc.Ua.Server — build a UA server.
- OPCFoundation.NetStandard.Opc.Ua.Client and OPCFoundation.NetStandard.Opc.Ua.Client.ComplexTypes — build a client; the complex-type library adds support for complex types.
- OPCFoundation.NetStandard.Opc.Ua.Bindings.Https — optional
opc.httpstransport. - OPCFoundation.NetStandard.Opc.Ua.PubSub (Beta) — publisher/subscriber model.
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.
The full set of packages the preview pipeline produces is pinned in .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<major>.<minor>/cs layout, that they carry their runtime closure privately, that the model generator's auto-imported build/<PackageId>.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:
- .NET Standard 2.0 (
Opc.Ua.Typesonly) - .NET Standard 2.1
- .NET Framework 4.7.2 (limited support)
- .NET Framework 4.8
- .NET 8.0
- .NET 9.0
- .NET 10.0
To keep pull-request CI fast, only (4) and (6) are part of the qualifying build; the other platforms are covered by scheduled or manual CI. See Running tests for how to build and test a specific framework locally with CustomTestTarget / tests/customtest.bat.
Supported analyzer and source generator hosts
The analyzer and source generator packages ship under analyzers/dotnet/roslyn<major>.<minor>/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.14 | analyzers/dotnet/roslyn4.14/cs |
Visual Studio 2022 17.14 / .NET 9 SDK |
| 5.0 | analyzers/dotnet/roslyn5.0/cs |
Visual Studio 2026 18.0 / .NET 10 SDK |
The version is declared once in roslyn.props.
Adding a band below 4.14 is not just another entry in that file. The analyzer closure — the generator,
Opc.Ua.SourceGeneration.CoreandOpc.Ua.Types— must bind against the Roslyn host's ownSystem.Collections.ImmutableandSystem.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)inroslyn.propsdrives 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.Typesincluded, a second time.Get it wrong and the failure is silent: the generator is skipped (
CS9057), fails to load (CS8032) or throwsMissingMethodExceptionwhile initializing (CS8784) — all warnings, so the consumer just gets no generated code.validate-source-generator-packages.ps1therefore refuses any package that shipsMicrosoft.CodeAnalysis*,System.Collections.ImmutableorSystem.Reflection.Metadata, and runs the packed down-level payload through a real compiler of that band.
Versioning
From 2.0 onward, package versions are produced by Nerdbank.GitVersioning (nbgv) from the version.json file at the repository root. That file holds the base version (currently 2.0-preview) and requests SemVer 2.0 package versions (nugetPackageVersion.semVer: 2); nbgv derives the version height, prerelease tag, and build metadata from the git history, and version.props maps the computed values onto the assembly and package version properties. Stable (public-release) versions are produced only on the main, master, develop/*, and release/<x.y.z> branches — every other branch yields a prerelease build.
The earlier 1.x packages used a different, spec-derived scheme in which the first two digits encoded the embedded NodeSet spec version (for example
1.5.378.xcorresponds to OPC UA spec V1.05, mapped to release branches such asrelease/1.4.372). That scheme no longer applies from 2.0 onward.
Contributing and pull requests
- Fork the repository (or, if you have write access, push a branch prefixed with your username) and open a pull request. You must agree to the Contributor License Agreement; the "I AGREE" prompt appears automatically on your first PR. See CONTRIBUTING.md.
- Before submitting: all tests pass, code analysis is clean (no new warnings), the change keeps backward compatibility, and security implications are reviewed.
- The pull-request template asks you to confirm the CLA, added tests/coverage, documentation, a warning-free build, that the
UA.slnxsuite passed on .NET Framework 4.8 and .NET 10.0, and that CI and CodeQL are green. - You can run the
opc-ua-codestyle-enforceragent to drive analyzer warnings to zero before opening the PR.
Related documentation
- Documentation index — all topic guides.
- Diagnostics — telemetry context, logging runtime, metrics, audit events, server diagnostics nodes, and packet capture.
- Dependency Injection, Certificates / Certificate Manager, NativeAOT, Migration Guide, What's New in 2.0.
- Fuzz testing.