7 Commits

Author SHA1 Message Date
romanett 442889cba5 Fix source generator audit findings; conform structure-field matrices and type metadata to OPC UA 1.05 (#4569)
# Description

This PR fixes a bug audit of the source generation projects
(`tools/Opc.Ua.SourceGeneration*`: model loading, NodeSet import, all
emitters, the Roslyn hosts and packaging). It also fixes a max-effort
code review of those fixes, and a check of every spec-relevant change
against the OPC UA 1.05 text.

- **Scope:** about 85 audit findings, 48 review findings and 32
spec-grounding items were addressed. Each fix has a regression test that
fails without it.
- **Wire change:** it includes one deliberate, stack-wide wire change:
**structure-field matrices now follow OPC 10000-6 5.2.5 Table 28**.

Most changes are internal correctness fixes. The sections below list
every change a user can observe: wire formats, generated code and API,
generated address-space content, diagnostics and runtime behaviour.

## Behaviour / user-facing changes

### Wire format (encoders)
- **Binary inline matrices** (OPC 10000-6 5.2.5 Table 28).
- **Layout:** a multi-dimensional *structure field* is encoded as
`Int32[] Dimensions`, then the values **without a length prefix**, with
at least 2 dimensions.
- **Empty and null:** any dimension <= 0 means no values follow, and a
null matrix is `-1`. An empty matrix is written as `[0,0]`.
- **Compatibility:** this is **not compatible with earlier builds of
this SDK** for matrix fields. It applies to generated types,
`[DataType]` types, `Structure`, client ComplexTypes and
`WriteEncodeableMatrix`.
  - **Unchanged:** Variant matrices (5.2.2.16) keep their old encoding.
- **Declared rank decides matrix vs array.** A 1-D `MatrixOf<T>` in an
array field or a Variable value is written as an array again.
Matrix-declared fields go through the new public
`EncoderExtensions.WriteInlineMatrixValue`, which generated code also
uses. A non-empty 1-D value in a matrix field throws `BadEncodingError`.
- **Decoder hardening:**
- Before allocating, the decoder bounds all matrix dimensions against
overflow, `MaxArrayLength` and the remaining message length.
  - The decoder rejects:
    - a matrix with fewer than 2 dimensions;
- a populated matrix whose rank differs from the field's declared rank;
    - a union SwitchField greater than the number of fields;
    - an EncodingMask with unassigned bits set.
- **Encoder `MaxArrayLength`:** the encoder enforces `MaxArrayLength`
for inline matrices, as it already did for arrays.
- **XML structure fields** (5.3.1, 5.3.4, 5.3.5).
- **Fields:** `Structure` and client ComplexTypes write fields like
typed fields (`<A>1</A>`, array elements directly) instead of a Variant
body.
- **Matrix fields:** `Dimensions` and `Elements` go directly inside the
field element, with no `<Matrix>` wrapper.
- **Empty matrices:** written as nil, because XML dimensions must be
greater than 0.
  - **Compatibility:** decoders still accept the old shapes.
- **Enum fixes:** enum array elements with value 0 are no longer
dropped, and enum array element names are fixed.
- **Other encoding fixes:**
- A null `DiagnosticInfo` structure field writes `0x00` instead of
nothing.
- A null scalar field in a runtime `Structure` is written as its default
instead of zero bytes, which used to shift every following field.
- A Variant holding an empty matrix encodes as an empty array instead of
throwing.
  - `Variant.Copy` keeps the matrix identity of null and empty matrices.
- **NodeSet export and the DataContract surrogate** write Variable
values with the Variant rules again. A null or empty `MatrixOf` variable
no longer produces invalid XML or unreadable NodeSets.
- **`JsonEncoder.CanOmitFields`** is `true` only for Compact encoding.
Verbose and RawData JSON include default-valued fields.

### `[DataType]` source-generated types
- **Default-value handling** (OPC 10000-6 5.4.1, Table 1).
- **Encode:** a field is omitted only when its value *and* its declared
default are the type's default (0, false, null). Fields with a
non-default initializer or constructor default, and empty strings, are
always written, so configuration files get slightly larger.
- **JSON decode:** a field missing from JSON decodes to the type
default.
- **XML decode:** a field missing from XML keeps its declared default,
which older configuration files rely on.
- **Why:** this fixes silent round-trip loss, for example
`SamplingInterval = 0` read back as `-1`.
- **Nested `[DataType]` types:** they are now generated inside their
containing types. Without an explicit id, their default TypeId and
XmlName are qualified (e.g. `s=Outer.Foo`); **wire identity change** for
nested types. Colliding activator names get a `_2` suffix.
- **Activators:** they are `internal` when the type, or any containing
type, is not public (was CS0060).
- **Matrix properties:**
  - `MatrixOf<T>` properties use the inline matrix encoding.
- Matrices of non-sealed structures, or with
`StructureHandling.ExtensionObject`, are written as ExtensionObjects.
- Their published ValueRank is 2, because the rank can't be known from
`MatrixOf<T>`.
- **Published StructureDefinitions:**
  - They include base-class fields.
  - Nullable properties are no longer marked optional.
  - Inline structure fields name their concrete type.
- **`[Flags]` enums:** they are published as plain enumerations with
their member values, which matches how they are encoded.
- **Other generated code:**
- `Clone` deep-copies arrays and matrices of structures, Variants,
ExtensionObjects and DataValues.
- `IsEqual` and `GetHashCode` are consistent: EncodingMask, active union
member and NaN handling agree.
- Pooled `Reuse()` restores the freshly constructed state through the
new `protected virtual ResetForReuse()`, and returns an instance to its
runtime type's pool.
- **Enum capture:** enum member values are captured culture-invariantly.
Negative values were lost on cultures that use U+2212 as the minus sign.

### Model-driven generator: generated code, API and file names
- **Removed API:** generated `*_Placeholder` proxy accessors (e.g.
`PowerTrainTypeClient.GetMotorIdentifier_PlaceholderAsync`) and fluent
`X_Placeholder()` accessors are removed; they could never resolve
anything.
- Proxy accessors now look children up by BrowseName with the right
namespace.
- `RoboticsClient` finds motors and gears by type and drives via
`IsDrivenBy`. Its topology snapshots used to be empty.
- **Renamed files:** generated files for a bound `[NodeManager]` are now
named `{Namespace}.{Class}.NodeManager.g.cs` (also `.Factory` and
`.FluentBuilders`).
- **Generated NodeState code:**
- Attributes are set through `baseState`/`nodeState` locals, so children
named like base attributes (e.g. `DataType`, `EventNotifier`) compile.
- Children that hide base members are declared `public new`, using names
reflected from the runtime.
  - `global::` qualification is used throughout.
- **Proxies and fluent builders:** a proxy whose supertype is excluded
(e.g. Draft) derives from the nearest emitted ancestor. Fluent wrappers
and NodeState factories fall back the same way; they used to fail with
CS0246.
- **Derived structures:**
  - The optional-field `EncodingMask` continues across inheritance.
- A derived structure over a base type from another model no longer
relies on `base.ResetForReuse`.
- **Default values:**
- NodeId, QualifiedName and ExpandedNodeId defaults resolve by namespace
URI (`context.NamespaceUris.GetIndexOrAppend`) and server URI.
- StatusCode, negative enum, `double`/`float` (shortest round-trip
literal) and escaped string defaults now compile.
- **Event records:**
- Vendor event records have `Decoder.GetStandardFields(NamespaceTable)`,
and their browse paths use the model's namespace.
- A field whose namespace is unknown to the session no longer matches a
namespace-0 field.
  - Companion `StandardFields` is `[Obsolete]`.
- Vendor simple subtypes such as `Percent : Double` map to their
built-in type.
- **Other fixes:**
- `MethodState.CallAsync` passes the real `objectId` to synchronous
`OnCallMethod2` handlers; it used to pass `NodeId.Null`.
- Enumeration fields with an explicit `Identifier` keep it, instead of
taking a trailing `_<digits>`.
  - Explicit NumericIds are no longer reused by automatic numbering.
  - Dependencies are loaded in topological order.

### Generated address-space content
- **Arguments:**
- Methods without arguments no longer get empty
`InputArguments`/`OutputArguments` properties (OPC 10000-3 5.7).
- About 2,660 automatically assigned namespace-0 NodeIds of 15000 and
above shift. Every NodeId in `StandardTypes.csv` is unchanged.
- **Permissions:** type nodes and method declarations on types now carry
the model's `RolePermissions`/`AccessRestrictions`.
- **Restored nodes:** NodeSet import keeps nodes that were silently
dropped before:
- `NamespaceMetadata` objects and the NodeSet's own TypeDictionary
variables;
  - ISA95 placeholders linked only non-hierarchically;
  - instances whose parent is in another namespace.
- **Import details:**
  - ValueRank > 1 without ArrayDimensions keeps its rank.
  - `svr=` indexes map correctly.
  - Invalid MinimumSamplingInterval values import as -1, not 0.
  - Localized texts prefer the invariant or English translation.
- **StructureDefinitions:**
- They include inherited fields, including for subtypes without their
own fields (e.g. `AnonymousIdentityToken` now publishes PolicyId).
  - The StructureType kind covers inherited AllowSubTypes fields.
  - Unions ignore IsOptional.
- StructureFields never publish ValueRank 0; a OneOrMoreDimensions field
without dimensions becomes rank 2.
- Fields with ValueRank -2/-3 are published as scalar BaseDataType,
which is what is on the wire.
- **OptionSets:**
  - Implicit fields get the next free bit.
- `OptionSetValues` uses null for undefined bits instead of "Reserved",
and structure OptionSets publish all their bits (no 32/96-bit cap).
- Numeric OptionSets are limited to their base type's width, and UInt64
bit 63 works.
- **Schemas:**
- Structures with inline-matrix fields are left out of the `.bsd`
(5.2.5).
- In the BSD, Variant-encoded and non-structure subtyped fields are
described as `ua:Variant`.
- In the `.xsd`, matrix fields are typed `ua:Matrix` with
`Dimensions`/`Elements`, and `EncodingMask` is required.

### NodeSet / model input handling
- **Model selection:**
- The newest NodeSet of a model is chosen by `ModelVersion` (SemVer 2.0
precedence), then PublicationDate. The `Version` attribute, which Annex
F says is not for comparison, is only a tie-break.
- The order is total and does not depend on input order; referenced
assemblies use the same rule.
- **Default names:**
- Invalid default Name/Prefix values are repaired (`201301ISA95` becomes
`_201301ISA95`, `class` becomes `class_`).
  - Valid names are unchanged.
  - Sanitising no longer depends on the OS.
- **`ModelUri` metadata** that doesn't match the NodeSet raises a
warning instead of silently losing the model.
- **Input detection:**
- Only XML files whose root is `ModelDesign` or `UANodeSet` are treated
as models.
- A malformed NodeSet stays a NodeSet input, so it no longer breaks the
ModelDesign pass.
- **`IdentifierFile` paths:** they are normalized (`./`, `../`), and the
suffix match requires a folder boundary.
- **Dependency payloads** (`ModelDependencyV1`) carry field IsOptional,
AllowSubTypes, ArrayDimensions and the producer's namespace tables in
skippable trailers, so old and new readers stay compatible.

### Roslyn host, diagnostics, packaging
- **New diagnostics:**
- MODELGEN036: `[NodeManager]` target not supported (nested, generic,
file-local or global namespace).
- MODELGEN037: `[DataType]` target not supported (struct, generic,
abstract, file-local or global namespace).
- MODELGEN038: the definition of an encodeable base type can't be found.
- **New validator errors:**
- a structure with both optional and subtyped fields (OPC 10000-6 F.13);
  - multi-bit or duplicate OptionSet masks.
- **Diagnostic locations:** `[DataType]`/`[NodeManager]` diagnostics
point at real source locations again, so `#pragma` and `.editorconfig`
apply. MODELGEN010 is the exception.
- **Incremental caching:** pipeline values compare by value, so
unrelated edits no longer regenerate the whole model or all
`[DataType]`s.
- **`ModelSourceGeneratorIgnore`** on a ModelDesign input is honoured;
the design still resolves as a dependency.
- **Cancellation:** a cancelled generation run propagates
`OperationCanceledException` instead of reporting a MODELGEN003 error.
- **Stack package:** the `Stack` NuGet package now ships
`build/OPCFoundation.NetStandard.Opc.Ua.SourceGeneration.Stack.props`,
so `StackSourceGeneratorMode` and `StackSourceGeneratorExclude` reach
the compiler.

## Related Issues

- No tracking issue. The audit, review and spec-grounding reports are
available on request. Contains the matrix wire-format change discussed
for D-5 (OPC 10000-6 5.2.5 Table 28).

## Checklist

- [ ] 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.
(`docs/SourceGeneratedDataTypes.md`, `docs/NodeManagers.md`, generator
readme)
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. (`UA.slnx` 0 warnings / 0 errors on net48 and
net10.0)
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. The
affected suites were run on net10.0: SourceGeneration Core/Roslyn/Stack,
Types, Core.Encoders, Encoders.Fuzz, Client, Client.ComplexTypes,
PubSub, Core, Server, GDS, Configuration, Di, Robotics, ISA95,
Positioning, OpenUsd, XRegistry. net48 was built; its test legs, and the
post-rebase verification, run in CI.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 00:47:40 +02:00
romanett e639932b95 Fix source generator defects from the Client/Types/SourceGeneration audit (#4473)
# Description

Fixes the SourceGeneration findings of a read-only bug audit of
`a05a36055` — sections 15 (host generators, Roslyn side), 16
(SourceGeneration.Core schema / ModelDesign loader) and 17
(SourceGeneration.Core generators). 36 of the 39 findings are fixed;
each has a regression test.

### Generated-code correctness

- **State machine NodeId constants** were emitted as `const uint` even
when the aliased `Objects` constant is a `string`, so every model using
string NodeIds with a `FiniteStateMachineType` subtype failed with
CS0029. The constant type now follows the aliased node, looked up from
the same source `NodeIdGenerator` emits from — top-level nodes **plus
each node's instance hierarchy**, since a state or transition only ever
exists as a hierarchy entry.
- `NodeIdGenerator` emitted string identifiers into C# string literals
without escaping them.
- Structure field names are authored data, so they can be C# keywords or
collide with the members every generated data type carries. Properties
and `{Type}Fields` members now go through a sanitizing,
collision-breaking mapping (including two siblings that sanitize to the
same identifier). **Wire names are untouched** — OPC 10000-3 §8.51
requires the StructureField name to match the BrowseName, and the
`DataMember`/encode/decode names still come from `Parameter.Name`.
- `NodeStateGenerator`'s copy-on-write cast now uses the fully qualified
data type name, which a cross-model structure needs.
- `GetScalarValueAsCode` emitted `(double)NaN` and the infinities, which
do not compile.
- `TemplateWriter` formatted with the current culture, so a machine with
a comma decimal separator emitted invalid generated source.

### Served schema documents

- The binary schema described a **different wire layout than the
generated `Encode`/`Decode`**: unions carried no
`SwitchField`/`SwitchValue`, and structures with optional fields carried
no encoding-mask presence bits. Both are now emitted per **OPC 10000-6
§5.2.7**, matching the layout the runtime `BsdSchemaGenerator` produces.
A structure with more than 32 optional fields now fails generation
instead of silently wrapping `1 << 32` back onto the first field's bit.
- Documentation text and attribute values are XML-escaped.

### ModelDesign loading and validation

- Identifiers pinned in a CSV were dropped whenever any other loaded
namespace declared the same symbolic name. Removal is now scoped to the
file's target namespace, and explicit `NumericId`s are keyed by
`(namespace, id)` so the same number in two namespaces is legal.
- Declaration order inside a design file no longer matters — base types
are imported and validated ahead of their subtypes.
- OptionSet `OptionSetValues` were truncated at bit 31 (signed shift)
and bits 32–63 of a 64-bit option set were never emitted.
- An authored `ValueRank` is no longer overwritten by the shape of the
default value (OPC 10000-3 §6.2.8: a ValueRank may only be further
restricted), and a VariableType data type may be narrowed any number of
levels deep rather than exactly one.
- NodeSet import: `AccessRestrictions="0"` means "no restrictions", not
`EncryptionRequired` (OPC 10000-3 §8.56) — an unrecognised mask stays
**fail-closed**; a NodeSet without `<Models>` reports what is wrong; an
optional `<References>` no longer throws; the parent/child reference
scan looks at the parent's references; and `IsTypeOf` terminates on the
self-referential root reference types NodeSets routinely declare.
- Version comparison is numeric per component (ordinally `"1.05.9"`
sorts above `"1.05.10"`) and now lives on `SemVer`, so the NodeSet
collection and the dependency tie-break cannot pick different winners; a
publication-date version no longer outranks a real one.

### Host generator

- `[DataType]` on an `enum` is matched again (an enum is not a
`TypeDeclarationSyntax`), MODELGEN003 renders its arguments instead of
the raw template, and the Types hint name keeps the namespace's dots so
`A.BC` and `AB.C` cannot claim the same one.
- Compilation-derived pipeline stages carry value comparers, so editing
a file no longer re-runs the entire model generation on every keystroke.
- A NodeSet that fails to load skips that file rather than every
remaining one; a file listed twice as an `AdditionalFile` is deduped;
superseded NodeSet versions are still recognised as NodeSet inputs and
not generated a second time by the ModelDesign pass.
- `LogLevel.Critical` maps to a diagnostic instead of being silently
dropped.

### Client

- `EventRecordFieldReaders` gained the readers the event record
generator needs (including `Variant`, enumerations and the array forms).
Fields whose type had no reader were declared on the record but never
populated, so they read as `null`/`default` for every event.

## Not fixed (deliberately)

- **Forward same-namespace hierarchical references in
`NodeSetToModelDesign.ImportReferences`** — the audit flags the
inverse-only handling as asymmetric. Making it symmetric compiles and
passes every generator unit test, but regresses the address space: all
17 `RobotOpenUsdE2eTests` fail in `OneTimeSetUpAsync` because the hosted
server never finishes starting. Reverted, with a comment at the site
recording why.
- **§17.10 and §15.10** (both low + PLAUSIBLE) — each needs a wider
refactor than this PR: a consistent NodeId-constant rename across ~6
cross-reference sites, and replacing `Location` with a serializable
equivalent for incremental cache equality.

## Spec cross-check

The encoding/schema changes were checked against the specification: OPC
10000-6 §5.2.7 (32-bit EncodingMask, one bit per optional field in
order, padded), the union `SwitchField` encoding, OPC 10000-3 §8.56
(AccessRestrictionType bit mask), §6.2.8 and §5.6.2/§5.6.5 (ValueRank),
§8.51 (StructureField name == BrowseName).
`GenerateStackTests.OptionalFieldBitsAgreeBetweenBsdAndGeneratedEncoder`
pins the BSD presence-bit order against the generated encoder's
`LogRecordFields` so the two cannot drift apart again.

## Related Issues

_No tracking issue — this PR implements the SourceGeneration sections of
a bug audit. Happy to open one if maintainers prefer._

## Checklist

- [ ] 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.
- [ ] 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.

> Note on the test checkbox: verified on **net10.0 only** — `UA.slnx`
builds clean (0 warnings, 0 errors) and
`Opc.Ua.SourceGeneration.Core.Tests` (3915),
`Opc.Ua.SourceGeneration.Tests` (184), `Opc.Ua.Di.Tests` (425, including
the `RobotOpenUsdE2eTests` end-to-end fixture that catches address-space
content changes) and the `EventRecordFieldReaders` tests (16) all pass.
.NET Framework has **not** been run locally; CI needs to confirm it.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
2026-09-13 19:07:55 +02:00
Marc Schier 0612a43fe4 Pluggable cryptography and hardware-held private keys (#4192)
Makes the stack's cryptographic operations replaceable - by another library, an offboard service, or hardware (TPM 2.0 / HSM / PKCS#11 token / cloud KMS) - with no performance cost in the default configuration, and extends this to certificates so a private key need never exist in process memory.

Also fixes three pre-existing defects found while validating: STACKGEN001 erroring for every compilation the stack generator was merely loaded into, certificates failing to load from paths past MAX_PATH on Windows, and the MonitoredNode2 tests starving the thread pool.

Fixes #4190
2026-08-09 12:40:26 +02:00
Marc Schier 76d33cb139 Remove SourceGenerator.Foundations, ship analyzers under Roslyn-versioned folders for VS2022 and VS2026, and validate the packages (#4120)
# 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
2026-07-31 15:46:17 +00:00
Marc Schier e73eb3a0e7 Fix source-generator package delivery and preview restore (Roslyn packaging) (#4041)
## Failure

The source-generator projects disabled normal build-output packing
without adding
their analyzer assemblies and private runtime closure back to the NuGet
payload.
Debug and Release packages also shared IDs, and both preview workflows
passed the
unsupported `--configuration` option to `dotnet restore`.

## Fix

- Package each generator and its private runtime closure under
`analyzers/dotnet/cs`, excluding Roslyn host assemblies and package
dependencies.
- Preserve Roslyn compile references for project-reference test
consumers.
- Give Debug generator packages `.Debug` IDs.
- Validate package contents and clean net10 consumers in both preview
pipelines.
- Remove invalid restore configuration arguments from both preview
workflows.

## Reference

- Roslyn analyzer packaging and MSBuild restore semantics; no normative
OPC UA change.
- Split from #4021.

## Tests

- `Opc.Ua.SourceGeneration.Tests`: 68 passed on net10.0 and 68 on net48.
- `Opc.Ua.SourceGeneration.Stack.Tests`: 90 passed on net10.0 and 87 on
net48.
- Generated `preview-pack.slnx` and restored it with
`--disable-parallel`.
- Packed both Release generators with `--no-build`; package-content
checks and two clean net10 consumer builds passed with zero
warnings/errors.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-22 12:40:27 +02:00
Marc Schier 8538f531e7 Update paths for the consolidated repository layout 2026-07-16 06:26:05 +02:00
Marc Schier 7cec2d614d Move repository files to the consolidated layout 2026-07-16 06:25:54 +02:00