diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/PassthroughScript.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/PassthroughScript.cs index c89a1fa7..0f517218 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/PassthroughScript.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/PassthroughScript.cs @@ -7,10 +7,17 @@ using System.Text.RegularExpressions; /// which can be evaluated by returning the dependency value directly — no Roslyn compilation. /// Narrow, exact pattern: any near-miss returns false and falls through to the Roslyn path. /// +/// +/// Physically defined in the Core.Scripting.Abstractions assembly (Roslyn-free, so ControlPlane +/// can reference it); the namespace is Core.Scripting to keep consumer using-directives unchanged. +/// public static partial class PassthroughScript { // ^ \s* return \s+ ctx . GetTag ( "X" ) . Value ; \s* $ (whitespace-tolerant around tokens) - [GeneratedRegex(@"^\s*return\s+ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)\s*\.\s*Value\s*;\s*$")] + // Tag-name class [^"\\] excludes both the closing quote and backslash: a literal containing a + // backslash escape (e.g. "a\\b" → runtime name a\b) won't match, so it correctly falls through + // to Roslyn, which interprets the escape and resolves the actual dependency key. + [GeneratedRegex(@"^\s*return\s+ctx\s*\.\s*GetTag\s*\(\s*""([^""\\]+)""\s*\)\s*\.\s*Value\s*;\s*$")] private static partial Regex MirrorRegex(); /// True if is the mirror passthrough shape; outputs the referenced tag. diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/PassthroughScriptTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/PassthroughScriptTests.cs index 466788bf..cb92843b 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/PassthroughScriptTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/PassthroughScriptTests.cs @@ -76,4 +76,26 @@ public sealed class PassthroughScriptTests PassthroughScript.TryMatch(source, out var tag).ShouldBeFalse(); tag.ShouldBe(string.Empty); } + + /// + /// A tag literal containing a backslash escape (C# source "a\\b" → runtime name + /// a\b) does NOT match the passthrough pattern — it falls through to Roslyn, which + /// interprets the escape and resolves the correct dependency key. Capturing the raw source + /// text a\\b would produce a wrong-result silent miss against the key a\b. + /// + [Fact] + public void Rejects_tag_literal_containing_backslash_escape() + { + // C# literal "return ctx.GetTag(\"a\\\\b\").Value;" → script source contains: a\\b (two chars: backslash + b) + PassthroughScript.TryMatch("return ctx.GetTag(\"a\\\\b\").Value;", out var tag).ShouldBeFalse(); + tag.ShouldBe(string.Empty); + } + + /// A plain dotted tag name (no backslash) still matches — the fix is additive only. + [Fact] + public void Matches_plain_dotted_tag_after_backslash_fix() + { + PassthroughScript.TryMatch("return ctx.GetTag(\"Site1.Area.Tag\").Value;", out var tag).ShouldBeTrue(); + tag.ShouldBe("Site1.Area.Tag"); + } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs index 520fa4ba..085cd771 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/RoslynVirtualTagEvaluatorTests.cs @@ -200,6 +200,38 @@ public sealed class RoslynVirtualTagEvaluatorTests result.Reason.ShouldBeNull(); } + /// Direct parity test: the EXACT mirror source return ctx.GetTag("missing").Value; + /// evaluated against an empty dependency dictionary takes the fast-path and yields + /// Ok(null); a minimally-altered but semantically-identical non-passthrough variant + /// return (object?)ctx.GetTag("missing").Value; compiled by Roslyn against the same + /// empty deps yields the identical Ok(null) — proving the fast-path is byte-identical + /// to the Roslyn path for the missing-dependency case. + [Fact] + public void Passthrough_exact_mirror_missing_dep_matches_Roslyn_baseline() + { + using var sut = new RoslynVirtualTagEvaluator(NullLogger.Instance); + + // Fast-path: exact mirror shape → passthrough returns Ok(null) for missing dep. + var fastPath = sut.Evaluate( + "vt-parity-fast", + "return ctx.GetTag(\"missing\").Value;", + new Dictionary()); + + fastPath.Success.ShouldBeTrue(fastPath.Reason); + fastPath.Value.ShouldBeNull(); + fastPath.Reason.ShouldBeNull(); + + // Roslyn path: `(object?)` cast forces compilation but reads the same missing tag. + var roslynPath = sut.Evaluate( + "vt-parity-roslyn", + "return (object?)ctx.GetTag(\"missing\").Value;", + new Dictionary()); + + roslynPath.Success.ShouldBeTrue(roslynPath.Reason); + roslynPath.Value.ShouldBeNull(); + roslynPath.Reason.ShouldBeNull(); + } + /// Decisive proof the fast-path skips Roslyn: the compiled-script cache (which every /// Roslyn evaluation populates via GetOrAdd) stays EMPTY after a mirror evaluation, then /// grows to one entry once a genuine (non-mirror) expression forces compilation.