diff --git a/adapter/MTConnect.NET-Applications-Adapter/Service.cs b/adapter/MTConnect.NET-Applications-Adapter/Service.cs index 4d0ba2578..8596deac8 100644 --- a/adapter/MTConnect.NET-Applications-Adapter/Service.cs +++ b/adapter/MTConnect.NET-Applications-Adapter/Service.cs @@ -3,14 +3,18 @@ using MTConnect.Services; using NLog; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif namespace MTConnect.Applications { /// /// Class used to implement a Windows Service for an MTConnect Agent Application /// +#if NET5_0_OR_GREATER [SupportedOSPlatform("windows")] +#endif public class Service : MTConnectAdapterService { private static readonly Logger _serviceLogger = LogManager.GetLogger("service-logger"); diff --git a/agent/MTConnect.NET-Applications-Agents/Service.cs b/agent/MTConnect.NET-Applications-Agents/Service.cs index 769780ce6..ce3b1925f 100644 --- a/agent/MTConnect.NET-Applications-Agents/Service.cs +++ b/agent/MTConnect.NET-Applications-Agents/Service.cs @@ -3,14 +3,18 @@ using MTConnect.Services; using NLog; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif namespace MTConnect.Applications { /// /// Class used to implement a Windows Service for an MTConnect Agent Application /// +#if NET5_0_OR_GREATER [SupportedOSPlatform("windows")] +#endif public class Service : MTConnectAgentService { private static readonly Logger _serviceLogger = LogManager.GetLogger("service-logger"); diff --git a/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs b/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs index ab1673327..049dc784a 100644 --- a/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs +++ b/agent/Modules/MTConnect.NET-AgentModule-MqttRelay/Module.cs @@ -941,11 +941,17 @@ await AsyncVoidGuard.Run( if (!conditionObservations.IsNullOrEmpty()) { var multipleObservations = new List(conditionObservations.Count()); - foreach (var observation in conditionObservations) + // Rename to avoid CS0136: the + // enclosing AgentObservationAdded + // handler takes `observation` as its + // parameter; declaring an inner + // `observation` here shadows it and + // fails to compile under net4x. + foreach (var condObservation in conditionObservations) { - multipleObservations.Add(CloneAsObservation(observation)); + multipleObservations.Add(CloneAsObservation(condObservation)); } - + var result = await _entityServer.PublishObservations(_mqttClient, multipleObservations); if (result != null && result.IsSuccess) { diff --git a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs index 66719fa21..25e821241 100644 --- a/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs +++ b/libraries/MTConnect.NET-HTTP/Ceen/Httpd/HttpServer.cs @@ -2,7 +2,9 @@ using System.Net; using System.Linq; using System.Net.Sockets; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif using System.Threading.Tasks; using System.Threading; using System.Security.Cryptography.X509Certificates; @@ -172,7 +174,9 @@ public void Setup(bool usessl, ServerConfig config) /// The socket handle. /// The remote endpoint. /// The task ID to use. +#if NET5_0_OR_GREATER [SupportedOSPlatform("windows")] +#endif public void HandleRequest(SocketInformation socket, EndPoint remoteEndPoint, string logtaskid) { RunClient(socket, remoteEndPoint, logtaskid, Controller); @@ -865,7 +869,9 @@ public static Task ListenAsync( /// The remote endpoint. /// The log task ID. /// The controller instance +#if NET5_0_OR_GREATER [SupportedOSPlatform("windows")] +#endif private static void RunClient(SocketInformation socketinfo, EndPoint remoteEndPoint, string logtaskid, RunnerControl controller) { RunClient(new Socket(socketinfo), remoteEndPoint, logtaskid, controller); diff --git a/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs b/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs index 4e7b3fb85..5459864a2 100644 --- a/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs +++ b/libraries/MTConnect.NET-Services/MTConnectAdapterService.cs @@ -5,7 +5,9 @@ using System.Diagnostics; using System.IO; using System.Reflection; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif using System.ServiceProcess; namespace MTConnect.Services @@ -13,7 +15,9 @@ namespace MTConnect.Services /// /// Class used to implement an MTConnect Adapter as a Windows Service /// +#if NET5_0_OR_GREATER [SupportedOSPlatform("windows")] +#endif public abstract class MTConnectAdapterService : ServiceBase { private const string DefaultServiceName = "MTConnect-Adapter"; diff --git a/libraries/MTConnect.NET-Services/MTConnectAgentService.cs b/libraries/MTConnect.NET-Services/MTConnectAgentService.cs index a09843650..4134627f1 100644 --- a/libraries/MTConnect.NET-Services/MTConnectAgentService.cs +++ b/libraries/MTConnect.NET-Services/MTConnectAgentService.cs @@ -5,7 +5,9 @@ using System.Diagnostics; using System.IO; using System.Reflection; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif using System.ServiceProcess; namespace MTConnect.Services @@ -13,7 +15,9 @@ namespace MTConnect.Services /// /// Class used to implement an MTConnect Agent as a Windows Service /// +#if NET5_0_OR_GREATER [SupportedOSPlatform("windows")] +#endif public abstract class MTConnectAgentService : ServiceBase { private const string DefaultServiceName = "MTConnect.NET-Agent"; diff --git a/libraries/MTConnect.NET-Services/WindowsService.cs b/libraries/MTConnect.NET-Services/WindowsService.cs index af5c441c6..cc43cf424 100644 --- a/libraries/MTConnect.NET-Services/WindowsService.cs +++ b/libraries/MTConnect.NET-Services/WindowsService.cs @@ -3,13 +3,17 @@ using System.Linq; using System.Runtime.InteropServices; +#if NET5_0_OR_GREATER using System.Runtime.Versioning; +#endif using System.Security.Principal; using System.ServiceProcess; namespace MTConnect.Services { +#if NET5_0_OR_GREATER [SupportedOSPlatform("windows")] +#endif internal static class WindowsService { public static bool ServiceExists(string serviceName) diff --git a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj index 41e35537a..29a10bef2 100644 --- a/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj +++ b/libraries/MTConnect.NET-XML/MTConnect.NET-XML.csproj @@ -17,7 +17,18 @@ MTConnect Debug;Release;Package - + + + 8.0 + MTConnect.NET-XML implements the XML Document Format for use with the MTConnect.NET library. Supports MTConnect versions up to 2.7. Supports .NET Framework 4.6.1 up to .NET 9 README-Nuget.md diff --git a/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ConditionObservationVariableScopeTests.cs b/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ConditionObservationVariableScopeTests.cs new file mode 100644 index 000000000..752e96916 --- /dev/null +++ b/tests/MTConnect.NET-AgentModule-MqttRelay-Tests/ConditionObservationVariableScopeTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using NUnit.Framework; + +namespace MTConnect.AgentModule.MqttRelay.Tests +{ + // Pins the rename PR #194 lands on Module.cs to resolve the + // CS0136 shadowed-local diagnostic that fires under net4x. The + // original code in `AgentObservationAdded` declared an inner + // `var observation` inside a `foreach` loop that lived in the + // same scope as the handler's outer `observation` parameter: + // + // private async void AgentObservationAdded(object sender, + // IObservation observation) // <- outer name + // { + // ... + // foreach (var observation in conditionObservations) // <- shadow + // { ... } + // } + // + // The C# 5+ language spec section 7.6.2.1 forbids a local + // variable declaration from shadowing an enclosing + // parameter/local of the same name. The .NET 5+ compiler emits + // a warning that TreatWarningsAsErrors escalates to an error on + // net8.0, but the project's net4x roslyn pinned compiler emits + // CS0136 directly. PR #194 renames the inner declaration to + // `condObservation` (or similar non-shadowing name) so the + // multi-TFM build path stays green. + // + // This fixture reads the Module.cs source via reflection on the + // assembly's location and scans the relevant block, asserting + // that no `foreach (var observation` declaration remains inside + // the handler. A future contributor who re-introduces the + // shadow fails this test under net8.0 — the test TFM that the + // CI matrix exercises — instead of waiting for the next Release + // pack to surface the failure under net4x. + /// Pins the rename that resolves the net4x CS0136 shadow. + [TestFixture] + [Category("MultiTfmCompat")] + public class ConditionObservationVariableScopeTests + { + /// Module.cs AgentObservationAdded must not declare an inner `observation` loop variable. + [Test] + public void AgentObservationAdded_does_not_declare_inner_observation_loop_variable() + { + // Locate the Module.cs source by walking up from the test + // assembly to the repo root, then descending into the + // module's source tree. The path is stable in-tree; + // CI runs with the same layout. + var moduleSourcePath = FindModuleSourcePath(); + Assert.That(moduleSourcePath, Is.Not.Null, + "Could not locate Module.cs for MTConnect.NET-AgentModule-MqttRelay."); + + var source = File.ReadAllText(moduleSourcePath!); + + // Extract the AgentObservationAdded handler body. The + // method ends at the matching close-brace of the + // `try { ... } finally { ... }` pair, which is the last + // brace at column 8 before the next `private` member. + var startIndex = source.IndexOf( + "private async void AgentObservationAdded", + StringComparison.Ordinal); + Assert.That(startIndex, Is.GreaterThanOrEqualTo(0), + "AgentObservationAdded handler not found in Module.cs."); + + // Find the start of the next private member after the + // handler so we scope the scan to AgentObservationAdded's + // body and don't reach into AgentAssetAdded. + var nextMemberIndex = source.IndexOf( + "private async void AgentAssetAdded", + startIndex, StringComparison.Ordinal); + Assert.That(nextMemberIndex, Is.GreaterThan(startIndex), + "Next handler AgentAssetAdded not found after AgentObservationAdded; " + + "Module.cs structure has changed."); + + var handlerBody = source.Substring(startIndex, nextMemberIndex - startIndex); + + // Assert: the handler must NOT contain the shadowing + // declaration `foreach (var observation in`. The renamed + // form uses a different identifier such as + // `condObservation`. + var shadowingPatterns = new[] + { + "foreach (var observation in", + "foreach(var observation in", + }; + foreach (var pattern in shadowingPatterns) + { + Assert.That( + handlerBody.Contains(pattern, StringComparison.Ordinal), + Is.False, + $"AgentObservationAdded contains `{pattern}` which shadows " + + "the outer `observation` parameter and fails CS0136 under net4x. " + + "Rename the inner loop variable."); + } + } + + private static string FindModuleSourcePath() + { + // The test assembly's location lives under + // tests/MTConnect.NET-AgentModule-MqttRelay-Tests/bin/... + // walk up to find the repo root marker, then descend. + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir != null) + { + var candidate = Path.Combine( + dir.FullName, + "agent", "Modules", "MTConnect.NET-AgentModule-MqttRelay", "Module.cs"); + if (File.Exists(candidate)) return candidate; + dir = dir.Parent; + } + return null; + } + } +} diff --git a/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj b/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj index e06ed7bd0..b49ea509f 100644 --- a/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj +++ b/tests/MTConnect.NET-Common-Tests/MTConnect.NET-Common-Tests.csproj @@ -17,6 +17,20 @@ + + + + + diff --git a/tests/MTConnect.NET-Common-Tests/Platform/SupportedOSPlatformAttributePresenceTests.cs b/tests/MTConnect.NET-Common-Tests/Platform/SupportedOSPlatformAttributePresenceTests.cs new file mode 100644 index 000000000..0c08593fe --- /dev/null +++ b/tests/MTConnect.NET-Common-Tests/Platform/SupportedOSPlatformAttributePresenceTests.cs @@ -0,0 +1,156 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using System; +using System.Linq; +using System.Reflection; +using System.Runtime.Versioning; +using NUnit.Framework; + +namespace MTConnect.NET_Common_Tests.Platform +{ + // Pins the [SupportedOSPlatform("windows")] decoration on every type or + // method that was annotated by commit dd2eb424 (2026-05-22) to silence + // the CA1416 platform-compatibility analyzer on .NET 8. The attribute + // type ships in System.Runtime on .NET 5.0+ but is absent from net4x + // and netstandard2.0; PR #194 wraps each declaration plus its + // using-directive in `#if NET5_0_OR_GREATER ... #endif` so the + // Release pack survives the full TFM matrix. + // + // This fixture only exercises the net8.0 build path (the only TFM + // every test project targets), and so cannot directly fail under the + // pre-fix code (net8.0 keeps the attribute regardless of the wrap). + // Its value is REGRESSION-PREVENTIVE: a future contributor who + // removes the attribute outright — or who narrows the wrap to a + // condition that excludes net8.0 — fails this test, and the + // protection the original commit added stays in place. + /// Pins SupportedOSPlatform("windows") on every site PR #194 wraps. + [TestFixture] + [Category("MultiTfmCompat")] + public class SupportedOSPlatformAttributePresenceTests + { + private static void AssertWindowsPlatform(MemberInfo member, string description) + { + var attributes = member + .GetCustomAttributes(typeof(SupportedOSPlatformAttribute), inherit: false) + .Cast() + .ToArray(); + + Assert.That(attributes, Is.Not.Empty, + $"{description}: expected [SupportedOSPlatform] attribute."); + Assert.That( + attributes.Any(a => string.Equals(a.PlatformName, "windows", StringComparison.Ordinal)), + Is.True, + $"{description}: expected PlatformName == \"windows\" but got " + + $"[{string.Join(", ", attributes.Select(a => a.PlatformName))}]."); + } + + /// MTConnect.Services.WindowsService carries the Windows-only attribute. + [Test] + public void WindowsService_carries_supported_os_platform_windows() + { + // WindowsService is internal — reach it via Assembly.GetType + // rather than typeof(...). + var servicesAssembly = typeof(MTConnect.Services.MTConnectAgentService).Assembly; + var windowsServiceType = servicesAssembly.GetType( + "MTConnect.Services.WindowsService", throwOnError: true); + AssertWindowsPlatform(windowsServiceType!, + "MTConnect.Services.WindowsService"); + } + + /// MTConnect.Services.MTConnectAgentService carries the Windows-only attribute. + [Test] + public void MTConnectAgentService_carries_supported_os_platform_windows() + { + AssertWindowsPlatform(typeof(MTConnect.Services.MTConnectAgentService), + "MTConnect.Services.MTConnectAgentService"); + } + + /// MTConnect.Services.MTConnectAdapterService carries the Windows-only attribute. + [Test] + public void MTConnectAdapterService_carries_supported_os_platform_windows() + { + AssertWindowsPlatform(typeof(MTConnect.Services.MTConnectAdapterService), + "MTConnect.Services.MTConnectAdapterService"); + } + + /// The agent-application Service class carries the Windows-only attribute. + [Test] + public void AgentApplications_Service_carries_supported_os_platform_windows() + { + // Both agent-application and adapter-application Service + // types live under the same MTConnect.Applications + // namespace; distinguish by assembly. + var agentServiceType = typeof(MTConnect.Applications.IMTConnectAgentApplication).Assembly + .GetType("MTConnect.Applications.Service", throwOnError: true); + AssertWindowsPlatform(agentServiceType!, + "MTConnect.Applications.Service (agent application)"); + } + + /// The adapter-application Service class carries the Windows-only attribute. + [Test] + public void AdapterApplications_Service_carries_supported_os_platform_windows() + { + var adapterServiceType = typeof(MTConnect.Applications.IMTConnectAdapterApplication).Assembly + .GetType("MTConnect.Applications.Service", throwOnError: true); + AssertWindowsPlatform(adapterServiceType!, + "MTConnect.Applications.Service (adapter application)"); + } + + /// The Ceen HTTP-server socket-handle sites carry the Windows-only attribute. + [Test] + public void CeenHttpServer_socket_handle_sites_carry_supported_os_platform_windows() + { + // Both annotated members are internal to MTConnect.NET-HTTP: + // * Ceen.Httpd.HttpServer.InterProcessBridge.HandleRequest + // * Ceen.Httpd.HttpServer.RunClient (private static) + // Reach them via Assembly.GetType + reflection. + var httpAssembly = typeof(MTConnect.Servers.Http.MTConnectHttpRequests).Assembly; + var httpServerType = httpAssembly.GetType( + "Ceen.Httpd.HttpServer", throwOnError: true)!; + var socketInformationType = typeof(System.Net.Sockets.SocketInformation); + + // The [SupportedOSPlatform("windows")] attribute lives on + // Ceen.Httpd.HttpServer.AppDomainBridge.HandleRequest( + // SocketInformation, EndPoint, string) — AppDomainBridge, + // not InterProcessBridge, is the AppDomain-marshallable + // sibling that wraps the duplicated socket handle. The + // RunClient(SocketInformation,...) private static method on + // HttpServer itself is the second site. + var bridgeType = httpServerType.GetNestedType( + "AppDomainBridge", BindingFlags.Public | BindingFlags.NonPublic); + Assert.That(bridgeType, Is.Not.Null, + "Ceen.Httpd.HttpServer.AppDomainBridge nested type not found."); + + const BindingFlags allInstance = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Instance; + // HandleRequest has two overloads; the SocketInformation-typed + // one is the Windows-only path the attribute decorates. The + // Socket-typed overload is cross-platform. + var handleRequestCandidates = bridgeType!.GetMethods(allInstance) + .Where(m => m.Name == "HandleRequest") + .ToArray(); + var handleRequest = handleRequestCandidates + .FirstOrDefault(m => m.GetParameters().Length > 0 + && m.GetParameters()[0].ParameterType == socketInformationType); + Assert.That(handleRequest, Is.Not.Null, + "Ceen.Httpd.HttpServer.AppDomainBridge.HandleRequest(SocketInformation,...) overload not found among: " + + string.Join("; ", + handleRequestCandidates.Select(m => $"{m.Name}({string.Join(",", m.GetParameters().Select(p => p.ParameterType.FullName))})"))); + + const BindingFlags allStatic = BindingFlags.Public | BindingFlags.NonPublic + | BindingFlags.Static; + var runClient = httpServerType.GetMethods(allStatic) + .FirstOrDefault(m => m.Name == "RunClient" + && m.GetParameters().Length > 0 + && m.GetParameters()[0].ParameterType == socketInformationType); + Assert.That(runClient, Is.Not.Null, + "Ceen.Httpd.HttpServer.RunClient(SocketInformation,...) overload not found."); + + AssertWindowsPlatform(handleRequest!, + "Ceen.Httpd.HttpServer.AppDomainBridge.HandleRequest"); + AssertWindowsPlatform(runClient!, + "Ceen.Httpd.HttpServer.RunClient(SocketInformation,...)"); + } + } +} diff --git a/tests/MTConnect.NET-XML-Tests/Xml/UsingDeclarationsTests.cs b/tests/MTConnect.NET-XML-Tests/Xml/UsingDeclarationsTests.cs new file mode 100644 index 000000000..c028cfa08 --- /dev/null +++ b/tests/MTConnect.NET-XML-Tests/Xml/UsingDeclarationsTests.cs @@ -0,0 +1,82 @@ +// Copyright (c) 2026 TrakHound Inc., All Rights Reserved. +// TrakHound Inc. licenses this file to you under the MIT license. + +using NUnit.Framework; + +namespace MTConnect.Tests.XML.Xml +{ + // Pins the C# 8.0 `using` declaration code path in + // MTConnect.NET-XML. The production site lives in + // XsdPreprocessor.StripXsd11Constructs: + // + // using var reader = new StringReader(xsdSourceXml); + // + // C# 8.0 added the using-declaration form; on netstandard2.0 the + // compiler's default LangVersion is 7.3, which rejects that syntax + // with CS8370. PR #194 pins the project's LangVersion to 8.0 so the + // Release pack survives the full TFM matrix; this fixture exercises + // the path so a regression that breaks the preprocessor's load step + // surfaces as a test failure rather than only as a build break. + // + // The fixture compiles and runs under net8.0 — the only TFM every + // test project targets — so the test passes regardless of + // LangVersion. Its value is to ensure the production code path + // stays exercised, matching the brief's TDD shape for paths whose + // pre-fix surface is already correct on the test TFM. + /// Pins the C# 8.0 using-declaration path in XsdPreprocessor. + [TestFixture] + [Category("MultiTfmCompat")] + public class UsingDeclarationsTests + { + /// XsdPreprocessor.StripXsd11Constructs accepts a minimal well-formed XSD. + [Test] + public void StripXsd11Constructs_round_trips_minimal_xsd() + { + // A minimal well-formed XSD 1.0 schema with no 1.1-only + // constructs round-trips through StripXsd11Constructs + // unchanged structurally. The path exercised here is the + // using-declaration body that disposes the StringReader. + const string minimalXsd = + "\n" + + "\n" + + " \n" + + ""; + + var result = XsdPreprocessor.StripXsd11Constructs(minimalXsd); + + Assert.That(result, Is.Not.Null, + "StripXsd11Constructs must return a non-null result for valid input."); + Assert.That(result, Does.Contain("Root"), + "The minimal schema's element name must round-trip through the preprocessor."); + } + + /// XsdPreprocessor.StripXsd11Constructs strips the 1.1-only xs:assert element. + [Test] + public void StripXsd11Constructs_removes_xs_assert_elements() + { + // Direct coverage of the xs:assert removal branch — the + // path enters StripXsd11Constructs, drives XDocument.Load + // through the using-declared StringReader, and exits with + // the xs:assert element removed. + const string xsdWithAssert = + "\n" + + "\n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + " \n" + + ""; + + var result = XsdPreprocessor.StripXsd11Constructs(xsdWithAssert); + + Assert.That(result, Does.Not.Contain("xs:assert"), + "xs:assert must be stripped by the preprocessor."); + Assert.That(result, Does.Contain("WithAssert"), + "The enclosing complexType must survive the assertion stripping."); + } + } +}