refactor(sql): split ISqlDialect/SqlServerDialect into a .Dialect leaf (#526)

`Driver.Sql.Browser` referenced the runtime `Driver.Sql` project. The AdminUI
references that browser, so the whole SQL driver runtime — and
`ZB.MOM.WW.OtOpcUa.Core` through it — sat in the AdminUI's build/deploy graph.
This is the same layering violation #512 removed from the MQTT browser, in a
second driver, and it is why #512's expected removal of `Core.dll` from the
AdminUI's output did not happen.

MEASURED, both directions, on a clean AdminUI build:

  before: ZB.MOM.WW.OtOpcUa.Core.dll + ZB.MOM.WW.OtOpcUa.Driver.Sql.dll present
  after:  both absent

The old csproj comment's premise was sound and survives — the dialect's catalog
SQL *is* the browse engine, so it must be shared rather than duplicated. What
was wrong was the conclusion that sharing it required referencing the runtime
driver. `ISqlDialect`/`SqlServerDialect` depend on nothing in `Driver.Sql`, so
they move to a `.Dialect` leaf that both consume.

This is the `.Transport` shape, not the cheap one: `ISqlDialect` exposes
`DbProviderFactory` and `SqlServerDialect` returns `SqlClientFactory.Instance`,
so `.Contracts` (deliberately provider-free) cannot hold them. Assembly name is
`.Dialect`; the NAMESPACE stays `ZB.MOM.WW.OtOpcUa.Driver.Sql`, matching the
`Driver.Mqtt.Transport` precedent — the extraction cost zero `using` changes.

Two dependencies the cut edge surfaced as the implicit ones they were:

- The AdminUI itself read `SqlDriver.DriverTypeName` in three places — the tag
  editor map, the tag config validator, and the Sql address picker. All three
  now read `DriverTypeNames.Sql`, which is exactly what that constant resolves
  to and exactly the repoint `SqlDriverBrowser`'s own comment had deferred
  until a factory was registered against the shared constant. One exists now.
- `SqlDriverFormContractTests` asserts the AdminUI form's emitted JSON against
  the runtime factory that reads it, so it genuinely needs the runtime driver.
  `AdminUI.Tests` now references it explicitly. A test project referencing a
  runtime driver is fine — tests are not in the AdminUI's deploy graph.

New guard: `DriverBrowserLayeringTests` (Commons.Tests) scans the csproj graph
of every `*.Browser` project in the tree, transitively. It reads csproj files
rather than loaded assemblies because the cost is a build-graph cost — the
compiler emits no assembly reference for an unused `ProjectReference`, so
`MqttBrowserLayeringTests`' assembly-reference approach cannot see that case.
The two are complementary and each now points at the other.

Falsified three ways, each red then green again: the direct reference re-added
to the browser; the same reference one hop out via `.Dialect` (proving the walk
is really transitive); and both controls — browsers-discovered and
references-parsed — which exist so an empty discovery cannot pass the absence
assertion vacuously.

#526's claim that the reference "carries no comment at all" was wrong; it
carried a detailed one from the scaffold commit. Corrected on the issue and in
the design doc's project table, which had recorded the deviation as deliberate.

Claude-Session: https://claude.ai/code/session_015p7wGqy3YpZNCpDzTpGMKo
This commit is contained in:
Joseph Doherty
2026-07-31 08:38:57 -04:00
parent 857a10b4f7
commit 803f4e6efe
15 changed files with 249 additions and 27 deletions
+1
View File
@@ -32,6 +32,7 @@
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts/ZB.MOM.WW.OtOpcUa.Driver.MTConnect.Contracts.csproj" />
<Project Path="src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/ZB.MOM.WW.OtOpcUa.Driver.S7.csproj" />
@@ -62,7 +62,8 @@ All `net10.0`, `Nullable`/`ImplicitUsings` enabled, `TreatWarningsAsErrors`, mat
|---|---|
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts/` | Options DTOs, `SqlProvider` enum, tag-model records, `SqlEquipmentTagParser`. **Zero transport deps** (refs only `Core.Abstractions`) so AdminUI + browser can reference it without dragging `Microsoft.Data.SqlClient`. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql/` | `SqlDriver` (runtime capabilities), `ISqlDialect` + `SqlServerDialect`, `SqlPollReader`, factory extensions. **Owns the `Microsoft.Data.SqlClient` package ref.** |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Driver.Sql`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). This runtime-project ref is a deliberate deviation from `OpcUaClient.Browser` (which refs only its Contracts and owns the OPC UA client packages itself): the browser still opens its own transient connection. The resulting **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. Recorded here deliberately so nobody "fixes" the deviation later without this context. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser/` | `SqlDriverBrowser : IDriverBrowser`, `SqlBrowseSession : IBrowseSession` (schema walk). Refs `Commons` + `Sql.Contracts` + **`Sql.Dialect`** (for `ISqlDialect`/`SqlServerDialect` — the dialect's catalog SQL *is* the browse engine, so it must be shared, not duplicated; `Microsoft.Data.SqlClient` comes transitively). The browser opens its own transient connection. The **transitive `Microsoft.Data.SqlClient` reference on AdminUI is reviewed and accepted** — AdminUI already uses SqlClient for ConfigDb, so this adds no new package to its closure. |
| `src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect/` | `ISqlDialect` + `SqlServerDialect`, assembly-split out of `Driver.Sql` (namespace unchanged). **Added by Gitea #526**, which corrected the row above: it used to say the browser referenced the runtime **`Driver.Sql`**, called that "a deliberate deviation from `OpcUaClient.Browser`", and asked that nobody "fix" it without reading this context. The *sharing* premise was right and survives; the conclusion that sharing required the runtime reference was not. The AdminUI references the browser, so that edge put the whole SQL driver runtime — and `ZB.MOM.WW.OtOpcUa.Core` through it — into the AdminUI's build/deploy graph, invisibly (measured before and after: both DLLs present, then absent). The dialect depends on nothing in `Driver.Sql`, so it moved to this leaf, referenced by the runtime driver and the browser alike. It cannot live in `.Contracts`: `ISqlDialect` exposes `DbProviderFactory` and `SqlServerDialect` returns `SqlClientFactory.Instance`, and `.Contracts` is deliberately provider-free — the same shape as `Driver.Mqtt.Transport`. Guarded by `DriverBrowserLayeringTests`, which scans every `*.Browser` csproj in the tree. |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests/` | Unit suite (SQLite fixture, §9). |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests/` | Env-gated integration suite (central SQL Server, §9). |
| `tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests/` | Browser unit tests (SQLite `PRAGMA` dialect). |
@@ -4,6 +4,7 @@ using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;
namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser;
@@ -100,12 +101,14 @@ public sealed class SqlDriverBrowser : IDriverBrowser
/// <inheritdoc />
/// <remarks>
/// Sourced from <see cref="SqlDriver.DriverTypeName"/>, the driver's interim local constant, rather
/// than from <c>DriverTypeNames</c> — the shared constant set is added by the driver-factory task,
/// because adding a member there reddens <c>DriverTypeNamesGuardTests</c> until a factory is
/// registered against it. Repointing that one constant repoints this too.
/// Sourced from the shared <see cref="DriverTypeNames"/> set, which is what the driver's own
/// <c>SqlDriver.DriverTypeName</c> resolves to. This used to read that interim local constant, and
/// the comment here said so; repointing it was deferred until a factory was registered against
/// <see cref="DriverTypeNames.Sql"/> (else <c>DriverTypeNamesGuardTests</c> goes red). One exists
/// now, and reading the shared constant is also what lets this browser stay off the runtime driver
/// — <c>SqlDriver</c> is the only type it still needed from there (Gitea #526).
/// </remarks>
public string DriverType => SqlDriver.DriverTypeName;
public string DriverType => DriverTypeNames.Sql;
/// <summary>The environment variable a <c>connectionStringRef</c> resolves through.</summary>
/// <param name="connectionStringRef">The authored reference name.</param>
@@ -11,15 +11,34 @@
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
<!-- Deliberate deviation from the OpcUaClient.Browser house style (which refs only its
Contracts project and owns its own transport packages): the dialect's catalog SQL
(ISqlDialect / SqlServerDialect, in Driver.Sql) *is* the browse engine, so it must be
shared here rather than duplicated. Microsoft.Data.SqlClient comes in transitively.
The resulting transitive SqlClient reference on AdminUI is reviewed and accepted —
AdminUI already carries SqlClient for ConfigDb. See design doc §2, table row for this
project: docs/plans/2026-07-15-sql-poll-driver-design.md. Do not "fix" this to a
Contracts-only reference without reading that context first. -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj" />
<!--
The layering exception this project used to carry is GONE (Gitea #526). It referenced the
runtime Driver.Sql project, and the AdminUI references this browser: the AdminUI's
build/deploy graph therefore pulled in the whole SQL driver runtime and, through it,
ZB.MOM.WW.OtOpcUa.Core.
The premise of the old comment was sound and survives — the dialect's catalog SQL
(ISqlDialect / SqlServerDialect) *is* the browse engine, so it must be SHARED here rather
than duplicated. What was wrong was the conclusion that sharing it required a reference to
the runtime driver. Those two types depend on nothing from Driver.Sql, so they now live in
the .Dialect leaf below, referenced by the runtime driver and by this browser alike.
Note this is the .Transport shape, not the cheap one: ISqlDialect exposes DbProviderFactory
and SqlServerDialect returns SqlClientFactory.Instance, so the dialect cannot move into
.Contracts (deliberately provider-free) — it needs a leaf of its own.
MEASURED, not assumed: after this change ZB.MOM.WW.OtOpcUa.Driver.Sql.dll and
ZB.MOM.WW.OtOpcUa.Core.dll are both absent from the AdminUI's output, and both were present
immediately before it. #512 removed the matching MQTT reference and expected Core to
disappear with it; it did not, because this project was the remaining cause.
The transitive Microsoft.Data.SqlClient on AdminUI is unchanged and remains accepted —
AdminUI already carries SqlClient for ConfigDb. Design doc context, still worth reading
before changing the sharing decision: docs/plans/2026-07-15-sql-poll-driver-design.md §2.
Guarded by DriverBrowserLayeringTests, which scans every *.Browser csproj in the tree.
-->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.Tests"/>
@@ -15,7 +15,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Sql;
/// <see cref="QuoteIdentifier"/>. Any code path that builds SQL by concatenating an authored tag field
/// <em>without</em> going through <see cref="QuoteIdentifier"/> is a defect (design §8.1).</para>
/// <para><b>Quoting is the backstop, not the only defence.</b> Design §8.1's catalog gate is
/// implemented: <see cref="SqlCatalogGate"/> resolves every authored table/column against the live
/// implemented: <c>SqlCatalogGate</c> resolves every authored table/column against the live
/// catalog at Initialize and <b>replaces it with the catalog's own spelling</b>, so an identifier
/// reaching <see cref="QuoteIdentifier"/> on the poll path is a string this driver read back out of
/// <see cref="ListSchemasSql"/> / <see cref="ListTablesSql"/> / <see cref="ListColumnsSql"/> — not
@@ -96,7 +96,7 @@ public interface ISqlDialect
/// principal — T-SQL's <c>SELECT SCHEMA_NAME()</c>. Takes no parameters.
/// </summary>
/// <remarks>
/// <para>Needed by <see cref="SqlCatalogGate"/>: a tag authored as <c>TagValues</c> rather than
/// <para>Needed by <c>SqlCatalogGate</c>: a tag authored as <c>TagValues</c> rather than
/// <c>dbo.TagValues</c> has to be looked up in <em>some</em> schema, and guessing <c>dbo</c> would be a
/// silent lie on any estate that maps its service accounts to their own default schema. Asking the
/// server is the only answer that matches how the poll query itself will resolve the name.</para>
@@ -77,7 +77,7 @@ public sealed class SqlServerDialect : ISqlDialect
/// <para>The rejection messages deliberately do <b>not</b> echo the offending value — it is untrusted
/// input and this exception's text reaches the driver log.</para>
/// <para><b>This is a backstop, not the only defence.</b> Design §8.1's catalog gate
/// (<see cref="SqlCatalogGate"/>) resolves an authored table/column against the live catalog at
/// (<c>SqlCatalogGate</c>) resolves an authored table/column against the live catalog at
/// Initialize and substitutes the catalog's own spelling, so on the poll path the value arriving here
/// is a string read back out of the catalog. The rules below still run — the gate uses them as its own
/// charset filter, and they must hold for any future caller that has no catalog to check against.</para>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<!-- Assembly is .Dialect; the NAMESPACE stays ZB.MOM.WW.OtOpcUa.Driver.Sql, matching the
Driver.Mqtt.Transport precedent. The split is a build-graph concern, not an API one, so
extracting these two types cost zero `using` changes anywhere in the tree. -->
<RootNamespace>ZB.MOM.WW.OtOpcUa.Driver.Sql</RootNamespace>
<AssemblyName>ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect</AssemblyName>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj" />
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj" />
</ItemGroup>
<ItemGroup>
<!-- The reason this project exists rather than the dialect living in .Contracts: ISqlDialect
exposes DbProviderFactory and SqlServerDialect returns SqlClientFactory.Instance, so the
provider package is unavoidable here. .Contracts is deliberately provider-free. This is
the same shape as Driver.Mqtt.Transport, whose BuildClientOptions returns an MQTTnet type. -->
<PackageReference Include="Microsoft.Data.SqlClient" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests"/>
</ItemGroup>
</Project>
@@ -13,6 +13,9 @@
<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="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts\ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts.csproj"/>
<!-- ISqlDialect / SqlServerDialect moved to the .Dialect leaf so Driver.Sql.Browser can share
the catalog SQL without referencing this runtime project (Gitea #526). -->
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect\ZB.MOM.WW.OtOpcUa.Driver.Sql.Dialect.csproj"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" />
@@ -269,7 +269,7 @@ else
// never written to the composed TagConfig blob nor back to the driver config — this is the
// sole place it is read, and it dies with the local variable.
var json = BuildBrowseConfig();
var result = await BrowserService.OpenAsync(SqlDriver.DriverTypeName, json, default);
var result = await BrowserService.OpenAsync(DriverTypeNames.Sql, json, default);
if (result.Ok) { _token = result.Token; }
else { _openError = result.Message; }
}
@@ -22,10 +22,7 @@ public static class TagConfigEditorMap
[DriverTypeNames.FOCAS] = typeof(Components.Shared.Uns.TagEditors.FocasTagConfigEditor),
[DriverTypeNames.OpcUaClient] = typeof(Components.Shared.Uns.TagEditors.OpcUaClientTagConfigEditor),
[DriverTypeNames.Calculation] = typeof(Components.Shared.Uns.TagEditors.CalculationTagConfigEditor),
// Keyed off SqlDriver.DriverTypeName, which is value-identical to DriverTypeNames.Sql. The
// note that used to sit here — "that shared constant is deliberately absent until Task 11 wires
// the factory" — went stale when the constant landed; the key is correct either way.
[SqlDriver.DriverTypeName] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Sql] = typeof(Components.Shared.Uns.TagEditors.SqlTagConfigEditor),
[DriverTypeNames.Mqtt] = typeof(Components.Shared.Uns.TagEditors.MqttTagConfigEditor),
[DriverTypeNames.MTConnect] = typeof(Components.Shared.Uns.TagEditors.MTConnectTagConfigEditor),
};
@@ -24,8 +24,7 @@ public static class TagConfigValidator
[DriverTypeNames.FOCAS] = j => FocasTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.OpcUaClient] = j => OpcUaClientTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Calculation] = j => CalculationTagConfigModel.FromJson(j).Validate(),
// Keyed off SqlDriver.DriverTypeName (= "Sql") — see the note in TagConfigEditorMap.
[SqlDriver.DriverTypeName] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Sql] = j => SqlTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.Mqtt] = j => MqttTagConfigModel.FromJson(j).Validate(),
[DriverTypeNames.MTConnect] = j => MTConnectTagConfigModel.FromJson(j).Validate(),
};
@@ -41,8 +41,10 @@
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser\ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser.csproj"/>
<!-- Sql schema-browse (IDriverBrowser, SqlBrowseNodeId codec, SqlEquipmentTagParser). Pulls
Driver.Sql + .Contracts + Microsoft.Data.SqlClient transitively — reviewed/accepted, see
the Browser project's own csproj comment (AdminUI already carries SqlClient for ConfigDb). -->
.Contracts + .Dialect + Microsoft.Data.SqlClient transitively — SqlClient is
reviewed/accepted (AdminUI already carries it for ConfigDb). It no longer pulls the
runtime Driver.Sql, nor Core through it: see Gitea #526 and the Browser project's own
csproj comment. -->
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser\ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser.csproj"/>
<ProjectReference Include="..\..\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser\ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser.csproj"/>
</ItemGroup>
@@ -0,0 +1,152 @@
using System.Text.RegularExpressions;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
/// <summary>
/// Pins the layering rule for <b>every</b> bespoke driver browser in the tree: a <c>*.Browser</c>
/// project must not reach its runtime <c>*.Driver</c> project, by any path.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why this exists rather than a per-driver test.</b> Gitea #512 removed this violation from
/// the MQTT browser and pinned it with <c>MqttBrowserLayeringTests</c>. The identical violation was
/// sitting in <c>Driver.Sql.Browser</c> the whole time and no guard saw it, because the guard was
/// written for the driver that happened to have an issue filed (#526). This test enumerates the
/// browsers off disk, so a browser added later is covered on the day it is added.
/// </para>
/// <para>
/// <b>Why it reads csproj files rather than loaded assemblies.</b> The cost being prevented is a
/// build/deploy-graph cost: the AdminUI references these browsers, so a runtime driver reachable
/// from one lands in the AdminUI's output whether or not a single type is used. The compiler emits
/// no assembly reference for an unused <c>ProjectReference</c>, so an assembly-reference test —
/// which is what <c>MqttBrowserLayeringTests</c> does, and says so — cannot see that case. The two
/// are complementary: this one catches the reference, that one catches the usage.
/// </para>
/// <para>
/// <b>The walk is transitive.</b> Asserting only on direct references would pass a browser that
/// reached its runtime driver through a shared leaf, which is the same defect one hop further out
/// — the hazard <c>MqttBrowserLayeringTests</c> guards for <c>.Transport</c> by hand.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class DriverBrowserLayeringTests
{
/// <summary>
/// Browsers known to exist when this guard was written. Asserted as a MINIMUM, not an exact set —
/// a new browser must be covered automatically, so this list only defends against the discovery
/// itself silently finding nothing (a wrong repo root, a moved directory), which would make every
/// assertion below vacuously true.
/// </summary>
private static readonly string[] KnownBrowsers =
[
"ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser",
"ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Browser",
"ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser",
"ZB.MOM.WW.OtOpcUa.Driver.Sql.Browser",
];
[Fact]
public void Every_bespoke_browser_is_discovered()
{
// The positive control for the whole file. Every other test here is an ABSENCE assertion over a
// discovered set, so an empty set passes them all.
var discovered = DiscoverBrowsers().Select(p => Path.GetFileNameWithoutExtension(p)!).ToArray();
foreach (var known in KnownBrowsers)
discovered.ShouldContain(known);
}
[Fact]
public void Every_browser_declares_at_least_one_project_reference()
// The second half of the control: proves the csproj parse actually returns something, so a
// regex that silently stopped matching cannot turn the rule below into a no-op.
=> DiscoverBrowsers().ShouldAllBe(p => DirectReferences(p).Count > 0);
[Fact]
public void No_browser_reaches_its_runtime_driver_project()
{
var offenders = new List<string>();
foreach (var browser in DiscoverBrowsers())
{
var browserName = Path.GetFileNameWithoutExtension(browser)!;
// "ZB…Driver.Sql.Browser" -> "ZB…Driver.Sql": the sibling runtime project.
var runtime = browserName[..^".Browser".Length];
var reached = TransitiveReferences(browser);
if (reached.Contains(runtime))
offenders.Add($"{browserName} -> {runtime}");
}
offenders.ShouldBeEmpty(
"a driver browser reaches its runtime driver project. The AdminUI references these browsers, "
+ "so the whole driver runtime lands in its build/deploy graph — the cost is invisible, which "
+ "is exactly why it needs a guard. If shared code is needed, put transport-free protocol or "
+ "contract types in .Contracts, and anything that must name a provider/vendor type in a leaf "
+ "project of its own (see Driver.Mqtt.Transport and Driver.Sql.Dialect). Do NOT resolve this "
+ "by copying the shared code into the browser: for anything touching credentials, TLS or "
+ "identifier quoting, a second copy is a silent security divergence rather than a build break.");
}
// ---- csproj graph ------------------------------------------------------------------------------
private static readonly Regex ProjectReferencePattern = new(
"<ProjectReference\\s+Include\\s*=\\s*\"([^\"]+)\"",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
private static IReadOnlyList<string> DiscoverBrowsers() =>
[.. Directory
.EnumerateDirectories(Path.Combine(RepoRoot.Value, "src", "Drivers"), "*.Browser")
.Select(d => Path.Combine(d, Path.GetFileName(d) + ".csproj"))
.Where(File.Exists)
.OrderBy(p => p, StringComparer.Ordinal)];
/// <summary>Absolute paths of the csproj files one project references directly.</summary>
private static IReadOnlyList<string> DirectReferences(string csprojPath)
{
var dir = Path.GetDirectoryName(csprojPath)!;
return
[
.. ProjectReferencePattern
.Matches(File.ReadAllText(csprojPath))
.Select(m => Path.GetFullPath(
Path.Combine(dir, m.Groups[1].Value.Replace('\\', Path.DirectorySeparatorChar))))
];
}
/// <summary>Assembly-ish names of every project reachable from <paramref name="csprojPath"/>.</summary>
private static HashSet<string> TransitiveReferences(string csprojPath)
{
var reached = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var pending = new Stack<string>(DirectReferences(csprojPath));
while (pending.Count > 0)
{
var next = pending.Pop();
if (!reached.Add(Path.GetFileNameWithoutExtension(next)!) || !File.Exists(next)) continue;
foreach (var onward in DirectReferences(next)) pending.Push(onward);
}
return reached;
}
/// <summary>
/// Walks up from the test binary until the solution file appears. The tests run out of
/// <c>bin/Debug/net10.0</c>, so no fixed relative depth is reliable across host layouts.
/// </summary>
private static readonly Lazy<string> RepoRoot = new(() =>
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx")))
dir = dir.Parent;
dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)");
return dir.FullName;
});
}
@@ -22,6 +22,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Mqtt.Tests;
/// <c>ProjectReference</c> re-added but never used would not trip it (the compiler emits no
/// reference), so the csproj comment remains the primary record of the decision.
/// </para>
/// <para>
/// <b>That gap is now covered</b> by <c>DriverBrowserLayeringTests</c> (Commons.Tests), which
/// scans the csproj graph of every <c>*.Browser</c> project in the tree — added for Gitea #526,
/// after the identical violation was found sitting in <c>Driver.Sql.Browser</c> with no guard
/// watching it, because this file was written for the one driver that had an issue filed. What
/// stays here is the MQTT-specific half: the <c>.Transport</c> leaf shape and the
/// MQTTnet-free-ness of <c>.Contracts</c>.
/// </para>
/// </remarks>
[Trait("Category", "Unit")]
public sealed class MqttBrowserLayeringTests
@@ -25,6 +25,13 @@
<ItemGroup>
<ProjectReference Include="..\..\..\src\Server\ZB.MOM.WW.OtOpcUa.AdminUI\ZB.MOM.WW.OtOpcUa.AdminUI.csproj"/>
<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Core\ZB.MOM.WW.OtOpcUa.Core.csproj"/>
<!-- SqlDriverFormContractTests asserts the AdminUI form's emitted JSON against the runtime
factory that reads it, so it genuinely needs the runtime driver. It used to arrive here
transitively through AdminUI -> Driver.Sql.Browser -> Driver.Sql; Gitea #526 cut that
edge, which is what surfaced this dependency as the implicit one it always was. A TEST
project referencing a runtime driver is fine — tests are not in the AdminUI's deploy
graph, which is the whole thing #526 was about. -->
<ProjectReference Include="..\..\..\src\Drivers\ZB.MOM.WW.OtOpcUa.Driver.Sql\ZB.MOM.WW.OtOpcUa.Driver.Sql.csproj"/>
</ItemGroup>
</Project>