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:
+3
-1
@@ -22,7 +22,9 @@
|
||||
|
||||
<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.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"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
+74
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user