Files
lmxopcua/docs/plans/2026-06-10-equipment-relative-tag-paths.md
T

27 KiB
Raw Blame History

Equipment-Relative Tag Paths ({{equip}}) — Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers-extended-cc:subagent-driven-development (or executing-plans) to implement this plan task-by-task.

Goal: Let one virtual-tag script be reused across every equipment instance by substituting a reserved {{equip}} token (inside ctx.GetTag(...) / ctx.SetVirtualTag(...) path literals) with the owning equipment's tag base prefix at deploy time.

Architecture: A pure Commons helper derives each equipment's base from the common prefix-before-first-dot of its child-tag FullNames and substitutes {{equip}} into the script text at the two compose seams (Phase7Composer.Compose + DeploymentArtifact.BuildEquipmentVirtualTagPlans), before dependency extraction. After substitution the path is a concrete literal, so the runtime, dependency graph, and editor are untouched. No schema migration, no new column. Design: docs/plans/2026-06-10-equipment-relative-tag-paths-design.md.

Tech Stack: C# / .NET 10, regex (no Roslyn in the helper), xUnit + Shouldly, in-memory EF for AdminUI tests. No bUnit.

Hard rules (carried from prior work): stage by path (never git add .); never stage sql_login.txt or src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/; never echo the gateway API key into a new tracked file; no --no-verify; no force-push; no Configuration entity / migration change; the agent does not sign in to the AdminUI (the user drives /run).

Branch: feat/equip-relative-tag-paths off master (HEAD 50446643).


Verified facts the executor must not re-derive

  • Runtime resolves ctx.GetTag("X") by the driver FullName (the DependencyMuxActor._byRef key). The UNS-path engine is dormant. So {{equip}} resolves in FullName space; base = substring before the first ..
  • Both compose seams already build an equipmentTags list (Equipment-kind tags with EquipmentId + FullName) before the virtual-tag plans, and both currently carry a duplicated private ExtractDependencyRefs + GetTagRefRegex. This plan consolidates those into the shared helper.
  • OpcUaServer and Runtime both reference Commons. OpcUaServer does not reference Core.Scripting (keep the helper Roslyn-free, in Commons).
  • IScriptTagCatalog.GetTagInfoAsync returns null for a {{equip}} literal (not a configured path) — today that yields a "not a known tag" hover, which task 5 fixes.

Task 0: Feature branch + Commons.Tests project scaffold

Classification: small Estimated implement time: ~4 min Parallelizable with: none

Files:

  • Create: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/ZB.MOM.WW.OtOpcUa.Commons.Tests.csproj
  • Create: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/ScaffoldSmokeTests.cs
  • Modify: ZB.MOM.WW.OtOpcUa.slnx

Step 1: Branch.

git checkout -b feat/equip-relative-tag-paths

Step 2: Create the test project. Model the .csproj on an existing Core test project that references Commons, e.g. tests/Core/ZB.MOM.WW.OtOpcUa.Cluster.Tests/ZB.MOM.WW.OtOpcUa.Cluster.Tests.csproj (copy its TargetFramework, xUnit + Shouldly + Microsoft.NET.Test.Sdk package refs, and IsPackable=false). The only project reference needed:

<ProjectReference Include="..\..\..\src\Core\ZB.MOM.WW.OtOpcUa.Commons\ZB.MOM.WW.OtOpcUa.Commons.csproj" />

Add a trivial passing test in ScaffoldSmokeTests.cs:

using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Commons.Tests;

public class ScaffoldSmokeTests
{
    [Fact]
    public void Project_builds() => true.ShouldBeTrue();
}

Step 3: Register in the solution.

dotnet sln ZB.MOM.WW.OtOpcUa.slnx add tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/ZB.MOM.WW.OtOpcUa.Commons.Tests.csproj

If dotnet sln … add does not handle .slnx, edit ZB.MOM.WW.OtOpcUa.slnx by hand to add the <Project Path="tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/…csproj" /> entry alongside the other test projects.

Step 4: Verify.

dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests

Expected: 1 passing.

Step 5: Commit.

git add tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests ZB.MOM.WW.OtOpcUa.slnx
git commit -m "test(commons): scaffold Commons.Tests project"

Task 1: EquipmentScriptPaths helper (Commons) + unit tests

Classification: standard Estimated implement time: ~5 min Parallelizable with: none (blocks 25)

Files:

  • Create: src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs
  • Create: tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs

