From 084c8a28e7a94af45a121cb1e31aaf322ce2b80b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 10 Jun 2026 07:36:59 -0400 Subject: [PATCH] docs(scripting): implementation plan for equipment-relative tag paths ({{equip}}) --- ...2026-06-10-equipment-relative-tag-paths.md | 598 ++++++++++++++++++ ...equipment-relative-tag-paths.md.tasks.json | 18 + 2 files changed, 616 insertions(+) create mode 100644 docs/plans/2026-06-10-equipment-relative-tag-paths.md create mode 100644 docs/plans/2026-06-10-equipment-relative-tag-paths.md.tasks.json diff --git a/docs/plans/2026-06-10-equipment-relative-tag-paths.md b/docs/plans/2026-06-10-equipment-relative-tag-paths.md new file mode 100644 index 00000000..66b45c67 --- /dev/null +++ b/docs/plans/2026-06-10-equipment-relative-tag-paths.md @@ -0,0 +1,598 @@ +# 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 `FullName`s 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.** +```bash +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: +```xml + +``` +Add a trivial passing test in `ScaffoldSmokeTests.cs`: +```csharp +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.** +```bash +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 `` +entry alongside the other test projects. + +**Step 4: Verify.** +```bash +dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests +``` +Expected: 1 passing. + +**Step 5: Commit.** +```bash +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 2–5) + +**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`): +```csharp +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; + } +} +``` + +**Step 3: Run tests green.** +```bash +dotnet test tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests +``` + +**Step 4: Commit.** +```bash +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 ~291–310; delete local `ExtractDependencyRefs` ~349–364 + `GetTagRefRegex` ~346–347) +- 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 +`VirtualTag`s (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: +```csharp +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.** +```bash +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.** +```bash +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` ~533–585; delete local + `ExtractDependencyRefs` ~596–607 + `GetTagRefRegex` ~587–588) +- 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 equipmentTags)`. +- At the top of that method, build the base map and substitute per vtag: +```csharp +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.** +```bash +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.** +```bash +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` ~893–935, `UpdateVirtualTagAsync` ~938–985) +- 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`: +```csharp +private static async Task 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.** +```bash +dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests +``` + +**Step 4: Commit.** +```bash +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 ~162–167; `Hover` tag-path branch ~243–252) +- 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`: +```csharp +/// Distinct attribute leaf names (the substring after the first dot of configured +/// FullNames), optionally prefix-filtered — for {{equip}}. completion. +Task> 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: +```csharp +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: +```csharp +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}}.` 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.** +```bash +dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests +``` + +**Step 6: Commit.** +```bash +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/.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.** +```bash +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.** +```bash +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}}.")`; 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-paths` → `master`). + +--- + +## 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). diff --git a/docs/plans/2026-06-10-equipment-relative-tag-paths.md.tasks.json b/docs/plans/2026-06-10-equipment-relative-tag-paths.md.tasks.json new file mode 100644 index 00000000..f271bf7b --- /dev/null +++ b/docs/plans/2026-06-10-equipment-relative-tag-paths.md.tasks.json @@ -0,0 +1,18 @@ +{ + "planPath": "docs/plans/2026-06-10-equipment-relative-tag-paths.md", + "branch": "feat/equip-relative-tag-paths", + "baseBranch": "master", + "baseSha": "50446643", + "status": "pending", + "tasks": [ + {"id": 183, "planTask": 0, "subject": "Task 0: Feature branch + Commons.Tests scaffold", "classification": "small", "status": "pending"}, + {"id": 184, "planTask": 1, "subject": "Task 1: EquipmentScriptPaths helper + tests", "classification": "standard", "status": "pending", "blockedBy": [183]}, + {"id": 185, "planTask": 2, "subject": "Task 2: Phase7Composer substitutes {{equip}}", "classification": "standard", "status": "pending", "blockedBy": [184], "parallelizableWith": [186, 187, 188]}, + {"id": 186, "planTask": 3, "subject": "Task 3: DeploymentArtifact substitutes {{equip}} (parity)", "classification": "standard", "status": "pending", "blockedBy": [184], "parallelizableWith": [185, 187, 188]}, + {"id": 187, "planTask": 4, "subject": "Task 4: AdminUI {{equip}} save validation", "classification": "standard", "status": "pending", "blockedBy": [184], "parallelizableWith": [185, 186, 188]}, + {"id": 188, "planTask": 5, "subject": "Task 5: Editor {{equip}} hover + leaf completion", "classification": "standard", "status": "pending", "blockedBy": [184], "parallelizableWith": [185, 186, 187]}, + {"id": 189, "planTask": 6, "subject": "Task 6: Docs ({{equip}})", "classification": "small", "status": "pending", "blockedBy": [185, 186, 187, 188]}, + {"id": 190, "planTask": 7, "subject": "Task 7: Full build/test + live /run verify", "classification": "verification", "status": "pending", "blockedBy": [185, 186, 187, 188, 189]} + ], + "lastUpdated": "2026-06-10" +}