mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-18 22:55:08 +08:00
a81d981755
# Description
Pure code-style sweep across the repo — no behavioral changes. Drives
`dotnet format` info-level diagnostics down on a large slice of the
codebase (Stack, Libraries, Tests, Tools).
Six commits, applied bottom-up in safety order so each batch leaves the
build green:
1. **`style: fix whitespace and final-newline formatting (WHITESPACE,
FINALNEWLINE)`**
197 hits across ~30 files; pure mechanical cleanup.
2. **`style: apply dotnet format style fixes (IDE rules)`**
IDE-rule batch via `dotnet format style --severity info` —
`var`/explicit type, simplify names, collection expressions, expression
bodies, unused usings, etc.
3. **`style: apply dotnet format analyzers fixes (RCS blank-line
formatting)`**
Roslynator formatting (RCS0xxx) blank-line and punctuation rules.
4. **`style: convert comments to doc comments and inline local vars
(RCS1181, RCS1124)`**
24 files. RCS1181 promotes `// summary`-style comments above
public/internal members to `/// <summary>` doc comments; RCS1124 inlines
locals used exactly once. Generated `Tools/` files were reverted to keep
the source-generator outputs deterministic.
5. **`docs(agent): correct --diagnostics flag form and verify Roslynator
4.x code fixers`**
Updates `.github/agents/opc-ua-codestyle-enforcer.agent.md` with two
corrections discovered while running this sweep:
- `dotnet format --diagnostics "RULE1 RULE2"` (single quoted,
space-separated) silently fixes nothing on `dotnet format` 10.x — it
parses as one unknown ID and exits 0. The correct form is `--diagnostics
RULE1 --diagnostics RULE2 …`, repeated per rule.
- Roslynator 4.15.0 **does** ship code fixers for
RCS1078/1118/1124/1140/1142/1181/1221/1260 (the previous "no auto-fixer"
list was outdated). RCS1140/1142 default to `Hidden` so need `--severity
info` to surface and fire; RCS1078 requires
`roslynator_empty_string_style = field` (already set in
`.editorconfig`). Only NUnit4002/2046/2010 genuinely lack auto-fixers in
this stack.
6. **`style: format new code from master merge (dotnet format sweep)`**
After merging master into the branch (commit `e3ebc8742`), re-ran the
same three-phase format sweep on the 151 .cs files brought in by the
merge, scoped per project via repeated `--include`. 81 files changed;
same rule families as commits 1–4.
- Hand-fixed `Tests/Opc.Ua.MigrationAnalyzer.Tests/AnalyzerHarness.cs`
for `RCS1174` (redundant `async`/`await` removal) because the auto-fixer
didn't apply for that specific case.
- Reverted files that the formatter would break:
- `Libraries/Opc.Ua.Server/Server/ServerInternalData.cs` — multi-TFM
analysis disagreement injected `<<<<<<<` / `=======` / `>>>>>>>`
markers.
-
`Tools/Opc.Ua.MigrationAnalyzer.Core/{Core/Stack/Client/ChannelBase.cs,
Core/Stack/Types/IUserIdentityTokenHandler.cs,
Client/Session/Session.cs}` — IDE0005 removed `using
Opc.Ua.Security.Certificates` that IS used (the files are 1.5.378
reference fixtures for the analyzer).
- `Tests/Opc.Ua.Gds.Tests/PushTest.cs` — IDE0002 over-simplified
`OpcUa.ObjectTypeIds` to `ObjectTypeIds`, which then resolves to a
conflicting type.
### What this PR does **not** touch
- No behavioral or semantic logic changes.
- No `.editorconfig` rule promotions (rules stay at their current
severities; this is a backlog-burndown pass, not a rule tightening).
- Generated source under `Tools/` is intentionally excluded.
- Higher-risk rule families (`RCS1090`/`CA2007` `ConfigureAwait`,
`RCS1249` null-forgiving cleanup, `IDE0270`/`IDE0059`) are left for
follow-up sweeps because they need per-TFM build verification or
semantic review.
## Related Issues
_No tracking issue — this is a non-functional style sweep and the
agent-doc correction is a follow-up to the same work. Happy to open one
if the 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. _(N/A — style-only, no
behavioral change)_
- [x] I have added all necessary documentation. _(agent doc updated with
the two corrections)_
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings. _(verified clean on `net10.0` and `net48`; only
pre-existing `NETSDK1005`/`NETSDK1207` failures on Tools/AOT projects)_
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed. _(build
verified on net10 and net48 per batch; full test suite not yet run
locally — relying on CI)_
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
---------
Co-authored-by: agent <agent@local>
382 lines
17 KiB
C#
382 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 an <code>internal 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 <c>System.*</c> and
|
|
/// <c>Opc.Ua.*</c> — 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>()];
|
|
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.* — covers primitive aliases (Int32, Boolean, ...).
|
|
// 2. Opc.Ua.* — 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 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)")]
|
|
internal 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);
|
|
}
|
|
}
|
|
}
|
|
}
|