refactor(opcuaclient): move NamespaceMap to a .Session leaf so Contracts is dependency-light

G-7 wired OpcUaClientTagDefinitionFactory.Inspect into the deploy-time TagConfig
gate, which gave ControlPlane a ProjectReference to
Driver.OpcUaClient.Contracts — and with it, transitively, the whole OPC UA client
stack (5 OPCFoundation packages) into every host that loads the control plane.

NamespaceMap was the only reason Contracts carried
OPCFoundation.NetStandard.Opc.Ua.Client: its entire public surface is
ISession/NamespaceTable/NodeId-typed. It moves to a new leaf project,
ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session, referenced by the runtime .Driver
and by .Browser. Contracts is left with Core.Abstractions and NOTHING else —
the same shape as Calculation.Contracts.

The namespace stays ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient, so no consumer's
`using` changed. The .Session -> .Contracts direction is deliberate: the blob
read NamespaceMap needs is OpcUaClientTagDefinitionFactory.TryReadNodeId, the
one authority it shares with the deploy gate, and that must stay in the
dependency-light half.

Why a new project rather than folding NamespaceMap into the runtime .Driver and
having .Browser reference it (the Driver.Mqtt.Browser #512-style deliberate
exception): the AdminUI project-references .Browser and NOT .Driver, so that
shape would pull the OpcUaClient driver runtime — plus Core,
Opc.Ua.Configuration and Secrets.Abstractions — into the AdminUI's build/deploy
graph. That is exactly the violation #512 spent two extractions removing from
Mqtt.Browser; re-introducing it to save one csproj is a bad trade.

Guard: ContractsIsDependencyLightTests (Driver.OpcUaClient.Tests) asserts the
Contracts manifest names no Opc.Ua* assembly, plus a counterpart asserting
.Session does. An ABSENCE assertion is sound on the manifest where a presence
one is not — the compiler elides an unused reference, but it MUST emit one the
moment Opc.Ua-typed code is added. Falsified by temporarily re-adding the
package plus an Opc.Ua.NodeId-typed member: red on Opc.Ua.Types, green again on
removal.

