mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-17 05:11:13 +08:00
7fe8b30cc3
# Description - Replace the Azure DevOps continuous-preview feed with the GitHub Packages NuGet feed, including its classic PAT `read:packages` requirement, while retaining nuget.org for official public 2.0 preview releases. - Document one-shot MCP execution with `dotnet tool exec` and `dnx`. - Make this repository registerable as the `opcua-dotnet` Copilot marketplace and package `opcua-v20-migration` as an installable plugin. - Bundle the 15 thematic migration documents for offline plugin use and add a CI check that keeps the bundle and marketplace metadata synchronized. - Align the migration analyzer guidance with current master, including the new `UA0030` analyzer while retaining `UA0029` as a runtime-shim-only marker with reserved diagnostic metadata. - Restrict generated collection-shim type discovery to consumer source declarations before the exact `System.<Type>` / `Opc.Ua.<Type>` metadata fallback, with regression coverage for dependency-only metadata. - Update 52 unpinned NuGet package IDs across 60 central entries, plus two schema-test overrides, while preserving documented compatibility and security pins. ## Related Issues - Fixes #4330 ## Validation - `dotnet build UA.slnx -c Release --no-restore`: 0 warnings, 0 errors. - Migration analyzer tests across net472, net48, net8.0, net9.0, and net10.0: 795 passed, 0 failed, 0 skipped. - The migration plugin bundle, manifests, PowerShell, XML, links, and isolated marketplace installation validate successfully. - The direct and transitive NuGet vulnerability audit reports no known vulnerabilities. - A full solution test run was completed before merging current master. Its remaining failures were existing locale, path-length, GPU, shared-state, and flaky integration failures. Persistent cases reproduced on the pre-update commit, while isolated reruns cleared the dependency-relevant Core, GDS, Sessions, Subscriptions, and durable-queue failures. ## 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. - [ ] 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. - [x] I have addressed **all** PR feedback received. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e0ecb0e9-e5ef-4b2f-9922-41a7d42b0372
385 lines
17 KiB
C#
385 lines
17 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 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.Collections.Generic;
|
|
using System.Collections.Immutable;
|
|
using System.Linq;
|
|
using System.Text;
|
|
using System.Threading;
|
|
using Microsoft.CodeAnalysis;
|
|
using Microsoft.CodeAnalysis.CSharp;
|
|
using Microsoft.CodeAnalysis.CSharp.Syntax;
|
|
using Microsoft.CodeAnalysis.Text;
|
|
|
|
namespace Opc.Ua.MigrationAnalyzer.Generator
|
|
{
|
|
/// <summary>
|
|
/// Emits a <code>public sealed [Obsolete] class <Name>Collection :
|
|
/// List<TElement></code> shim into the consumer's compilation for every
|
|
/// <c><Name>Collection</c> reference that fails to bind, so 1.5.378-style
|
|
/// call sites compile against the 2.0 stack while UA0002 still guides the
|
|
/// eventual rewrite to <c>List<T></c> / <c>ArrayOf<T></c>.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Pipeline:
|
|
/// <list type="number">
|
|
/// <item>
|
|
/// Syntactic filter: <see cref="IdentifierNameSyntax"/> whose
|
|
/// <c>Identifier.ValueText</c> ends with <c>"Collection"</c> AND appears in a
|
|
/// type position (object-creation type, variable / parameter / field
|
|
/// declaration type, generic argument, <c>typeof</c>, cast target).
|
|
/// </item>
|
|
/// <item>
|
|
/// Semantic transform: skip if the symbol resolves (the type still exists);
|
|
/// otherwise resolve the element type in priority order:
|
|
/// <list type="bullet">
|
|
/// <item><see cref="CollectionShimCatalog.WellKnownOverrides"/> — element-type
|
|
/// renames across the 1.5.378 → 2.0 boundary (only 4 entries).</item>
|
|
/// <item><c>Compilation.GetSymbolsWithName</c> — model-compiled
|
|
/// <c><UserType></c>s declared in the consumer's source.</item>
|
|
/// <item><c>Compilation.GetTypeByMetadataName</c> against exact
|
|
/// <c>System.<Type></c> and <c>Opc.Ua.<Type></c> metadata names —
|
|
/// primitive aliases (Int32, Boolean, ...) and built-in OPC UA element
|
|
/// types (NodeId, Variant, DataValue, ...).</item>
|
|
/// </list>
|
|
/// On zero / ambiguous matches, report <c>MIG01</c> instead of emitting.
|
|
/// </item>
|
|
/// <item>
|
|
/// Collect & deduplicate by <c>(shortName, elementFqn)</c>; emit one
|
|
/// <c><name>.g.cs</c> file per unique entry.
|
|
/// </item>
|
|
/// </list>
|
|
/// </remarks>
|
|
[Generator(LanguageNames.CSharp)]
|
|
public sealed class MigrationGenerator : IIncrementalGenerator
|
|
{
|
|
private const string CollectionSuffix = "Collection";
|
|
private const string ShimNamespace = "Opc.Ua";
|
|
|
|
/// <inheritdoc/>
|
|
public void Initialize(IncrementalGeneratorInitializationContext context)
|
|
{
|
|
// 1. Syntactic candidates: every IdentifierNameSyntax that smells like a
|
|
// legacy <Type>Collection in a type position.
|
|
IncrementalValuesProvider<CandidateSite> candidates = context.SyntaxProvider
|
|
.CreateSyntaxProvider(
|
|
predicate: static (node, _) => IsLegacyCollectionCandidate(node),
|
|
transform: static (ctx, ct) => TransformCandidate(ctx, ct))
|
|
.Where(static x => x is not null)
|
|
.Select(static (x, _) => x!.Value);
|
|
|
|
// 2. Per compilation: bucket the candidates so we can dedup + emit once.
|
|
IncrementalValueProvider<ImmutableArray<CandidateSite>> collected =
|
|
candidates.Collect();
|
|
|
|
context.RegisterSourceOutput(collected, static (spc, sites) => Emit(spc, sites));
|
|
}
|
|
|
|
private static bool IsLegacyCollectionCandidate(SyntaxNode node)
|
|
{
|
|
if (node is not IdentifierNameSyntax id)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string text = id.Identifier.ValueText;
|
|
if (text.Length <= CollectionSuffix.Length ||
|
|
!text.EndsWith(CollectionSuffix, System.StringComparison.Ordinal))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
return IsInTypePosition(id);
|
|
}
|
|
|
|
private static bool IsInTypePosition(SyntaxNode node)
|
|
{
|
|
// Reject obviously-non-type contexts (variable references, method-call
|
|
// receivers that aren't static-typed accesses, etc.) by checking the
|
|
// immediate / shallow ancestor.
|
|
SyntaxNode? parent = node.Parent;
|
|
while (parent is QualifiedNameSyntax or AliasQualifiedNameSyntax or NameSyntax)
|
|
{
|
|
parent = parent.Parent;
|
|
}
|
|
|
|
return parent switch
|
|
{
|
|
ObjectCreationExpressionSyntax oce when oce.Type == node => true,
|
|
TypeArgumentListSyntax => true,
|
|
ArrayTypeSyntax => true,
|
|
NullableTypeSyntax => true,
|
|
TupleElementSyntax => true,
|
|
VariableDeclarationSyntax vd when vd.Type == node => true,
|
|
ParameterSyntax p when p.Type == node => true,
|
|
PropertyDeclarationSyntax pd when pd.Type == node => true,
|
|
FieldDeclarationSyntax fd when fd.Declaration.Type == node => true,
|
|
MethodDeclarationSyntax md when md.ReturnType == node => true,
|
|
TypeOfExpressionSyntax => true,
|
|
CastExpressionSyntax ce when ce.Type == node => true,
|
|
DefaultExpressionSyntax de when de.Type == node => true,
|
|
BaseListSyntax => true,
|
|
SimpleBaseTypeSyntax => true,
|
|
IsPatternExpressionSyntax => true,
|
|
DeclarationExpressionSyntax => true,
|
|
_ => false
|
|
};
|
|
}
|
|
|
|
private static CandidateSite? TransformCandidate(GeneratorSyntaxContext ctx, CancellationToken ct)
|
|
{
|
|
var id = (IdentifierNameSyntax)ctx.Node;
|
|
string shortName = id.Identifier.ValueText;
|
|
|
|
// If the type already binds in the compilation, no shim needed.
|
|
SymbolInfo info = ctx.SemanticModel.GetSymbolInfo(id, ct);
|
|
if (info.Symbol is INamedTypeSymbol)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
string elementShortName = shortName.Substring(0, shortName.Length - CollectionSuffix.Length);
|
|
Location location = id.GetLocation();
|
|
|
|
// (a) Catalog override fast-path.
|
|
if (CollectionShimCatalog.WellKnownOverrides.TryGetValue(shortName, out string? elementFqn))
|
|
{
|
|
return new CandidateSite(
|
|
shortName: shortName,
|
|
elementDisplay: elementFqn,
|
|
resolved: true,
|
|
location: location);
|
|
}
|
|
|
|
// (b) Semantic lookup for model-compiled element types (declared in the
|
|
// consumer's source — captures the legacy model-compiler pattern where
|
|
// Foo.BarCollection sat next to Foo.Bar in the same source tree).
|
|
ImmutableArray<INamedTypeSymbol> matches = [.. ctx.SemanticModel.Compilation
|
|
.GetSymbolsWithName(elementShortName, SymbolFilter.Type, ct)
|
|
.OfType<INamedTypeSymbol>()
|
|
.Where(static symbol => symbol.Locations.Any(static location => location.IsInSource))];
|
|
if (matches.Length == 1)
|
|
{
|
|
INamedTypeSymbol elementType = matches[0];
|
|
return new CandidateSite(
|
|
shortName: shortName,
|
|
elementDisplay: elementType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
|
|
resolved: true,
|
|
location: location);
|
|
}
|
|
|
|
// (c) Metadata-reference lookup for primitives + standard OPC UA types.
|
|
// GetSymbolsWithName above only inspects source declarations, so types
|
|
// like System.Int32 (from mscorlib) or Opc.Ua.NodeId (from the
|
|
// Opc.Ua.Types reference) won't be found there. We try the well-known
|
|
// namespaces a 1.5.378 <Type>Collection might wrap, in priority order:
|
|
// 1. System.<Type> — covers primitive aliases (Int32, Boolean, ...).
|
|
// 2. Opc.Ua.<Type> — covers all built-in OPC UA element types
|
|
// (NodeId, Variant, DataValue, ExpandedNodeId,
|
|
// EndpointDescription, ReadValueId, ...).
|
|
INamedTypeSymbol? metadataType =
|
|
ctx.SemanticModel.Compilation.GetTypeByMetadataName("System." + elementShortName)
|
|
?? ctx.SemanticModel.Compilation.GetTypeByMetadataName("Opc.Ua." + elementShortName);
|
|
if (metadataType is not null)
|
|
{
|
|
return new CandidateSite(
|
|
shortName: shortName,
|
|
elementDisplay: metadataType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat),
|
|
resolved: true,
|
|
location: location);
|
|
}
|
|
|
|
// (d) Unresolvable - propagate the location so we can emit MIG01.
|
|
return new CandidateSite(
|
|
shortName: shortName,
|
|
elementDisplay: elementShortName,
|
|
resolved: false,
|
|
location: location);
|
|
}
|
|
|
|
private static void Emit(SourceProductionContext spc, ImmutableArray<CandidateSite> sites)
|
|
{
|
|
if (sites.IsDefaultOrEmpty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Dedup the emit set by (shortName, elementDisplay); report MIG01 only
|
|
// when no resolved entry for the same shortName exists.
|
|
HashSet<string> resolvedShortNames = new(System.StringComparer.Ordinal);
|
|
Dictionary<string, (string ElementDisplay, Location Location)> emitTargets =
|
|
new(System.StringComparer.Ordinal);
|
|
List<(string ShortName, string ElementShortName, Location Location)> unresolvedSites = [];
|
|
|
|
foreach (CandidateSite site in sites)
|
|
{
|
|
if (site.Resolved)
|
|
{
|
|
resolvedShortNames.Add(site.ShortName);
|
|
if (!emitTargets.ContainsKey(site.ShortName))
|
|
{
|
|
emitTargets[site.ShortName] = (site.ElementDisplay, site.Location);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
unresolvedSites.Add((site.ShortName, site.ElementDisplay, site.Location));
|
|
}
|
|
}
|
|
|
|
foreach (KeyValuePair<string, (string ElementDisplay, Location Location)> entry in emitTargets)
|
|
{
|
|
string source = RenderShimSource(entry.Key, entry.Value.ElementDisplay);
|
|
spc.AddSource($"{entry.Key}.g.cs", SourceText.From(source, Encoding.UTF8));
|
|
}
|
|
|
|
foreach ((string shortName, string elementShortName, Location location) in unresolvedSites)
|
|
{
|
|
if (resolvedShortNames.Contains(shortName))
|
|
{
|
|
// Already covered by a sibling resolved site; suppress the noise.
|
|
continue;
|
|
}
|
|
|
|
spc.ReportDiagnostic(Diagnostic.Create(
|
|
GeneratorDiagnostics.UnresolvableElementType,
|
|
location,
|
|
elementShortName,
|
|
shortName));
|
|
}
|
|
}
|
|
|
|
private static string RenderShimSource(string shortName, string elementDisplay)
|
|
{
|
|
// Strip the leading "global::" if the catalog supplied it for readability
|
|
// in the [Obsolete] message + XML doc, but keep the qualified form on
|
|
// the base type / ctors so name resolution can't go wrong.
|
|
string elementDisplayShort = elementDisplay.StartsWith("global::", System.StringComparison.Ordinal)
|
|
? elementDisplay.Substring("global::".Length)
|
|
: elementDisplay;
|
|
|
|
return $$"""
|
|
// <auto-generated/>
|
|
#nullable enable
|
|
|
|
#pragma warning disable CS1591 // generated public shim members intentionally omit XML docs
|
|
#pragma warning disable RCS0056 // generated code has long obsolete messages
|
|
|
|
namespace {{ShimNamespace}}
|
|
{
|
|
/// <summary>
|
|
/// Source-generated shim for the legacy '{{shortName}}' wrapper that was
|
|
/// removed in 2.0. Inherits from <c>List<{{elementDisplayShort}}></c>
|
|
/// so 1.5.378-style call sites compile, and converts implicitly to
|
|
/// <c>ArrayOf<{{elementDisplayShort}}></c> so 2.0 APIs that expect
|
|
/// <c>ArrayOf</c> still accept the instance. Use <c>List<{{elementDisplayShort}}></c>
|
|
/// for mutable storage or <c>ArrayOf<{{elementDisplayShort}}></c> for
|
|
/// read-only consumers. Tracked by analyzer rule <c>UA0002</c>.
|
|
/// </summary>
|
|
[global::System.Obsolete(
|
|
"'{{shortName}}' was removed in 2.0. Use 'List<{{elementDisplayShort}}>' " +
|
|
"or 'ArrayOf<{{elementDisplayShort}}>' instead. (UA0002)")]
|
|
public sealed class {{shortName}} : global::System.Collections.Generic.List<{{elementDisplay}}>
|
|
{
|
|
public {{shortName}}() { }
|
|
public {{shortName}}(int capacity) : base(capacity) { }
|
|
public {{shortName}}(
|
|
global::System.Collections.Generic.IEnumerable<{{elementDisplay}}> collection)
|
|
: base(collection) { }
|
|
public static implicit operator global::Opc.Ua.ArrayOf<{{elementDisplay}}>(
|
|
{{shortName}}? value)
|
|
=> value is null ? default : value.ToArrayOf();
|
|
}
|
|
}
|
|
|
|
""";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Per-call-site capture flowed through the incremental pipeline.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Roslyn requires every value-type flowing through an
|
|
/// <c>IncrementalValueProvider</c> to be equatable for caching correctness.
|
|
/// The <c>Location</c> isn't part of the equality contract because it changes
|
|
/// across edits even when the candidate set is otherwise stable; the
|
|
/// generator only uses it as the report site for <c>MIG01</c>.
|
|
/// </remarks>
|
|
private readonly struct CandidateSite : System.IEquatable<CandidateSite>
|
|
{
|
|
public CandidateSite(string shortName, string elementDisplay, bool resolved, Location location)
|
|
{
|
|
ShortName = shortName;
|
|
ElementDisplay = elementDisplay;
|
|
Resolved = resolved;
|
|
Location = location;
|
|
}
|
|
|
|
public string ShortName { get; }
|
|
|
|
public string ElementDisplay { get; }
|
|
|
|
public bool Resolved { get; }
|
|
|
|
public Location Location { get; }
|
|
|
|
public bool Equals(CandidateSite other)
|
|
{
|
|
return string.Equals(ShortName, other.ShortName, System.StringComparison.Ordinal) &&
|
|
string.Equals(ElementDisplay, other.ElementDisplay, System.StringComparison.Ordinal) &&
|
|
Resolved == other.Resolved;
|
|
}
|
|
|
|
public override bool Equals(object? obj)
|
|
{
|
|
return obj is CandidateSite other && Equals(other);
|
|
}
|
|
|
|
public override int GetHashCode()
|
|
{
|
|
int hash = 17;
|
|
hash = (hash * 31) + System.StringComparer.Ordinal.GetHashCode(ShortName);
|
|
hash = (hash * 31) + System.StringComparer.Ordinal.GetHashCode(ElementDisplay);
|
|
hash = (hash * 31) + Resolved.GetHashCode();
|
|
return hash;
|
|
}
|
|
|
|
public static bool operator ==(CandidateSite left, CandidateSite right)
|
|
{
|
|
return left.Equals(right);
|
|
}
|
|
|
|
public static bool operator !=(CandidateSite left, CandidateSite right)
|
|
{
|
|
return !left.Equals(right);
|
|
}
|
|
}
|
|
}
|
|
}
|