Files
14fba2d120 Fix instance TypeDefinition across dependency ModelDesign boundaries (#4355)
# 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>
2026-08-31 09:47:08 +00:00

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.