Step 1: Write the failing tests (TDD — write these first, run, watch them fail to compile/assert). Cover:

  • DeriveEquipmentBase: ["TestMachine_001.A","TestMachine_001.B"]"TestMachine_001"; divergent ["TestMachine_001.A","DelmiaReceiver_001.B"]null; empty → null; no-dot ["NoDot"]"NoDot"; null/empty entries skipped.
  • SubstituteEquipmentToken: ctx.GetTag("{{equip}}.Source") + "TestMachine_001"ctx.GetTag("TestMachine_001.Source"); ctx.SetVirtualTag("{{equip}}.Out", x) substituted; a {{equip}} inside a // comment or ctx.Logger.Information("{{equip}}") string is left unchanged; multiple occurrences across literals all substituted; identity when base is null/empty; identity when token absent; a raw-string literal """{{equip}}.X""" is not substituted (documents the regex limitation, matches the existing seam extractors).
  • ExtractDependencyRefs: distinct, first-seen order; ctx.SetVirtualTag("Y", …) is not returned (writes are not dependencies).
  • ContainsEquipToken: true for a {{equip}} source, false otherwise / for null.

Step 2: Implement (EquipmentScriptPaths.cs):

using System.Text.RegularExpressions;

namespace ZB.MOM.WW.OtOpcUa.Commons.Types;

/// <summary>
/// Helpers for equipment-relative virtual-tag script paths. The reserved token
/// <c>{{equip}}</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
/// replaced at the compose seams with the owning equipment's tag base prefix (derived
/// from its child-tag <c>FullName</c>s). 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 <c>ctx.GetTag("…")</c> dependency-ref extraction those two seams used to
/// duplicate.
/// </summary>
public static class EquipmentScriptPaths
{
    /// <summary>The reserved equipment-base token.</summary>
    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);

    /// <summary>True when the source uses the <c>{{equip}}</c> token anywhere.</summary>
    /// <param name="source">The script source to scan.</param>
    public static bool ContainsEquipToken(string? source) =>
        !string.IsNullOrEmpty(source) && source.Contains(EquipToken, StringComparison.Ordinal);

    /// <summary>
    /// Equipment tag base = the single shared substring-before-first-dot across the
    /// equipment's child-tag <c>FullName</c>s. Returns <c>null</c> when there are no usable
    /// FullNames or they don't agree on one prefix (equipment spanning multiple objects).
    /// </summary>
    /// <param name="childFullNames">The equipment's child-tag driver FullNames.</param>
    /// <returns>The shared base prefix, or null when none/ambiguous.</returns>
    public static string? DeriveEquipmentBase(IEnumerable<string?> 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;
    }

    /// <summary>
    /// Replace <c>{{equip}}</c> with <paramref name="equipBase"/> inside
    /// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. Identity when
    /// <paramref name="equipBase"/> is null/empty or the token is absent (so every existing
    /// script — none of which use the token — is byte-unchanged).
    /// </summary>
    /// <param name="source">The script source.</param>
    /// <param name="equipBase">The equipment base prefix, or null/empty for no substitution.</param>
    /// <returns>The source with the token substituted inside path literals.</returns>
    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);
    }

    /// <summary>
    /// Distinct <c>ctx.GetTag("ref")</c> string literals in first-seen order — the
    /// dependency refs the <c>VirtualTagActor</c> subscribes to. The single shared copy
    /// formerly duplicated in <c>Phase7Composer</c> + <c>DeploymentArtifact</c>. GetTag
    /// only (writes are not dependencies).
    /// </summary>
    /// <param name="scriptSource">The (already substituted) script source.</param>
    /// <returns>Distinct read refs, first-seen order.</returns>
    public static IReadOnlyList<string> ExtractDependencyRefs(string? scriptSource)
    {
        if (string.IsNullOrWhiteSpace(scriptSource)) return Array.Empty<string>();
        var seen = new HashSet<string>(StringComparer.Ordinal);
        var result = new List<string>();
        foreach (Match m in GetTagRefRegex.Matches(scriptSource))
        {
            var r = m.Groups[1].Value;
            if (seen.Add(r)) result.Add(r);
        }
        return result;
    }
}

Step 3: Run tests green.

dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests

Step 4: Commit.

git add src/Core/ZB.MOM.WW.OtOpcUa.Commons/Types/EquipmentScriptPaths.cs \
        tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/EquipmentScriptPathsTests.cs
git commit -m "feat(commons): EquipmentScriptPaths — derive base + {{equip}} substitution + shared dep extraction"

