Files
Marc Schier 81f200c9d2 Align WoT conversion and runtime with Binding 1.1 (#4375)
## Summary

Align the generic WoT/NodeSet converter, WoT Connectivity registry, and
runtime
binding implementation with the OPC UA WoT Binding 1.1 draft.

### Conversion and preservation

- fix the standard placeholder ModellingRule NodeIds;
- make every produced/restored NodeSet importable by completing required
aliases;
- support `ua:ComponentOf`, arbitrary companion ReferenceTypes,
BrowseNames,
  InverseNames, symmetric references, and exact direction;
- enforce the projection annotation allowlist and deterministic total
order;
- emit and consume Method argument schemas, EventType data, Alarms &
Conditions,
  event severity, engineering units/ranges, localization, ValueRank, and
  ArrayDimensions;
- preserve unsupported content through JSON residue/native projection
rather than
  silently dropping it;
- compare residue conflicts with RFC 8785 canonical equality while
retaining
  byte-exact residue digests;
- stamp generated documents with `uav:bindingVersion: "1.1"`.

### Runtime bindings

- resolve an EventType Thing Model referenced with `tm:ref`, recursively
derive
  its `data` fields as the baseline selection, and overlay explicit
  `uav:eventSelectClauses` that also reference EventType TMs;
- pre-resolve EventType links asynchronously through the bounded
sibling-document
  resolver before synchronous protocol planning;
- retain read compatibility with legacy implementation-specific
`uav:eventFields`;
- expose nested event `data` matching the TD while retaining the flat
transport
  field index for compatibility;
- apply minimum-security constraints through deterministic endpoint
selection and
  fail-closed session verification;
- use one Unicode code-point comparer for projection membership and
endpoint ties.

### Validation and fixtures

- add strict, profile-aware validation while keeping default parsing
permissive and
  residue-preserving;
- pin 27 normative examples byte-for-byte to the specification
repository, with an
  offline SHA-256 manifest and regeneration test;
- regenerate checked-in WoT documents with the final canonical writer
and binding
  version;
- document the generic converter separately from the legacy OPC 10100-1
asset path.

## Notable compatibility changes

- converted NodeSets may gain a completed `<Aliases>` table so standard
  ReferenceType names can be imported;
- previously inverted/dangling placeholder ModellingRules are corrected;
- generated documents gain binding-version, localization,
rank/dimension, and exact
  ReferenceType metadata;
- event data now has the nested shape described by the binding;
- the former NodeId-based `uav:typeDefinitionId` clause is removed;
event
  selections now use EventType-TM `tm:ref` links;
- `uav:eventFields` remains readable but new documents use
  `uav:eventSelectClauses`;
- unknown well-formed future revision/profile claims produce unsupported
warnings
  rather than strict rejection.

## Generated files

Most of the 621-file diff is deterministic regeneration: 517 checked-in
WoT
documents gain the binding version and final canonical spelling.
Regeneration was
run twice and is byte-idempotent.

## Validation

- `dotnet restore UA.slnx`;
- `dotnet build UA.slnx -c Release` across all configured TFMs;
- `Opc.Ua.Types.Tests`: 9,215 net10.0; 9,208 net48; 9,208 net472;
- `Opc.Ua.WotCon.Bindings.Tests`: 611 each on net10.0, net9.0, and
net8.0;
- `Opc.Ua.WotCon.Tests`: 1,136 net10.0;
- `Opc.Ua.WotCon.Samples.Tests`: 14 net10.0;
- NativeAOT test executable: 136/136;
- all 27 strict fixture conversion/import checks;
- formatting, analyzers, `git diff --check`, and regeneration
idempotence.

The full net48 run retains one unrelated existing MAX_PATH failure in

`FileWotRegistryStoreTests.MultiLevelDirectoryCreationSyncsEveryModifiedParentInOrder`.
Solution-wide stress runs also include an intentional 60-minute soak and
unrelated
locale-sensitive/flaky tests; all affected WoT projects and tests pass.

## Specification

Depends on OPCF-Members/spec-drafts#31.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 6a9f41b1-72f0-4c74-bd7f-26fa531e5738
2026-09-02 00:09:36 +00:00

425 lines
17 KiB
C#

/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Threading;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Text;
using Opc.Ua.Export;
using Opc.Ua.Wot;
namespace Opc.Ua.SourceGeneration
{
/// <summary>
/// The result of attempting to convert one WoT AdditionalFile input into
/// an in-memory NodeSet2 additional text. Conversion never throws: parse,
/// bounds, missing preservation/native mapping, dependency/resolver and
/// general conversion problems are all captured as <see cref="Diagnostic"/>s
/// instead, so a single malformed or unsupported WoT input can never fail
/// the whole generator run.
/// </summary>
internal sealed class WotConversionOutcome
{
/// <summary>
/// The path of the original WoT AdditionalFile.
/// </summary>
public string SourcePath { get; }
/// <summary>
/// The synthesized in-memory NodeSet2 additional text, or
/// <c>null</c> when conversion did not produce a usable result.
/// </summary>
public AdditionalText NodeSetText { get; }
/// <summary>
/// The AdditionalFiles options (Prefix, Name, ModelUri, Version,
/// Ignore) captured from the original WoT input, to be preserved
/// after wrapping it as a NodeSet2 file.
/// </summary>
public NodesetFileOptions Options { get; }
/// <summary>
/// The diagnostics produced while attempting the conversion, in the
/// order they occurred. Does not include virtual-path collision
/// diagnostics, which require the full set of inputs to detect.
/// </summary>
public ImmutableArray<Diagnostic> Diagnostics { get; }
public WotConversionOutcome(
string sourcePath,
AdditionalText nodeSetText,
NodesetFileOptions options,
ImmutableArray<Diagnostic> diagnostics)
{
SourcePath = sourcePath;
NodeSetText = nodeSetText;
Options = options;
Diagnostics = diagnostics;
}
}
/// <summary>
/// Presents a WoT model input to the existing generator as an in-memory
/// NodeSet2 file, using the completed <see cref="Wot"/> converter
/// entirely in memory (no file or network I/O beyond the supplied
/// <see cref="AdditionalText"/> content).
/// </summary>
internal sealed class WotNodeSetAdditionalText : AdditionalText
{
private WotNodeSetAdditionalText(string path, SourceText text)
{
Path = path;
m_text = text;
}
/// <inheritdoc/>
public override string Path { get; }
/// <inheritdoc/>
public override SourceText GetText(CancellationToken cancellationToken = default)
{
return m_text;
}
/// <summary>
/// Attempts to convert a WoT AdditionalFile into an in-memory
/// NodeSet2 additional text. Never throws (other than on
/// cancellation): every failure mode is reported as a diagnostic on
/// the returned <see cref="WotConversionOutcome"/> instead, so a
/// malformed or unsupported document can never crash the generator.
/// </summary>
/// <param name="source">The original WoT AdditionalFile.</param>
/// <param name="options">The AdditionalFiles options to preserve.</param>
/// <param name="cancellationToken">A cancellation token.</param>
public static WotConversionOutcome Convert(
AdditionalText source,
NodesetFileOptions options,
CancellationToken cancellationToken)
{
string sourcePath = source.Path;
ImmutableArray<Diagnostic>.Builder diagnostics = ImmutableArray.CreateBuilder<Diagnostic>();
SourceText sourceText;
try
{
sourceText = source.GetText(cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diagnostics.Add(CreateParseDiagnostic(sourcePath, null, ex.Message));
return Failed(sourcePath, options, diagnostics);
}
if (sourceText is null)
{
diagnostics.Add(CreateParseDiagnostic(
sourcePath, null, "The WoT model source text could not be read."));
return Failed(sourcePath, options, diagnostics);
}
byte[] utf8Json;
try
{
utf8Json = GetUtf8Bytes(sourceText, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diagnostics.Add(CreateParseDiagnostic(sourcePath, sourceText, ex.Message));
return Failed(sourcePath, options, diagnostics);
}
WotDocument document;
try
{
document = WotDocument.Parse(utf8Json);
}
catch (JsonException ex)
{
diagnostics.Add(CreateParseDiagnostic(sourcePath, sourceText, ex));
return Failed(sourcePath, options, diagnostics);
}
catch (FormatException ex)
{
// Thrown when the document exceeds a configured size bound.
diagnostics.Add(CreateParseDiagnostic(sourcePath, sourceText, ex.Message));
return Failed(sourcePath, options, diagnostics);
}
UANodeSet nodeSet;
try
{
using (document)
{
// No external resolver: source generation performs no
// file or network I/O beyond the supplied AdditionalText
// content, so referenced TD/TM documents are left
// unresolved (reported as a WotDiagnosticCode.
// UnresolvedReference warning by the converter, and as a
// WotDiagnosticCode.EventSelectionUnresolved error where an
// event affordance states a field selection that only a
// resolved EventType definition can supply).
WotConversionResult<UANodeSet> result = WotNodeSetConverter.ToNodeSetResult(
document,
options: null);
AppendConversionDiagnostics(diagnostics, sourcePath, result.Diagnostics);
// Exclude any result that produced an error diagnostic even
// when a (partial or inconsistent) NodeSet value was still
// produced. This decision is independent of whether the
// MODELGEN031 error diagnostic is later reported or suppressed,
// so a suppressed conversion error can never emit a model.
nodeSet = result.Success ? result.Value : null;
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Defence in depth: the converter is documented to report
// diagnostics rather than throw. Guard against any residual
// exception so a single malformed input can never take down
// the whole generator run.
diagnostics.Add(CreateConversionExceptionDiagnostic(sourcePath, ex));
return Failed(sourcePath, options, diagnostics);
}
if (nodeSet is null)
{
// An error diagnostic explaining why was already appended.
return Failed(sourcePath, options, diagnostics);
}
string xml;
try
{
using var stream = new MemoryStream();
nodeSet.Write(stream);
xml = Encoding.UTF8.GetString(stream.ToArray());
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
diagnostics.Add(CreateConversionExceptionDiagnostic(sourcePath, ex));
return Failed(sourcePath, options, diagnostics);
}
if (xml.Length > 0 && xml[0] == '\uFEFF')
{
xml = xml[1..];
}
var nodeSetText = new WotNodeSetAdditionalText(
GetNodeSetPath(sourcePath),
SourceText.From(xml, Encoding.UTF8));
return new WotConversionOutcome(
sourcePath, nodeSetText, options, diagnostics.ToImmutable());
}
private static WotConversionOutcome Failed(
string sourcePath,
NodesetFileOptions options,
ImmutableArray<Diagnostic>.Builder diagnostics)
{
return new WotConversionOutcome(sourcePath, null, options, diagnostics.ToImmutable());
}
private static void AppendConversionDiagnostics(
ImmutableArray<Diagnostic>.Builder builder,
string sourcePath,
IReadOnlyList<WotDiagnostic> wotDiagnostics)
{
if (wotDiagnostics.Count == 0)
{
return;
}
Location location = CreateFileLocation(sourcePath);
for (int ii = 0; ii < wotDiagnostics.Count; ii++)
{
WotDiagnostic diagnostic = wotDiagnostics[ii];
DiagnosticDescriptor descriptor = diagnostic.Severity switch
{
WotDiagnosticSeverity.Error => SourceGenerator.WotConversionError,
WotDiagnosticSeverity.Warning => SourceGenerator.WotConversionWarning,
_ => SourceGenerator.WotConversionInfo
};
builder.Add(Diagnostic.Create(descriptor, location, sourcePath, diagnostic.ToString()));
}
}
private static Diagnostic CreateParseDiagnostic(
string sourcePath, SourceText sourceText, JsonException ex)
{
Location location = CreateLocation(sourcePath, sourceText, ex.LineNumber, ex.BytePositionInLine);
return Diagnostic.Create(SourceGenerator.WotParseError, location, sourcePath, ex.Message);
}
private static Diagnostic CreateParseDiagnostic(
string sourcePath, SourceText sourceText, string message)
{
Location location = CreateLocation(sourcePath, sourceText, null, null);
return Diagnostic.Create(SourceGenerator.WotParseError, location, sourcePath, message);
}
private static Diagnostic CreateConversionExceptionDiagnostic(string sourcePath, Exception ex)
{
Location location = CreateFileLocation(sourcePath);
return Diagnostic.Create(
SourceGenerator.WotConversionError,
location,
sourcePath,
$"{ex.GetType().Name}: {ex.Message}");
}
private static byte[] GetUtf8Bytes(SourceText sourceText, CancellationToken cancellationToken)
{
using var stream = new MemoryStream();
using (var writer = new StreamWriter(stream, new UTF8Encoding(false), 4096, leaveOpen: true))
{
sourceText.Write(writer, cancellationToken);
}
return stream.ToArray();
}
/// <summary>
/// Creates a location anchored at the start of the given file. Used
/// when no more precise position is available.
/// </summary>
internal static Location CreateFileLocation(string path)
{
return Location.Create(path, default, default);
}
private static Location CreateLocation(
string path,
SourceText text,
long? lineNumber,
long? bytePositionInLine)
{
if (text is null || text.Lines.Count == 0)
{
return CreateFileLocation(path);
}
int line = 0;
int character = 0;
if (lineNumber >= 0 && lineNumber.Value < text.Lines.Count)
{
line = (int)lineNumber.Value;
if (bytePositionInLine >= 0)
{
character = GetCharacterIndexFromUtf8ByteOffset(text, line, bytePositionInLine.Value);
}
}
var position = new LinePosition(line, character);
int offset = text.Lines[line].Start + character;
var span = new TextSpan(offset, 0);
return Location.Create(path, span, new LinePositionSpan(position, position));
}
private static int GetCharacterIndexFromUtf8ByteOffset(SourceText text, int line, long byteOffset)
{
if (byteOffset <= 0)
{
return 0;
}
TextLine textLine = text.Lines[line];
int lineLength = textLine.Span.Length;
long bytesSeen = 0;
int character = 0;
while (character < lineLength)
{
char current = text[textLine.Start + character];
int characterCount = 1;
int byteCount;
if (char.IsHighSurrogate(current) &&
character + 1 < lineLength &&
char.IsLowSurrogate(text[textLine.Start + character + 1]))
{
characterCount = 2;
byteCount = 4;
}
else if (current <= '\u007F')
{
byteCount = 1;
}
else if (current <= '\u07FF')
{
byteCount = 2;
}
else
{
byteCount = 3;
}
long nextBytes = bytesSeen + byteCount;
if (byteOffset < nextBytes)
{
return character;
}
character += characterCount;
bytesSeen = nextBytes;
if (byteOffset == bytesSeen)
{
return character;
}
}
return lineLength;
}
private static string GetNodeSetPath(string sourcePath)
{
string directory = System.IO.Path.GetDirectoryName(sourcePath) ?? string.Empty;
string name = System.IO.Path.GetFileName(sourcePath);
// The canonical suffixes are checked before the bare ".jsonld"
// fallback used by opted-in plain JSON-LD inputs: none of the
// canonical suffixes is itself a suffix of another, but every one
// of them ends with ".jsonld" or ".json" as plain text, so the
// untyped fallback must come last.
foreach (string suffix in Extensions.WotFileExtensions)
{
if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase))
{
name = name[..^suffix.Length];
return System.IO.Path.Combine(directory, name + ".NodeSet2.xml");
}
}
const string plainJsonLd = ".jsonld";
if (name.EndsWith(plainJsonLd, StringComparison.OrdinalIgnoreCase))
{
name = name[..^plainJsonLd.Length];
}
return System.IO.Path.Combine(directory, name + ".NodeSet2.xml");
}
private readonly SourceText m_text;
}
}