mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 07:07:24 +08:00
# Description The model source generator crashed with `MODELGEN003` / `NullReferenceException` whenever a `ModelDesign` instantiated an `ObjectType` (or `VariableType`) declared in a **dependency** design file, as soon as that type had children. The sibling of #4332 on the instance-declaration path: #4335 linked dependency *data types*, but an *instance* whose `TypeDefinition` lives in another design still crashed in `NodeStateGenerator` on the type's inherited children. Root cause: dependency design files load with `validateDictionary: false`, so `ValidateInstance` / `ValidateType` never ran on them and their types' children kept `TypeDefinitionNode` / `DataTypeNode` at `null`. When the target instance merged those children into its hierarchy (`BuildInstanceHierarchy` re-walks the dependency type's `Children.Items`), the null references were copied along, and `GetNodeStateClassName` dereferenced them while building the node state factory list. Changes: - A new best-effort `LinkDependencyInstances` pass in `ModelDesignValidator` links dependency-design instances the way `ValidateInstance` / `ValidateType` do for the target model: the `TypeDefinitionNode` / `DataTypeNode` of the types' children and standalone instances (recursively), variable `DataType` / `ValueRank` inheritance from the variable type, `DefaultValue` decoding, `VariableTypeDesign.DataTypeNode` restrictions, and method arguments — including creation of the `InputArguments` / `OutputArguments` argument properties so method children materialise into the target hierarchy exactly like local ones. Resolution is best effort (unresolvable references stay `null`) and NodeSet2 dependencies remain excluded, consistent with `LinkDependencyDataTypes`. - The pass runs alongside `LinkDependencyDataTypes`: for upstream dependencies after all of them are loaded and before the target validates (so the hierarchy merge picks up the links), and for downstream dependencies after they load. - `docs/ModelDependencies.md` documents the instance linking. Tests (all verified to fail with the exact reported NRE stack before the fix): - `CrossModelInstanceTypeDefinitionTests` (Core, 4 tests): the issue's minimal repro end to end; an equivalence test asserting the cross-model instance emits the **identical node-state factory set** as a locally declared type (this is the issue's stated expectation, and it drove the method-argument-property part of the fix); the flipped upstream/downstream ordering; and validator-level assertions that the merged hierarchy carries linked `TypeDefinitionNode` / `DataTypeNode` references, including the data type inherited from a dependency `VariableType`. - `CrossModelInstanceTypeDefinitionGeneratorTests` (Roslyn driver, 2 tests): the issue's csproj repro shape — both `AdditionalFiles` orderings, asserting no diagnostics and that the combined generated output of both models **compiles** (the instance factories reference the dependency model's typed state classes). Verified locally on net10.0: `Opc.Ua.SourceGeneration.Core.Tests` 3800 passed, `Opc.Ua.SourceGeneration.Tests` 141 passed, `Opc.Ua.SourceGeneration.Stack.Tests` 94 passed; 0-warning Release build of `Opc.Ua.SourceGeneration.Core` (all TFMs incl. netstandard2.0); `dotnet format whitespace --verify-no-changes` clean. This removes the last generator blocker for source generating the instance model of the `Workshop/DataTypes` sample (OPCFoundation/UA-.NETStandard-Samples#814): its `ParkingLot` / `DriverOfTheMonth` are instances of types declared in the other design file. ## Related Issues - Fixes #4353 ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [ ] 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. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Marc Schier <marcschier@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: marcschier <11168470+marcschier@users.noreply.github.com>
159 lines
8.3 KiB
Markdown
159 lines
8.3 KiB
Markdown
# Cross-Assembly Model Dependencies
|
|
|
|
The OPC UA source generator emits `[assembly: Opc.Ua.ModelDependencyAttribute(...)]`
|
|
metadata on every assembly that has nodesets or design files in `<AdditionalFiles>`.
|
|
A single attribute carries both the lightweight dependency-closure information
|
|
**and**, on the assembly's self-declaration entry, a compact binary type-table
|
|
payload that downstream source generators can decode without re-walking
|
|
`AdditionalFiles`. Each instance of the attribute records, for one model the
|
|
assembly emits or transitively consumes:
|
|
|
|
- the model URI,
|
|
- the C# namespace prefix the generator used,
|
|
- (when known) the version and publication date,
|
|
- the C# identifier of the assembly's `Namespaces` class entry for the model,
|
|
- and — on self-declaration entries only — a base64-encoded Deflate-compressed
|
|
`ModelDependencyV1` type-table payload.
|
|
|
|
Downstream consumers that reference such an assembly do not need to re-add
|
|
those upstream nodesets to their own `<AdditionalFiles>`. The generator scans the
|
|
attributes on referenced assemblies and uses them to:
|
|
|
|
1. **Suppress the missing-dependency error** in nodeset dependency collection
|
|
when the missing namespace is known to be supplied by a referenced assembly.
|
|
2. **Apply override resolution** when a local `<AdditionalFiles>` entry refers
|
|
to a model URI that is also in a referenced assembly:
|
|
- **Same C# prefix** → local generation is silently skipped (the referenced
|
|
assembly already supplies the types; duplicate emission would cause
|
|
`CS0101`).
|
|
- **Different C# prefix** → local generation proceeds; types live in a
|
|
different C# namespace so no symbol conflict occurs.
|
|
3. **Cross-namespace prefix mapping** — when a local generator loads a
|
|
NodeSet2 that depends on another model, it auto-generates dependency
|
|
prefixes (e.g. `Opc.Ua.DI`) that may not match the C# namespace the
|
|
referenced assembly actually uses (`Opc.Ua.Di`). The override step rewrites
|
|
the dependency namespace's `Prefix` (and `Name`) to match the values
|
|
published by the referenced assembly's `[ModelDependencyAttribute]`, so
|
|
cross-namespace type references emitted into the local code compile
|
|
correctly. The target namespace itself is never rewritten.
|
|
4. **Tie-break** when more than one referenced assembly provides the same model
|
|
URI by selecting the entry with the highest `(Version, PublicationDate)`
|
|
lexicographic tuple. The losing entries are reported as `MODELGEN012` info
|
|
diagnostics.
|
|
5. **Import the type-table payload** on self-declaration entries so the
|
|
validator's node table is pre-populated with the upstream's types,
|
|
children, method arguments, and DataType fields. Cross-namespace
|
|
`BaseType` / `TypeDefinition` / `DataType` references in the consumer's
|
|
own models then resolve against the imported types without needing the
|
|
upstream NodeSet2/ModelDesign XML in `AdditionalFiles`.
|
|
|
|
## Attribute shape
|
|
|
|
The attribute constructor accepts six arguments (the trailing four are
|
|
optional):
|
|
|
|
```csharp
|
|
[assembly: Opc.Ua.ModelDependencyAttribute(
|
|
modelUri: "http://opcfoundation.org/UA/DI/",
|
|
prefix: "Opc.Ua.Di",
|
|
version: "1.05.0",
|
|
publicationDate: "2025-11-15T00:00:00Z",
|
|
name: "OpcUaDi",
|
|
payload: "qscBA…<base64 ModelDependencyV1>…AAA=")]
|
|
```
|
|
|
|
The `name` parameter records the C# identifier the assembly used inside its
|
|
`Namespaces` class — i.e. the `name` consumers must use when emitting
|
|
`global::{Prefix}.Namespaces.{Name}` cross-namespace constant references.
|
|
|
|
The `payload` parameter is non-null only on the assembly's own
|
|
self-declaration entry. Transitive-dependency entries (the one-per-referenced-
|
|
model rows the generator re-emits) carry `null`. The producing assembly is the
|
|
canonical source of its type-table description.
|
|
|
|
## Payload wire format (`ModelDependencyV1`)
|
|
|
|
Encoding lives in
|
|
`tools/Opc.Ua.SourceGeneration.Core/Dependency/ModelDependencyV1.cs`:
|
|
|
|
- Magic header: `0xAA 0xC7`
|
|
- Version byte: `0x01`
|
|
- Compression byte: `0x01` (Deflate)
|
|
- Body (compressed): `ModelUri` string + node array, each carrying symbolic
|
|
name/namespace, class name, kind, base-type chain, numeric/string NodeId,
|
|
abstract / enumeration flags, DataType fields, and declared instance
|
|
children (with method-argument lists). Deterministically sorted by
|
|
`(SymbolicNamespace, SymbolicName)` so the produced base64 string is
|
|
byte-reproducible across builds.
|
|
|
|
Readers reject unknown versions cleanly and the downstream pipeline falls
|
|
back to explicit `AdditionalFiles` resolution when a payload cannot be
|
|
decoded.
|
|
|
|
## Diagnostics
|
|
|
|
| ID | Severity | Meaning |
|
|
| ------------- | -------- | ------------------------------------------------------------------ |
|
|
| `MODELGEN010` | Warning | A `[NodeManager]` attribute could not be bound to a model (unmatched selector, or ambiguous when the project has multiple models). |
|
|
| `MODELGEN012` | Info | Multiple referenced assemblies provide the same model URI. |
|
|
| `MODELGEN013` | Info | Local model skipped because a referenced assembly provides it. |
|
|
|
|
## Implementation
|
|
|
|
The Roslyn-side scan lives in
|
|
`tools/Opc.Ua.SourceGeneration/ReferencedModelDependencyScanner.cs` and uses
|
|
`IAssemblySymbol.GetAttributes()` so it works for both
|
|
`PortableExecutableReference` and `CompilationReference`, hooks Roslyn's
|
|
per-symbol incremental cache, avoids file IO, and is AOT-safe inside the
|
|
generator. Each attribute is read into a `ModelDependencyReference` whose
|
|
`GetDependency()` method lazily decodes the payload through a memoising
|
|
`ConditionalWeakTable` keyed on the payload string.
|
|
|
|
The emitter lives in
|
|
`tools/Opc.Ua.SourceGeneration.Core/Generators/ModelDependencyGenerator.cs`
|
|
and produces one `{prefix}.ModelDependencies.g.cs` per generated model
|
|
containing assembly-attribute lines for the model itself (with the
|
|
`ModelDependencyV1` payload) and every model it consumes (with a `null`
|
|
payload). The template lives in `ModelDependencyTemplates.cs` and uses the
|
|
shared `Token` infrastructure for replacement.
|
|
|
|
The cross-namespace prefix override step lives in
|
|
`tools/Opc.Ua.SourceGeneration.Core/Generators.cs` as
|
|
`OverrideDependencyPrefixes`. It runs after `OpenModelDesign` and before
|
|
generation so that all downstream emitters see the harmonised prefix/name
|
|
values.
|
|
|
|
Both `GenerateCode` passes (NodeSet2 and ModelDesign) take the referenced
|
|
models as a single per-URI map of `ModelDependencyReference` and derive the
|
|
decoded payload map from it themselves via `BuildReferencedDependencyMap` —
|
|
the reference carries the raw base64 payload and memoises `GetDependency()`,
|
|
so the payload map is never threaded as a separate argument.
|
|
|
|
The payload-import surface lives directly on
|
|
`tools/Opc.Ua.SourceGeneration.Core/Schema/ModelDesignValidator.cs` (the
|
|
former `ModelDesignValidator.SnapshotImport.cs` partial was folded into the
|
|
main file). The validator's `ImportDependency(dependency, prefix, name)` API
|
|
queues a `ModelDependencyV1` for ingestion; `ApplyPendingDependencies()`
|
|
materialises the carried types into the validator's node table before the
|
|
dependency-loading pass walks `AdditionalFiles`, so consumer types can
|
|
resolve `BaseType` / `TypeDefinition` / `DataType` references against the
|
|
upstream's published types without those upstream models being present in
|
|
`AdditionalFiles`. Payloads for models that *are* present as design files in
|
|
the current compilation are skipped — the explicit file is authoritative.
|
|
After all design files are loaded, the validator links the
|
|
payload-materialised data types (base types, field data types, structure /
|
|
enumeration classification), so a local `ModelDesign` structure can subtype
|
|
a structure published only through a referenced assembly's payload.
|
|
|
|
Design files loaded as dependencies of the current target skip full
|
|
dictionary validation, so the validator links them the same way after
|
|
loading: their data types (as above) and their instances — the
|
|
`TypeDefinition` / `DataType` node references of the types' children,
|
|
variable-type data type restrictions, and method arguments including the
|
|
`InputArguments` / `OutputArguments` argument properties. A target
|
|
`ModelDesign` can therefore declare an `Object` or `Variable` whose
|
|
`TypeDefinition` is a type from another design file and have its inherited
|
|
children generate exactly as if the type were declared locally.
|
|
|
|
|