Task 2: Wire Phase7Composer to substitute {{equip}}

Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 3, Task 4, Task 5

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs (vtag loop ~291310; delete local ExtractDependencyRefs ~349364 + GetTagRefRegex ~346347)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerEquipTokenTests.cs (new)

Step 1: Write the failing test. Build two Equipment (TestMachine_001, TestMachine_002), each with one Equipment-kind Tag whose TagConfig is {"FullName":"TestMachine_00N.Source","DataType":"Int32"}, plus one shared Script whose SourceCode is return System.Convert.ToInt32(ctx.GetTag("{{equip}}.Source").Value) > 50;, and two VirtualTags (one per equipment) referencing that ScriptId. Call Phase7Composer.Compose(... , virtualTags, scripts) and assert each EquipmentVirtualTagPlan:

  • .Expression contains ctx.GetTag("TestMachine_001.Source") / ctx.GetTag("TestMachine_002.Source") respectively (no {{equip}} left).
  • .DependencyRefs equals ["TestMachine_001.Source"] / ["TestMachine_002.Source"]. Model setup on the existing Phase7ComposerPurityTests.cs for entity construction + the namespace/driver wiring needed for equipmentTags to populate (Equipment-kind namespace).

Step 2: Implement. Add using ZB.MOM.WW.OtOpcUa.Commons.Types; if absent. After equipmentTags is built (~line 289), derive the base map; in the vtag Select, substitute before extracting refs:

var baseByEquip = equipmentTags
    .GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
    .ToDictionary(
        g => g.Key,
        g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
        StringComparer.Ordinal);

// … inside the existing vtags.Select(v => { … }):
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
    src, baseByEquip.GetValueOrDefault(v.EquipmentId));
return new EquipmentVirtualTagPlan(
    VirtualTagId: v.VirtualTagId,
    EquipmentId: v.EquipmentId,
    FolderPath: string.Empty,
    Name: v.Name,
    DataType: v.DataType,
    Expression: expanded,
    DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded));

Delete the now-unused private ExtractDependencyRefs method and the GetTagRefRegex field (the Commons helper replaces them).

Step 3: Build + test.

dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests

Expected: new test passes; existing Phase7Composer*/Phase7Applier* tests still green (purity test must still pass — substitution is deterministic).

Step 4: Commit.

git add src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/Phase7Composer.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/Phase7ComposerEquipTokenTests.cs
git commit -m "feat(opcuaserver): Phase7Composer substitutes {{equip}} per equipment"

Task 3: Wire DeploymentArtifact to substitute {{equip}} (parity)

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2, Task 4, Task 5

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs (ParseComposition ~196; BuildEquipmentVirtualTagPlans ~533585; delete local ExtractDependencyRefs ~596607 + GetTagRefRegex ~587588)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs (new)

Step 1: Write the failing test. Build an artifact JSON (model on the existing DeploymentArtifactTests.cs construction) containing: a Tags array with an Equipment-kind tag {"EquipmentId":"TestMachine_001","DriverInstanceId":…,"Name":"Source", "TagConfig":"{\"FullName\":\"TestMachine_001.Source\"}",…} (plus the driver/namespace entries BuildEquipmentTagPlans requires), a Scripts array with a {{equip}} script, and a VirtualTags array binding it under TestMachine_001. Call DeploymentArtifact.ParseComposition(blob) and assert the single EquipmentVirtualTags[0].Expression contains ctx.GetTag("TestMachine_001.Source") and .DependencyRefs == ["TestMachine_001.Source"].

Step 2: Implement. Add using ZB.MOM.WW.OtOpcUa.Commons.Types;. Thread the already- built equipmentTags into the vtag-plan builder:

  • At ParseComposition ~line 196: var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
  • Change the signature to BuildEquipmentVirtualTagPlans(JsonElement root, IReadOnlyList<EquipmentTagPlan> equipmentTags).
  • At the top of that method, build the base map and substitute per vtag:
var baseByEquip = equipmentTags
    .GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
    .ToDictionary(
        g => g.Key,
        g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
        StringComparer.Ordinal);
// … per vtag:
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
    source, baseByEquip.GetValueOrDefault(equipmentId!));
result.Add(new EquipmentVirtualTagPlan(
    VirtualTagId: virtualTagId!,
    EquipmentId: equipmentId!,
    FolderPath: string.Empty,
    Name: name!,
    DataType: dataType ?? "BaseDataType",
    Expression: expanded,
    DependencyRefs: EquipmentScriptPaths.ExtractDependencyRefs(expanded)));

Delete the local ExtractDependencyRefs + GetTagRefRegex (Commons replaces them).

Step 3: Build + test.

dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests

Expected: new test passes; existing DeploymentArtifactTests still green (parity with the composer preserved — both now call the same helper on the same equipmentTags).

Step 4: Commit.

git add src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DeploymentArtifact.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DeploymentArtifactEquipTokenTests.cs
git commit -m "feat(runtime): DeploymentArtifact substitutes {{equip}} (parity with composer)"

Task 4: AdminUI save-time validation for {{equip}}

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2, Task 3, Task 5

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs (CreateVirtualTagAsync ~893935, UpdateVirtualTagAsync ~938985)
  • Create: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagConfigFullName.cs (internal static Extract(string?), mirroring ScriptTagCatalog.ExtractFullNameFromTagConfig — a deliberate small copy so this task stays disjoint from Task 5's ScriptTagCatalog edits; the codebase already keeps parallel copies of this extractor)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/VirtualTagEquipTokenValidationTests.cs (new)

Step 1: Write the failing tests (in-memory EF, model on the AdminUI.Tests pattern used by the Monaco ScriptSourceServiceTests):

  • Equipment with a tag {"FullName":"TestMachine_001.X"} + a {{equip}} script → CreateVirtualTagAsync returns Ok == true.
  • Equipment with no tags + a {{equip}} script → returns Ok == false with the derivable-base error message.
  • A script without {{equip}} → succeeds regardless of tags.
  • Same three for UpdateVirtualTagAsync.

Step 2: Implement. Add TagConfigFullName.Extract (copy the JSON FullName extractor from ScriptTagCatalog.ExtractFullNameFromTagConfig). Add a private helper to UnsTreeService:

private static async Task<UnsMutationResult?> ValidateEquipTokenAsync(
    OtOpcUaConfigDbContext db, string equipmentId, string scriptId, CancellationToken ct)
{
    var src = await db.Scripts.Where(s => s.ScriptId == scriptId)
        .Select(s => s.SourceCode).FirstOrDefaultAsync(ct);
    if (!EquipmentScriptPaths.ContainsEquipToken(src)) return null;

    var configs = await db.Tags.Where(t => t.EquipmentId == equipmentId)
        .Select(t => t.TagConfig).ToListAsync(ct);
    var fullNames = configs.Select(TagConfigFullName.Extract);
    if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
    {
        return new UnsMutationResult(false,
            $"Equipment '{equipmentId}' has no single tag base, so {{equip}} can't be " +
            "resolved. Add at least one driver tag under this equipment (all sharing one " +
            "object prefix), or remove {{equip}} from the script.");
    }
    return null;
}

Call it in CreateVirtualTagAsync after the name-dup check (~line 919, before the db.VirtualTags.Add), passing equipmentId + input.ScriptId; and in UpdateVirtualTagAsync after the name-dup check (~line 965, before applying), passing entity.EquipmentId + input.ScriptId. Return the result if non-null. Add the using ZB.MOM.WW.OtOpcUa.Commons.Types; import.

Note the message contains literal {{equip}} — in a C# interpolated string {{/}} are escaped braces, so it renders as {equip}… use a non-interpolated concatenation or {{{{equip}}}} so the operator sees {{equip}}. (The snippet above is non-interpolated except the $"…{equipmentId}…" segment — keep the token text in a plain segment.)

Step 3: Build + test.

dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests

Step 4: Commit.

git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagConfigFullName.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/VirtualTagEquipTokenValidationTests.cs
git commit -m "feat(adminui): reject {{equip}} virtual tags whose equipment has no derivable base"

Task 5: Editor — {{equip}}-aware hover + {{equip}}. leaf completion

Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2, Task 3, Task 4

Files:

  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs (CompleteAsync tag-path branch ~162167; Hover tag-path branch ~243252)
  • Modify: src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs (+ ScriptTagCatalog impl)
  • Test: tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/EquipTokenEditorTests.cs (new)
  • Modify: the test fake IScriptTagCatalog in AdminUI.Tests to implement the new method (find it under tests/.../ScriptAnalysis/)

No JS change is needed — both branches reuse detail == "tag path", which monaco-init.js already routes through the whole-literal literalRange.

Step 1: Catalog method. Add to IScriptTagCatalog:

/// <summary>Distinct attribute leaf names (the substring after the first dot of configured
/// FullNames), optionally prefix-filtered — for {{equip}}. completion.</summary>
Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct);

Implement in ScriptTagCatalog over the existing BuildEntriesAsync: for each entry Path, take the substring after the first . (skip entries with no dot), distinct (Ordinal), StartsWith(filter) when filter set, ordered, Take(MaxResults).