Evidence — `dotnet list ControlPlane package --include-transitive | grep -i opc`:
  before: OPCFoundation.NetStandard.Opc.Ua.{Client,Configuration,Core,
          Security.Certificates,Types} 1.5.378.106
  after:  none (and 0 Opc.Ua*.dll in ControlPlane's output directory)
This commit is contained in:
Joseph Doherty
2026-08-09 21:56:29 -04:00
parent fefcd2c872
commit b4f8cb8de7
11 changed files with 161 additions and 14 deletions
+1
View File
@@ -48,6 +48,7 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj" /> <Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Contracts.csproj" />
@@ -8,6 +8,10 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" /> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" /> <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj" />
<!-- NamespaceMap. Deliberately the .Session leaf and NOT the runtime .Driver project: the AdminUI
references THIS project, so a .Driver reference would pull the driver runtime into the
AdminUI's deploy graph (the Gitea #512 shape). See the .Session csproj block comment. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" /> <PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
@@ -31,7 +31,8 @@ public sealed record OpcUaClientDriverOptions
/// with the cluster's raw OPC UA Client tags. Each <see cref="RawTagEntry"/> carries the tag's /// with the cluster's raw OPC UA Client tags. Each <see cref="RawTagEntry"/> carries the tag's
/// <b>RawPath</b> identity plus a driver <c>TagConfig</c> blob whose <c>nodeId</c> key holds the /// <b>RawPath</b> identity plus a driver <c>TagConfig</c> blob whose <c>nodeId</c> key holds the
/// upstream OPC UA node id string the driver dials. The driver builds a <c>RawPath → nodeId</c> /// upstream OPC UA node id string the driver dials. The driver builds a <c>RawPath → nodeId</c>
/// resolution table (via <see cref="NamespaceMap"/>) from this list at connect time; a /// resolution table (via <c>NamespaceMap</c>, in the sibling <c>.Session</c> project) from this
/// list at connect time; a
/// read/write/subscribe/history reference is always a RawPath resolved through that table. /// read/write/subscribe/history reference is always a RawPath resolved through that table.
/// </summary> /// </summary>
public IReadOnlyList<RawTagEntry> RawTags { get; init; } = []; public IReadOnlyList<RawTagEntry> RawTags { get; init; } = [];
@@ -4,13 +4,19 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient;
/// <summary> /// <summary>
/// The authored-raw-tag reader for the OPC UA Client (gateway) driver: one authority, consumed by both /// The authored-raw-tag reader for the OPC UA Client (gateway) driver: one authority, consumed by both
/// the runtime (<see cref="NamespaceMap"/>'s <c>RawPath → upstream node id</c> table build) and the /// the runtime (<c>NamespaceMap</c>'s <c>RawPath → upstream node id</c> table build) and the
/// deploy-time TagConfig gate (<see cref="Inspect"/>, deferment-register G-7). /// deploy-time TagConfig gate (<see cref="Inspect"/>, deferment-register G-7).
/// <para> /// <para>
/// <b>This read used to be private to <see cref="NamespaceMap"/>.</b> It moved here — unchanged — /// <b>This read used to be private to <c>NamespaceMap</c>.</b> It moved here — unchanged —
/// rather than being mirrored: a mirror of an accept/reject boundary drifts the moment either side /// rather than being mirrored: a mirror of an accept/reject boundary drifts the moment either side
/// is touched, and the drift is silent, with the gate describing a shape the driver no longer /// is touched, and the drift is silent, with the gate describing a shape the driver no longer
/// reads. <see cref="NamespaceMap"/> now calls <see cref="TryReadNodeId"/>. /// reads. <c>NamespaceMap</c> now calls <see cref="TryReadNodeId"/>.
/// </para>
/// <para>
/// <b><c>NamespaceMap</c> is named by text, not <c>cref</c>, on purpose.</b> It lives in the
/// sibling <c>Driver.OpcUaClient.Session</c> project, which references THIS one — the dependency
/// runs that way round so this assembly stays dependency-light for ControlPlane, which
/// project-references it for the G-7 gate. A <c>cref</c> here would need the reverse reference.
/// </para> /// </para>
/// <para> /// <para>
/// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadNodeId"/></b> — it asks the /// <b><see cref="Inspect"/> is never stricter than <see cref="TryReadNodeId"/></b> — it asks the
@@ -6,11 +6,12 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" /> <!-- DEPENDENCY-LIGHT ON PURPOSE: Core.Abstractions and nothing else, the same shape as
</ItemGroup> Calculation.Contracts. ControlPlane project-references this for the G-7 deploy-time
<ItemGroup> TagConfig gate (OpcUaClientTagDefinitionFactory.Inspect), so anything added here lands in
<!-- v3: NamespaceMap resolves a RawPath (RawTagEntry identity) to the upstream node id. every host that loads the control plane. The OPC UA client SDK used to be here, for
RawTagEntry lives in the Core.Abstractions leaf beside EquipmentTagRefResolver. --> NamespaceMap; NamespaceMap now lives in the sibling .Session leaf. Pinned by
ContractsIsDependencyLightTests (Driver.OpcUaClient.Tests). -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -52,6 +52,17 @@ public readonly record struct RawTagBinding(string NodeId, bool WriteIdempotent)
/// driver then re-binds against the live session via the static /// driver then re-binds against the live session via the static
/// <see cref="TryResolve(ISession?, string, out NodeId)"/>. /// <see cref="TryResolve(ISession?, string, out NodeId)"/>.
/// </para> /// </para>
/// <para>
/// <b>Why this type sits in <c>.Session</c> and not <c>.Contracts</c>.</b> Its whole public
/// surface is OPC UA SDK-typed (<see cref="ISession"/>, <see cref="NamespaceTable"/>,
/// <see cref="NodeId"/>), so while it lived in <c>.Contracts</c> that project had to carry
/// <c>OPCFoundation.NetStandard.Opc.Ua.Client</c> — and once ControlPlane project-referenced
/// <c>.Contracts</c> for the G-7 deploy-time TagConfig gate, the whole OPC UA client stack
/// came with it into every host that loads the control plane. The blob read this class needs
/// stayed behind in <c>.Contracts</c> as
/// <c>OpcUaClientTagDefinitionFactory.TryReadNodeId</c> (the ONE authority the deploy gate and
/// this table share), which is why the reference runs <c>.Session → .Contracts</c>.
/// </para>
/// </remarks> /// </remarks>
public sealed class NamespaceMap public sealed class NamespaceMap
{ {
@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Namespace stays ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient (no .Session suffix) so moving
NamespaceMap here changed no consumer's `using`. -->
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session</AssemblyName>
</PropertyGroup>
<!--
The live-OPC-UA-session layer of the OpcUaClient driver: everything the driver AND the AdminUI's
browser both need that is typed in ISession / NodeId / NamespaceTable. Today that is exactly
NamespaceMap.
WHY IT IS NOT IN .Contracts. It used to be, and .Contracts therefore carried
OPCFoundation.NetStandard.Opc.Ua.Client. When G-7 wired OpcUaClientTagDefinitionFactory.Inspect into
the deploy-time TagConfig gate, ControlPlane gained a ProjectReference to .Contracts — and with it
the whole OPC UA client stack, transitively, into every host that loads the control plane. A
Contracts project is meant to be the dependency-light parse/shape layer (see Calculation.Contracts:
Core.Abstractions and nothing else), so the SDK-typed half moved out rather than the gate doing
without.
WHY NOT JUST PUT IT IN THE .Driver PROJECT (the Mqtt.Browser-style deliberate exception). The
AdminUI project-references .Browser and NOT the runtime .Driver. Having .Browser reference .Driver
to reach NamespaceMap would pull the whole driver runtime — plus Core, Opc.Ua.Configuration and
Secrets.Abstractions — into the AdminUI's build/deploy graph. That is precisely the violation Gitea
#512 spent two extractions removing from Driver.Mqtt.Browser; re-introducing the same shape here to
save one csproj would be trading a measured regression for a little less churn.
KEEP THIS LEAF THIN. Anything addable here that is NOT session-typed belongs in .Contracts, or the
dependency-light property this split exists to buy erodes one file at a time. The pin is
ContractsIsDependencyLightTests in Driver.OpcUaClient.Tests.
-->
<ItemGroup>
<PackageReference Include="OPCFoundation.NetStandard.Opc.Ua.Client" />
</ItemGroup>
<ItemGroup>
<!-- RawTagEntry (the v3 authored-tag identity NamespaceMap indexes) lives in the Core.Abstractions
leaf; OpcUaClientTagDefinitionFactory is the ONE authority for reading a TagConfig blob's
nodeId, shared with the deploy gate — NamespaceMap calls it rather than re-parsing. -->
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
</ItemGroup>
</Project>
@@ -14,6 +14,9 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/> <ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/>
<!-- NamespaceMap (ISession-typed) — shared with .Browser, which is why it is its own leaf rather
than living here. See the block comment in the .Session csproj. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/> <ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
</ItemGroup> </ItemGroup>
@@ -54,10 +54,8 @@
TagConfigValidator checked them. Adding the reference is what makes TagConfigValidator checked them. Adding the reference is what makes
EquipmentTagConfigInspectorCoverageTests demand a dispatch-map entry for them — the guard scans EquipmentTagConfigInspectorCoverageTests demand a dispatch-map entry for them — the guard scans
the output directory, so an unreferenced Contracts assembly is invisible to it. the output directory, so an unreferenced Contracts assembly is invisible to it.
Note OpcUaClient.Contracts brings OPCFoundation.NetStandard.Opc.Ua.Client transitively: its OpcUaClient.Contracts is dependency-light (Core.Abstractions only) — the OPC UA client SDK it
NamespaceMap re-binds a stable nsu= reference against a live session. Every host that loads used to pull in transitively left with NamespaceMap, to Driver.OpcUaClient.Session. -->
ControlPlane (Host, AdminUI) already carries the OPC UA stack, so this adds no new deployment
surface. -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/> <ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/> <ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts\ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/> <ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Calculation.Contracts.csproj"/>
@@ -22,7 +22,9 @@
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/> <ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts.csproj"/> <!-- NamespaceMap.FromTable — it moved from .Contracts to the .Session leaf when .Contracts was
made dependency-light (ControlPlane references .Contracts for the G-7 deploy gate). -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/> <ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj"/>
</ItemGroup> </ItemGroup>
@@ -0,0 +1,74 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// Pins the dependency shape of <c>ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts</c>: it must not
/// reference the OPC UA client SDK.
/// <para>
/// <b>Why this matters beyond tidiness.</b> Deferment-register G-7 wired
/// <see cref="OpcUaClientTagDefinitionFactory.Inspect"/> into the deploy-time TagConfig gate, which
/// made <c>ControlPlane</c> project-reference this Contracts assembly. ControlPlane is loaded by
/// every admin- and driver-role host, so whatever Contracts drags in, they all carry. Contracts
/// used to hold <c>NamespaceMap</c> — <c>ISession</c>/<c>NodeId</c>-typed throughout — and with it a
/// <c>OPCFoundation.NetStandard.Opc.Ua.Client</c> PackageReference; NamespaceMap moved to the
/// sibling <c>.Session</c> leaf so the parse-only half could stay light (the same shape as
/// <c>Calculation.Contracts</c>: Core.Abstractions and nothing else).
/// </para>
/// <para>
/// <b>Direction matters: this is an ABSENCE assertion, and that is what makes it sound here.</b>
/// The assembly manifest is an unreliable witness for PRESENCE — the C# compiler elides a reference
/// nothing in the IL actually uses, so "assembly X is referenced" can be false while the csproj says
/// otherwise (the trap <c>EquipmentTagConfigInspectorCoverageTests</c> documents, which is why that
/// one scans the output directory instead). Absence has no such hole in the direction that matters:
/// the moment someone adds Opc.Ua-typed code to Contracts, the compiler MUST emit the reference and
/// this test goes red. A csproj PackageReference that nothing consumes is elided and slips past —
/// accepted deliberately, because an unused package reference is not the failure being guarded.
/// </para>
/// <para>
/// Falsified 2026-08-09 by temporarily adding an <c>Opc.Ua.NodeId</c>-typed member to Contracts:
/// the assertion failed naming <c>Opc.Ua.Core</c>, and passed again once removed.
/// </para>
/// </summary>
public sealed class ContractsIsDependencyLightTests
{
/// <summary>The Contracts assembly's manifest must name no OPC UA SDK assembly.</summary>
[Fact]
public void The_Contracts_assembly_does_not_reference_the_OPC_UA_SDK()
{
var contracts = typeof(OpcUaClientTagDefinitionFactory).Assembly;
// Positive control: name the assembly under test, so a rename/retarget cannot silently point this
// at something else that trivially satisfies the assertion below.
contracts.GetName().Name.ShouldBe("ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Contracts");
var opcUa = contracts.GetReferencedAssemblies()
.Select(a => a.Name ?? string.Empty)
.Where(n => n.StartsWith("Opc.Ua", StringComparison.OrdinalIgnoreCase))
.OrderBy(n => n, StringComparer.Ordinal)
.ToArray();
opcUa.ShouldBeEmpty(
"OpcUaClient.Contracts must stay free of the OPC UA client SDK — ControlPlane project-references "
+ "it for the G-7 deploy-time TagConfig gate, so every host that loads the control plane would "
+ "carry the stack. Put SDK-typed code in Driver.OpcUaClient.Session instead. Found: "
+ string.Join(", ", opcUa));
}
/// <summary>
/// The counterpart the assertion above is meaningful against: the SDK-typed half really did land
/// somewhere, and that somewhere does reference the SDK. Without this, "no Opc.Ua reference
/// anywhere" would read as a pass.
/// </summary>
[Fact]
public void The_Session_assembly_is_where_the_OPC_UA_SDK_reference_lives()
{
var session = typeof(NamespaceMap).Assembly;
session.GetName().Name.ShouldBe("ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session");
session.GetReferencedAssemblies()
.Select(a => a.Name ?? string.Empty)
.ShouldContain(n => n.StartsWith("Opc.Ua", StringComparison.OrdinalIgnoreCase));
}
}