diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs
new file mode 100644
index 00000000..24ed133b
--- /dev/null
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs
@@ -0,0 +1,95 @@
+using System.Text.RegularExpressions;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+///
+/// Helpers for equipment-relative virtual-tag script paths. The reserved token
+/// {{equip}} inside a ctx.GetTag/ctx.SetVirtualTag path literal is
+/// replaced at the compose seams with the owning equipment's tag base prefix (derived
+/// from its child-tag FullNames). Pure + regex-based (no Roslyn) so the OpcUaServer
+/// composer and the Runtime artifact-decode path can both share it. Also the single home
+/// for the ctx.GetTag("…") dependency-ref extraction those two seams used to
+/// duplicate.
+///
+public static class EquipmentScriptPaths
+{
+ /// The reserved equipment-base token.
+ public const string EquipToken = "{{equip}}";
+
+ // ctx.GetTag("ref") — reads only; the dependency graph subscribes to exactly these.
+ private static readonly Regex GetTagRefRegex =
+ new(@"ctx\s*\.\s*GetTag\s*\(\s*""([^""]+)""\s*\)", RegexOptions.Compiled);
+
+ // ctx.GetTag("…") OR ctx.SetVirtualTag("…", …) — first string-literal arg captured in
+ // three parts (prefix, content, closing quote) so token substitution touches ONLY the
+ // literal content (never a comment, Logger string, or other code).
+ private static readonly Regex PathLiteralRegex =
+ new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled);
+
+ /// True when the source uses the {{equip}} token anywhere.
+ /// The script source to scan.
+ public static bool ContainsEquipToken(string? source) =>
+ !string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);
+
+ ///
+ /// Equipment tag base = the single shared substring-before-first-dot across the
+ /// equipment's child-tag FullNames. Returns null when there are no usable
+ /// FullNames or they don't agree on one prefix (equipment spanning multiple objects).
+ ///
+ /// The equipment's child-tag driver FullNames.
+ /// The shared base prefix, or null when none/ambiguous.
+ public static string? DeriveEquipmentBase(IEnumerable childFullNames)
+ {
+ string? found = null;
+ foreach (var fn in childFullNames)
+ {
+ if (string.IsNullOrWhiteSpace(fn)) continue;
+ var dot = fn.IndexOf('.');
+ var prefix = dot < 0 ? fn : fn.Substring(0, dot);
+ if (prefix.Length == 0) continue;
+ if (found is null) found = prefix;
+ else if (!string.Equals(found, prefix, StringComparison.Ordinal)) return null;
+ }
+ return found;
+ }
+
+ ///
+ /// Replace {{equip}} with inside
+ /// ctx.GetTag/ctx.SetVirtualTag path literals only. Identity when
+ /// is null/empty or the token is absent (so every existing
+ /// script — none of which use the token — is byte-unchanged).
+ ///
+ /// The script source.
+ /// The equipment base prefix, or null/empty for no substitution.
+ /// The source with the token substituted inside path literals.
+ public static string SubstituteEquipmentToken(string source, string? equipBase)
+ {
+ if (string.IsNullOrEmpty(source) || string.IsNullOrEmpty(equipBase)) return source;
+ if (!source.Contains(EquipToken, StringComparison.Ordinal)) return source;
+ return PathLiteralRegex.Replace(source, m =>
+ m.Groups[1].Value
+ + m.Groups[2].Value.Replace(EquipToken, equipBase, StringComparison.Ordinal)
+ + m.Groups[3].Value);
+ }
+
+ ///
+ /// Distinct ctx.GetTag("ref") string literals in first-seen order — the
+ /// dependency refs the VirtualTagActor subscribes to. The single shared copy
+ /// formerly duplicated in Phase7Composer + DeploymentArtifact. GetTag
+ /// only (writes are not dependencies).
+ ///
+ /// The (already substituted) script source.
+ /// Distinct read refs, first-seen order.
+ public static IReadOnlyList ExtractDependencyRefs(string? scriptSource)
+ {
+ if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty();
+ var seen = new HashSet(StringComparer.Ordinal);
+ var result = new List();
+ foreach (Match m in GetTagRefRegex.Matches(scriptSource))
+ {
+ var r = m.Groups[1].Value;
+ if (seen.Add(r)) result.Add(r);
+ }
+ return result;
+ }
+}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs
new file mode 100644
index 00000000..552c3f98
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs
@@ -0,0 +1,187 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;
+
+public class EquipmentScriptPathsTests
+{
+ // ---- DeriveEquipmentBase ----
+
+ [Fact]
+ public void DeriveEquipmentBase_returns_shared_prefix()
+ {
+ EquipmentScriptPaths
+ .DeriveEquipmentBase(["TestMachine_001.A", "TestMachine_001.B"])
+ .ShouldBe("TestMachine_001");
+ }
+
+ [Fact]
+ public void DeriveEquipmentBase_returns_null_when_prefixes_diverge()
+ {
+ EquipmentScriptPaths
+ .DeriveEquipmentBase(["TestMachine_001.A", "DelmiaReceiver_001.B"])
+ .ShouldBeNull();
+ }
+
+ [Fact]
+ public void DeriveEquipmentBase_returns_null_for_empty_sequence()
+ {
+ EquipmentScriptPaths
+ .DeriveEquipmentBase([])
+ .ShouldBeNull();
+ }
+
+ [Fact]
+ public void DeriveEquipmentBase_returns_whole_value_when_no_dot()
+ {
+ EquipmentScriptPaths
+ .DeriveEquipmentBase(["NoDot"])
+ .ShouldBe("NoDot");
+ }
+
+ [Fact]
+ public void DeriveEquipmentBase_skips_null_empty_and_whitespace_entries()
+ {
+ EquipmentScriptPaths
+ .DeriveEquipmentBase([null, "", " ", "TestMachine_001.A"])
+ .ShouldBe("TestMachine_001");
+ }
+
+ // ---- SubstituteEquipmentToken ----
+
+ [Fact]
+ public void SubstituteEquipmentToken_substitutes_inside_GetTag()
+ {
+ var result = EquipmentScriptPaths.SubstituteEquipmentToken(
+ "ctx.GetTag(\"{{equip}}.Source\")", "TestMachine_001");
+
+ result.ShouldContain("ctx.GetTag(\"TestMachine_001.Source\")");
+ }
+
+ [Fact]
+ public void SubstituteEquipmentToken_substitutes_inside_SetVirtualTag()
+ {
+ var result = EquipmentScriptPaths.SubstituteEquipmentToken(
+ "ctx.SetVirtualTag(\"{{equip}}.Out\", x)", "TestMachine_001");
+
+ result.ShouldContain("ctx.SetVirtualTag(\"TestMachine_001.Out\"");
+ }
+
+ [Fact]
+ public void SubstituteEquipmentToken_leaves_comment_unchanged()
+ {
+ var source = "// uses {{equip}} here";
+
+ EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
+ .ShouldBe(source);
+ }
+
+ [Fact]
+ public void SubstituteEquipmentToken_leaves_logger_string_unchanged()
+ {
+ var source = "ctx.Logger.Information(\"{{equip}}\")";
+
+ EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
+ .ShouldBe(source);
+ }
+
+ [Fact]
+ public void SubstituteEquipmentToken_substitutes_all_occurrences()
+ {
+ var source = "ctx.GetTag(\"{{equip}}.A\"); ctx.GetTag(\"{{equip}}.B\");";
+
+ var result = EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001");
+
+ result.ShouldContain("ctx.GetTag(\"TestMachine_001.A\")");
+ result.ShouldContain("ctx.GetTag(\"TestMachine_001.B\")");
+ result.ShouldNotContain(EquipmentScriptPaths.EquipToken);
+ }
+
+ [Fact]
+ public void SubstituteEquipmentToken_is_identity_when_equipBase_is_null()
+ {
+ var source = "ctx.GetTag(\"{{equip}}.Source\")";
+
+ EquipmentScriptPaths.SubstituteEquipmentToken(source, null)
+ .ShouldBe(source);
+ }
+
+ [Fact]
+ public void SubstituteEquipmentToken_is_identity_when_equipBase_is_empty()
+ {
+ var source = "ctx.GetTag(\"{{equip}}.Source\")";
+
+ EquipmentScriptPaths.SubstituteEquipmentToken(source, "")
+ .ShouldBe(source);
+ }
+
+ [Fact]
+ public void SubstituteEquipmentToken_is_identity_when_token_absent()
+ {
+ var source = "ctx.GetTag(\"TestMachine_001.Source\")";
+
+ EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
+ .ShouldBe(source);
+ }
+
+ [Fact]
+ public void SubstituteEquipmentToken_does_not_touch_raw_string_literal()
+ {
+ // Documents the regex limitation: only "double-quote" literals are handled,
+ // matching the existing seam extractors. A raw-string literal is left as-is.
+ var source = "ctx.GetTag(\"\"\"{{equip}}.X\"\"\")";
+
+ EquipmentScriptPaths.SubstituteEquipmentToken(source, "TestMachine_001")
+ .ShouldBe(source);
+ }
+
+ // ---- ExtractDependencyRefs ----
+
+ [Fact]
+ public void ExtractDependencyRefs_returns_distinct_refs_in_first_seen_order()
+ {
+ var source = "ctx.GetTag(\"A\"); ctx.GetTag(\"B\"); ctx.GetTag(\"A\");";
+
+ EquipmentScriptPaths.ExtractDependencyRefs(source)
+ .ShouldBe(["A", "B"]);
+ }
+
+ [Fact]
+ public void ExtractDependencyRefs_excludes_SetVirtualTag_writes()
+ {
+ var source = "ctx.GetTag(\"X\"); ctx.SetVirtualTag(\"Y\", 1);";
+
+ EquipmentScriptPaths.ExtractDependencyRefs(source)
+ .ShouldBe(["X"]);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void ExtractDependencyRefs_returns_empty_for_null_or_whitespace(string? source)
+ {
+ EquipmentScriptPaths.ExtractDependencyRefs(source).ShouldBeEmpty();
+ }
+
+ // ---- ContainsEquipToken ----
+
+ [Fact]
+ public void ContainsEquipToken_true_when_token_present()
+ {
+ EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"{{equip}}.X\")").ShouldBeTrue();
+ }
+
+ [Fact]
+ public void ContainsEquipToken_false_when_token_absent()
+ {
+ EquipmentScriptPaths.ContainsEquipToken("ctx.GetTag(\"X\")").ShouldBeFalse();
+ }
+
+ [Fact]
+ public void ContainsEquipToken_false_for_null()
+ {
+ EquipmentScriptPaths.ContainsEquipToken(null).ShouldBeFalse();
+ }
+}