Step 2: Completion branch. In CompleteAsync, inside if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix)), before the existing GetPathsAsync call:

const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}."
if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal))
{
    var leaves = await _catalog.GetEquipmentRelativeLeavesAsync(
        pathPrefix.Substring(equipDot.Length), CancellationToken.None);
    return new CompletionsResponse(leaves
        .Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field"))
        .ToList());
}

(Add using ZB.MOM.WW.OtOpcUa.Commons.Types;.)

Step 3: Hover branch. In Hover, inside the existing if (_catalog is not null && TryGetTagPathLiteral(token, out var tagPath) && …) block, before the GetTagInfoAsync call:

static string Code(string s) => s.Replace("`", "\\`");
if (tagPath.Contains(EquipmentScriptPaths.EquipToken, StringComparison.Ordinal))
{
    return new HoverResponse(
        $"**Equipment-relative path** `{Code(tagPath)}`\n\n" +
        "`{{equip}}` is replaced with the owning equipment's tag base when the VirtualTag is deployed.");
}

(Keep the existing static string Code once — don't double-declare; reuse the local.)

Step 4: Tests (ScriptAnalysisService ctor takes a fake IScriptTagCatalog):

  • CompleteAsync on return ctx.GetTag("{{equip}}.").Value; with the caret right after the dot → items are {{equip}}.<leaf> for the fake's leaves.
  • Hover over a {{equip}}.Source literal → markdown starts with **Equipment-relative path** (no "Not a known configured tag path").

Step 5: Build + test.

dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests

Step 6: Commit.

git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \
        src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/EquipTokenEditorTests.cs \
        tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/<fake-catalog-file>.cs
git commit -m "feat(adminui): {{equip}}-aware hover + {{equip}}. leaf completion in the script editor"

Task 6: Docs

Classification: small Estimated implement time: ~3 min Parallelizable with: Task 5

Files:

  • Modify: docs/ScriptEditor.md (new "Equipment-relative paths ({{equip}})" section)
  • Modify: CLAUDE.md (one sentence in the Scripting section)

Step 1: In docs/ScriptEditor.md, document: write ctx.GetTag("{{equip}}.Attr"); {{equip}} is replaced at deploy with the equipment's tag base (derived from its tags' FullNames before the first dot); one script can be shared across many machines by pointing many VirtualTags' ScriptId at it; the equipment must have ≥1 driver tag with a single shared prefix or the save is rejected; editor hover + {{equip}}. completion.

Step 2: In CLAUDE.md Scripting section, add one line pointing at the {{equip}} feature + docs/ScriptEditor.md.

Step 3: Commit.

git add docs/ScriptEditor.md CLAUDE.md
git commit -m "docs(scripting): document {{equip}} equipment-relative tag paths"

Task 7: Full build + test, then live /run verification

Classification: verification Estimated implement time: user-driven Parallelizable with: none

Step 1: Full suite.

dotnet build ZB.MOM.WW.OtOpcUa.slnx
dotnet test ZB.MOM.WW.OtOpcUa.slnx

Expected: clean build, all green (AdminUI.Tests includes the new editor + validation tests; Commons/OpcUaServer/Runtime suites include the new ones).

Step 2: Live docker-dev /run (user drives — agent does NOT sign in). Rebuild the central AdminUI image, then the user:

  • Authors a script using ctx.GetTag("{{equip}}.<attr>"); confirms editor hover shows the equipment-relative note and {{equip}}. offers leaf completions.
  • Binds that one script to VirtualTags under two different machines; deploys; confirms each resolves its own machine's tag and produces live values.
  • Confirms binding the {{equip}} script to an equipment with no derivable base is rejected with the clear message.

Step 3: On green, finish via superpowers-extended-cc:finishing-a-development-branch (merge feat/equip-relative-tag-pathsmaster).


Execution notes

  • Dependency spine: T0 → T1 → { T2 ∥ T3 ∥ T4 ∥ T5 } → T6 → T7. The four wire-in tasks touch disjoint files (OpcUaServer / Runtime / AdminUI-Uns / AdminUI-ScriptAnalysis) and may be dispatched concurrently after T1.
  • Parity is structural: T2 and T3 both call EquipmentScriptPaths.ExtractDependencyRefs
    • .SubstituteEquipmentToken on a base derived from their respective (already byte-parity) equipmentTags. Don't reintroduce a local regex in either seam.
  • No Configuration entity/migration change. If a task seems to need one, stop — the design is explicitly migration-free (base is derived, not stored).