merge worktree-agent-ad3207d913b3b74e6 (B3-WP4) into v3/batch3-uns-rework

This commit is contained in:
Joseph Doherty
2026-07-16 06:59:22 -04:00
23 changed files with 1142 additions and 426 deletions
@@ -29,6 +29,13 @@
[Parameter] public bool ReadOnly { get; set; } = false;
[Parameter] public bool ShowToolbar { get; set; } = true;
/// <summary>
/// Owning-equipment context for equipment-relative <c>{{equip}}/&lt;RefName&gt;</c> completion +
/// diagnostics. Set on the per-equipment VirtualTag / ScriptedAlarm modals; left null on the shared
/// ScriptEdit page (where the token cannot resolve to a single equipment).
/// </summary>
[Parameter] public string? EquipmentId { get; set; }
/// <summary>
/// Fires whenever Monaco's marker set updates (after the 500 ms diagnostic
/// debounce). The marker DTO is not modelled yet — typed as object[] until a
@@ -61,7 +68,8 @@
{
value = Value ?? "",
language = Language,
readOnly = ReadOnly
readOnly = ReadOnly,
equipmentId = EquipmentId
},
_dotNetRef);
_initialized = true;
@@ -99,7 +99,7 @@
Editing shared script "<span class="mono">@_scriptName</span>" — used by
@_scriptUsageCount virtual tag(s). Changes affect all of them.
</div>
<MonacoEditor @bind-Value="_scriptSource" Height="300px" />
<MonacoEditor @bind-Value="_scriptSource" Height="300px" EquipmentId="@EquipmentId" />
@if (!string.IsNullOrWhiteSpace(_scriptError))
{
<div class="text-danger small mt-2">@_scriptError</div>
@@ -23,13 +23,16 @@ public interface IScriptTagCatalog
/// <returns>The resolved tag info, or <see langword="null"/> when <paramref name="path"/> is not a known configured path.</returns>
Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct);
/// <summary>Distinct attribute leaf names — the substring after the first dot of each
/// configured tag FullName — optionally prefix-filtered. Powers {{equip}}. completion,
/// which needs the per-equipment object prefix stripped.</summary>
/// <param name="filter">Case-insensitive StartsWith prefix over the leaf; null/empty = all (bounded).</param>
/// <summary>v3: the owning equipment's <c>UnsTagReference</c> effective names (its
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the resolvable set for
/// <c>{{equip}}/&lt;RefName&gt;</c> completion + diagnostics, matching what the compose seams substitute
/// and the deploy gate enforces. Optionally prefix-filtered. Empty when the equipment id is blank or has
/// no references.</summary>
/// <param name="equipmentId">The equipment whose references to list; blank returns empty.</param>
/// <param name="filter">Case-insensitive StartsWith prefix over the reference name; null/empty = all (bounded).</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Distinct leaf names.</returns>
Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct);
/// <returns>Distinct reference effective names.</returns>
Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct);
}
/// <summary>Resolved info for one configured tag/virtual-tag path (for hover).</summary>
@@ -114,17 +117,32 @@ public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> d
}
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetEquipmentRelativeLeavesAsync(string? filter, CancellationToken ct)
public async Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
{
var entries = await BuildEntriesAsync(ct);
var leaves = new HashSet<string>(StringComparer.Ordinal);
foreach (var e in entries)
if (string.IsNullOrEmpty(equipmentId)) return Array.Empty<string>();
await using var db = await dbFactory.CreateDbContextAsync(ct);
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => new { r.TagId, r.DisplayNameOverride })
.ToListAsync(ct);
if (refs.Count == 0) return Array.Empty<string>();
var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
var tagNameById = await db.Tags.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.Name })
.ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
// Effective name = DisplayNameOverride else the backing raw tag's Name (ordinal set).
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in refs)
{
var dot = e.Path.IndexOf('.');
if (dot < 0 || dot + 1 >= e.Path.Length) continue;
leaves.Add(e.Path.Substring(dot + 1));
var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (!string.IsNullOrEmpty(eff)) names.Add(eff!);
}
IEnumerable<string> q = leaves;
IEnumerable<string> q = names;
if (!string.IsNullOrWhiteSpace(filter))
q = q.Where(n => n.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
return q.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).Take(MaxResults).ToList();
@@ -1,15 +1,19 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public record DiagnoseRequest(string Code);
// EquipmentId (optional) carries the owning-equipment context the {{equip}}/<RefName> completion +
// diagnostic need to resolve reference effective names. Null on the shared ScriptEdit page (no single
// equipment) — the equip-ref features are then inert, matching the deploy gate (which only resolves per
// owning equipment).
public record DiagnoseRequest(string Code, string? EquipmentId = null);
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn,
int EndLineNumber, int EndColumn, string Message, string Code);
public record CompletionsRequest(string CodeText, int Line, int Column);
public record CompletionsRequest(string CodeText, int Line, int Column, string? EquipmentId = null);
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0);
public record HoverRequest(string CodeText, int Line, int Column);
public record HoverRequest(string CodeText, int Line, int Column, string? EquipmentId = null);
public record HoverResponse(string? Markdown);
public record SignatureHelpRequest(string CodeText, int Line, int Column);
@@ -16,7 +16,7 @@ public static class ScriptAnalysisEndpoints
// ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
var group = endpoints.MapGroup("/api/script-analysis")
.RequireAuthorization(AdminUiPolicies.ConfigEditor);
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r)));
group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r)));
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r)));
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
@@ -130,6 +130,58 @@ public sealed class ScriptAnalysisService
}
}
// ctx.GetTag("…") / ctx.SetVirtualTag("…") first-arg literal — three-part capture, mirroring
// EquipmentScriptPaths.PathLiteralRegex so the editor's {{equip}}/<RefName> diagnostic is scoped to the
// SAME path literals the compose seams substitute (never a comment / logger string).
private static readonly System.Text.RegularExpressions.Regex EquipPathLiteralRegex =
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")",
System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>Diagnostics plus the v3 equipment-relative <c>{{equip}}/&lt;RefName&gt;</c> resolution check.
/// Runs the synchronous Roslyn/policy <see cref="Diagnose"/> then, when an equipment context + catalog
/// are present, appends a marker for each <c>{{equip}}/&lt;RefName&gt;</c> whose <c>&lt;RefName&gt;</c> is
/// not one of the equipment's reference effective names — flagging exactly what the deploy gate
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req)
{
var baseResp = Diagnose(req);
if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code))
return baseResp;
try
{
var code = Normalize(req.Code);
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
return baseResp;
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None);
var resolvable = new HashSet<string>(names, StringComparer.Ordinal);
var markers = new List<DiagnosticMarker>(baseResp.Markers);
foreach (System.Text.RegularExpressions.Match m in EquipPathLiteralRegex.Matches(code))
{
var content = m.Groups[2].Value;
if (!content.StartsWith(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) continue;
var refName = content.Substring(EquipmentScriptPaths.EquipTokenPrefix.Length);
if (refName.Length == 0 || resolvable.Contains(refName)) continue;
// Mark the whole literal content span (the {{equip}}/<RefName> path).
var span = new Microsoft.CodeAnalysis.Text.TextSpan(m.Groups[2].Index, content.Length);
markers.Add(ToUserMarker(code, span,
$"'{EquipmentScriptPaths.EquipTokenPrefix}{refName}' does not resolve — this equipment has no " +
$"reference named '{refName}'. Add a matching reference or correct the path.",
"OTSCRIPT_EQUIPREF"));
}
return new DiagnoseResponse(markers.Distinct().ToList());
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers.");
return baseResp;
}
}
private static int Sev(DiagnosticSeverity s) => s switch
{
DiagnosticSeverity.Error => 8,
@@ -173,13 +225,17 @@ public sealed class ScriptAnalysisService
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _))
{
const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}."
if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal))
// v3: {{equip}}/<RefName> completes the owning equipment's UnsTagReference effective names
// (needs the equipment context — inert on the shared ScriptEdit page where EquipmentId is null).
const string equipPrefix = EquipmentScriptPaths.EquipTokenPrefix; // "{{equip}}/"
if (pathPrefix.StartsWith(equipPrefix, 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"))
if (string.IsNullOrEmpty(req.EquipmentId))
return new CompletionsResponse(Array.Empty<CompletionItem>());
var names = await _catalog.GetEquipmentReferenceNamesAsync(
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None);
return new CompletionsResponse(names
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
.ToList());
}
@@ -281,7 +337,8 @@ public sealed class ScriptAnalysisService
{
return new HoverResponse(
$"**Equipment-relative path** `{Code(tagPath)}`\n\n" +
"The {{equip}} token is replaced with the owning equipment's tag base when the VirtualTag is deployed.");
"`{{equip}}/<RefName>` resolves through the owning equipment's tag reference " +
"(by effective name) to the backing raw tag's path when the VirtualTag is deployed.");
}
var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);
@@ -1226,29 +1226,45 @@ public sealed class UnsTreeService(
}
/// <summary>
/// When the bound script uses the reserved <c>{{equip}}</c> token, the owning equipment must have
/// a derivable tag base (≥1 driver tag, all sharing one object prefix). Returns a rejection result
/// when the token is present but no base can be derived; <c>null</c> otherwise (token absent, or a
/// base exists). The script source is fetched from the supplied (in-scope) context.
/// v3 (M1): when the bound script uses <c>{{equip}}/&lt;RefName&gt;</c>, every <c>&lt;RefName&gt;</c> must
/// resolve to one of the owning equipment's <c>UnsTagReference</c> effective names (its
/// <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>) — the same set the compose seams
/// substitute against and the deploy gate (<c>DraftValidator.ValidateEquipReferenceResolution</c>)
/// enforces. Returns a readable rejection naming the missing ref(s) when any is unresolved; <c>null</c>
/// otherwise (no slash-token, or all resolve). Replaces the v2 <c>DeriveEquipmentBase(empty)→null</c>
/// check, which rejected ALL <c>{{equip}}</c> VirtualTags with a now-impossible "add a driver tag" message.
/// </summary>
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 used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
if (used.Count == 0) return null;
// The equipment↔Tag binding was retired in v3 (Tag is Raw-only), so no equipment-bound driver
// tags exist to derive an equipment tag base from. The {{equip}} token can't be resolved here.
var fullNames = Array.Empty<string>();
if (EquipmentScriptPaths.DeriveEquipmentBase(fullNames) is null)
// The equipment's reference effective-name set: DisplayNameOverride else the backing raw tag's Name.
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => new { r.TagId, r.DisplayNameOverride })
.ToListAsync(ct);
var tagIds = refs.Select(r => r.TagId).ToList();
var tagNameById = await db.Tags.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.Name })
.ToDictionaryAsync(t => t.TagId, t => t.Name, ct);
var effectiveNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in refs)
{
return new UnsMutationResult(false,
$"Equipment '{equipmentId}' has no single tag base, so the "
+ $"{EquipmentScriptPaths.EquipToken} token can't be resolved. Add at least one driver tag under this "
+ $"equipment (all sharing one object prefix), or remove {EquipmentScriptPaths.EquipToken} from the script.");
var eff = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (!string.IsNullOrEmpty(eff)) effectiveNames.Add(eff!);
}
return null;
var missing = used.Where(u => !effectiveNames.Contains(u)).ToList();
if (missing.Count == 0) return null;
return new UnsMutationResult(false,
$"Script uses {string.Join(", ", missing.Select(m => $"'{EquipmentScriptPaths.EquipTokenPrefix}{m}'"))} "
+ $"but equipment '{equipmentId}' has no reference with that effective name. "
+ "Add a matching reference on the Tags tab, or correct the script.");
}
/// <inheritdoc />
@@ -23,6 +23,11 @@
const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 };
// The owning-equipment id is stamped onto each editor's model at createEditor time so the globally
// registered csharp providers (which only receive the model) can thread it into the {{equip}}/<RefName>
// completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there).
function equipmentIdOf(model) { return (model && model.__otEquipmentId) || null; }
function registerCSharpProviders() {
monaco.languages.registerCompletionItemProvider("csharp", {
triggerCharacters: [".", "(", "\""],
@@ -31,7 +36,7 @@
const resp = await fetch("/api/script-analysis/completions", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column })
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
});
if (!resp.ok) return { suggestions: [] };
const data = await resp.json();
@@ -72,7 +77,7 @@
const resp = await fetch("/api/script-analysis/hover", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column })
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
});
if (!resp.ok) return null;
const data = await resp.json();
@@ -149,7 +154,7 @@
const resp = await fetch("/api/script-analysis/diagnostics", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: model.getValue() })
body: JSON.stringify({ code: model.getValue(), equipmentId: equipmentIdOf(model) })
});
if (!resp.ok) return [];
const data = await resp.json();
@@ -192,6 +197,9 @@
// inside ctx.GetTag("…") / ctx.SetVirtualTag("…") without requiring an explicit Ctrl+Space.
quickSuggestions: { other: true, comments: false, strings: true }
});
// Stamp the owning-equipment id on the model so the global csharp providers can thread it into the
// {{equip}}/<RefName> completion + diagnostics (null on the shared ScriptEdit page).
try { const m0 = editor.getModel(); if (m0) m0.__otEquipmentId = options.equipmentId || null; } catch (x) {}
let diagTimer = null;
const scheduleDiagnostics = function () {
if (diagTimer) clearTimeout(diagTimer);
@@ -315,6 +315,11 @@ public static class AddressSpaceComposer
/// <param name="scriptedAlarms">The scripted alarms.</param>
/// <param name="virtualTags">The per-equipment virtual (calculated) tags. <c>null</c> = none.</param>
/// <param name="scripts">The scripts joined to <paramref name="virtualTags"/> by ScriptId for the expression. <c>null</c> = none.</param>
/// <param name="unsTagReferences">The UNS tag references (equipment → raw tag). Feed the <c>{{equip}}/&lt;RefName&gt;</c> reference map. <c>null</c> = none.</param>
/// <param name="tags">The raw tags backing <paramref name="unsTagReferences"/> (RawPath computed via the shared resolver). <c>null</c> = none.</param>
/// <param name="rawFolders">The raw-tree folders (RawPath ancestry). <c>null</c> = none.</param>
/// <param name="devices">The raw-tree devices (RawPath ancestry). <c>null</c> = none.</param>
/// <param name="tagGroups">The raw-tree tag-groups (RawPath ancestry). <c>null</c> = none.</param>
/// <returns>The composition result.</returns>
public static AddressSpaceComposition Compose(
IReadOnlyList<UnsArea> unsAreas,
@@ -323,7 +328,12 @@ public static class AddressSpaceComposer
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<ScriptedAlarm> scriptedAlarms,
IReadOnlyList<VirtualTag>? virtualTags = null,
IReadOnlyList<Script>? scripts = null)
IReadOnlyList<Script>? scripts = null,
IReadOnlyList<UnsTagReference>? unsTagReferences = null,
IReadOnlyList<Tag>? tags = null,
IReadOnlyList<RawFolder>? rawFolders = null,
IReadOnlyList<Device>? devices = null,
IReadOnlyList<TagGroup>? tagGroups = null)
{
var vtags = virtualTags ?? Array.Empty<VirtualTag>();
var resolvedScripts = scripts ?? Array.Empty<Script>();
@@ -356,14 +366,21 @@ public static class AddressSpaceComposer
.Select(a => new ScriptedAlarmPlan(a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId, a.MessageTemplate))
.ToList();
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out). {{equip}}
// substitution therefore has no per-equipment tag base — matches the artifact-decode mirror.
// v3 DARK: no equipment-tag variable plans this batch (Batch 4 owns UNS↔Raw fan-out).
var equipmentTags = Array.Empty<EquipmentTagPlan>();
var baseByEquip = new Dictionary<string, string?>(StringComparer.Ordinal);
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → backing-tag RawPath), built
// from the equipment's UnsTagReferences + the raw tags + the raw topology via the shared
// RawPathResolver — the same identity authority the artifact-decode mirror + the draft validator
// use, so the three agree byte-for-byte on the resolved RawPath.
var referenceMapByEquip = BuildEquipmentReferenceMap(
unsTagReferences, tags, rawFolders, driverInstances, devices, tagGroups);
var emptyRefMap = (IReadOnlyDictionary<string, string>)
new Dictionary<string, string>(StringComparer.Ordinal);
// Equipment VirtualTags = each VirtualTag joined to its Script (by ScriptId) for the
// expression source. The {{equip}} token is substituted with the owning equipment's tag
// base BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific.
// expression source. Each {{equip}}/<RefName> is substituted with the owning equipment's backing
// RawPath BEFORE extracting refs, so both Expression and DependencyRefs are machine-specific.
// DependencyRefs = the distinct ctx.GetTag("…") literals the VirtualTagActor subscribes to.
// VirtualTag has no FolderPath today → "".
var scriptsById = resolvedScripts.ToDictionary(s => s.ScriptId, StringComparer.Ordinal);
@@ -374,7 +391,7 @@ public static class AddressSpaceComposer
{
var src = scriptsById.TryGetValue(v.ScriptId, out var s) ? s.SourceCode : string.Empty;
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
src, baseByEquip.GetValueOrDefault(v.EquipmentId));
src, referenceMapByEquip.GetValueOrDefault(v.EquipmentId, emptyRefMap));
return new EquipmentVirtualTagPlan(
VirtualTagId: v.VirtualTagId,
EquipmentId: v.EquipmentId,
@@ -412,7 +429,13 @@ public static class AddressSpaceComposer
a.ScriptedAlarmId, a.EquipmentId, a.PredicateScriptId);
continue;
}
var source = s.SourceCode;
// v3: scripted-alarm predicates ALSO resolve {{equip}}/<RefName> (equipment-scoped scripts),
// substituted with the owning equipment's reference map BEFORE the dependency merge — so the
// stored PredicateSource + DependencyRefs are resolved RawPaths, byte-parity with the
// artifact-decode mirror. The merge (predicate reads first, then template tokens) lives in the
// shared EquipmentScriptPaths helper.
var source = EquipmentScriptPaths.SubstituteEquipmentToken(
s.SourceCode, referenceMapByEquip.GetValueOrDefault(a.EquipmentId, emptyRefMap));
equipmentScriptedAlarms.Add(new EquipmentScriptedAlarmPlan(
ScriptedAlarmId: a.ScriptedAlarmId,
EquipmentId: a.EquipmentId,
@@ -422,9 +445,6 @@ public static class AddressSpaceComposer
MessageTemplate: a.MessageTemplate,
PredicateScriptId: a.PredicateScriptId,
PredicateSource: source,
// Scripted alarms do NOT use {{equip}} substitution (only virtual tags do) — pass the
// predicate source as-is. The merge (predicate reads first, then template tokens) lives
// in the shared EquipmentScriptPaths helper so the artifact-decode mirror agrees.
DependencyRefs: EquipmentScriptPaths.ExtractAlarmDependencyRefs(source, a.MessageTemplate),
HistorizeToAveva: a.HistorizeToAveva,
Retain: a.Retain,
@@ -439,4 +459,48 @@ public static class AddressSpaceComposer
};
}
/// <summary>Build the per-equipment <c>{{equip}}/&lt;RefName&gt;</c> reference map from the v3 entities
/// via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/> — the entity-side
/// mirror of <c>DeploymentArtifact.BuildEquipmentReferenceMap</c> (JSON side). Empty when no references
/// are supplied (every test / earlier caller that omits the reference data).</summary>
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(
IReadOnlyList<UnsTagReference>? unsTagReferences,
IReadOnlyList<Tag>? tags,
IReadOnlyList<RawFolder>? rawFolders,
IReadOnlyList<DriverInstance> driverInstances,
IReadOnlyList<Device>? devices,
IReadOnlyList<TagGroup>? tagGroups)
{
var refs = unsTagReferences ?? Array.Empty<UnsTagReference>();
var tagRows = tags ?? Array.Empty<Tag>();
if (refs.Count == 0 || tagRows.Count == 0)
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
var folders = (rawFolders ?? Array.Empty<RawFolder>())
.GroupBy(f => f.RawFolderId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentRawFolderId, g.First().Name), StringComparer.Ordinal);
var drivers = driverInstances
.GroupBy(d => d.DriverInstanceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().RawFolderId, g.First().Name), StringComparer.Ordinal);
var deviceMap = (devices ?? Array.Empty<Device>())
.GroupBy(d => d.DeviceId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => (g.First().DriverInstanceId, g.First().Name), StringComparer.Ordinal);
var groups = (tagGroups ?? Array.Empty<TagGroup>())
.GroupBy(t => t.TagGroupId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => ((string?)g.First().ParentTagGroupId, g.First().Name), StringComparer.Ordinal);
var resolver = new RawPathResolver(folders, drivers, deviceMap, groups);
var tagsById = tagRows
.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => new EquipmentReferenceMap.TagRow(g.First().Name, g.First().DeviceId, g.First().TagGroupId),
StringComparer.Ordinal);
return EquipmentReferenceMap.Build(
refs.Select(r => new EquipmentReferenceMap.ReferenceRow(
r.UnsTagReferenceId, r.EquipmentId, r.TagId, r.DisplayNameOverride)),
tagsById,
resolver);
}
}
@@ -177,6 +177,82 @@ public static class DeploymentArtifact
return byDriver;
}
/// <summary>
/// Build the per-equipment <c>{{equip}}/&lt;RefName&gt;</c> reference map — <c>equipmentId →
/// (effectiveName → backing-tag RawPath)</c> — from the artifact's raw topology + <c>Tags</c> +
/// <c>UnsTagReferences</c> via the shared <see cref="EquipmentReferenceMap"/> + <see cref="RawPathResolver"/>.
/// The JSON-side mirror of <c>AddressSpaceComposer.BuildEquipmentReferenceMap</c> (entity side), so
/// both compose seams resolve <c>{{equip}}</c> script paths to byte-identical RawPaths. Empty when
/// the artifact carries no references / tags.
/// </summary>
/// <param name="root">The artifact root element.</param>
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>.</returns>
private static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> BuildEquipmentReferenceMap(JsonElement root)
{
var folders = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "RawFolders"))
{
var id = ReadString(el, "RawFolderId");
if (string.IsNullOrWhiteSpace(id)) continue;
folders[id!] = (ReadNullableString(el, "ParentRawFolderId"), ReadString(el, "Name") ?? id!);
}
var drivers = new Dictionary<string, (string? RawFolderId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "DriverInstances"))
{
var id = ReadString(el, "DriverInstanceId");
if (string.IsNullOrWhiteSpace(id)) continue;
drivers[id!] = (ReadNullableString(el, "RawFolderId"), ReadString(el, "Name") ?? id!);
}
var devices = new Dictionary<string, (string DriverInstanceId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "Devices"))
{
var id = ReadString(el, "DeviceId");
var di = ReadString(el, "DriverInstanceId");
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(di)) continue;
devices[id!] = (di!, ReadString(el, "Name") ?? id!);
}
var groups = new Dictionary<string, (string? ParentId, string Name)>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "TagGroups"))
{
var id = ReadString(el, "TagGroupId");
if (string.IsNullOrWhiteSpace(id)) continue;
groups[id!] = (ReadNullableString(el, "ParentTagGroupId"), ReadString(el, "Name") ?? id!);
}
var resolver = new RawPathResolver(folders, drivers, devices, groups);
var tagsById = new Dictionary<string, EquipmentReferenceMap.TagRow>(StringComparer.Ordinal);
foreach (var el in EnumerateArray(root, "Tags"))
{
var tagId = ReadString(el, "TagId");
var deviceId = ReadString(el, "DeviceId");
var name = ReadString(el, "Name");
if (string.IsNullOrWhiteSpace(tagId) || string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(name))
continue;
tagsById[tagId!] = new EquipmentReferenceMap.TagRow(name!, deviceId!, ReadNullableString(el, "TagGroupId"));
}
var references = new List<EquipmentReferenceMap.ReferenceRow>();
foreach (var el in EnumerateArray(root, "UnsTagReferences"))
{
var refId = ReadString(el, "UnsTagReferenceId");
var equipmentId = ReadString(el, "EquipmentId");
var tagId = ReadString(el, "TagId");
if (string.IsNullOrWhiteSpace(refId) || string.IsNullOrWhiteSpace(equipmentId) || string.IsNullOrWhiteSpace(tagId))
continue;
references.Add(new EquipmentReferenceMap.ReferenceRow(
refId!, equipmentId!, tagId!, ReadNullableString(el, "DisplayNameOverride")));
}
if (references.Count == 0 || tagsById.Count == 0)
return new Dictionary<string, IReadOnlyDictionary<string, string>>(StringComparer.Ordinal);
return EquipmentReferenceMap.Build(references, tagsById, resolver);
}
/// <summary>Build the <c>scriptId → SourceCode</c> map from the artifact's <c>Scripts</c> array (mirrors
/// the VirtualTag plan builder's join). Empty when the artifact carries no Scripts.</summary>
private static Dictionary<string, string> BuildScriptSourceMap(JsonElement root)
@@ -400,8 +476,11 @@ public static class DeploymentArtifact
// deliberately empty; {{equip}} substitution therefore has no per-equipment tag base (empty).
// The retained per-equipment plans (folder hierarchy + VirtualTags + ScriptedAlarms) still exist.
var equipmentTags = Array.Empty<EquipmentTagPlan>();
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, equipmentTags);
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root);
// Per-equipment {{equip}}/<RefName> reference map (effectiveName → RawPath), shared by the
// VirtualTag + ScriptedAlarm plan builders so both substitute against the same resolved paths.
var referenceMapByEquip = BuildEquipmentReferenceMap(root);
var equipmentVirtualTags = BuildEquipmentVirtualTagPlans(root, referenceMapByEquip);
var equipmentScriptedAlarms = BuildEquipmentScriptedAlarmPlans(root, referenceMapByEquip);
return new AddressSpaceComposition(areas, lines, equipment, drivers, alarms)
{
@@ -562,30 +641,22 @@ public static class DeploymentArtifact
/// Join the artifact's VirtualTags array to its Scripts array (by ScriptId) to emit one
/// <see cref="EquipmentVirtualTagPlan"/> per VirtualTag. The artifact-decode mirror of
/// <c>AddressSpaceComposer.Compose</c>'s VirtualTag producer — so the compose-side + artifact-decode
/// plans agree. The reserved <c>{{equip}}</c> token in the joined Script's <c>SourceCode</c> is
/// substituted with the owning equipment's tag base (derived from <paramref name="equipmentTags"/>'
/// FullNames) BEFORE refs are extracted, byte-parity with the composer. <c>Expression</c> = the
/// substituted source (empty when the ScriptId is absent); <c>DependencyRefs</c> = the distinct
/// plans agree. Each <c>{{equip}}/&lt;RefName&gt;</c> in the joined Script's <c>SourceCode</c> is
/// substituted with the owning equipment's backing RawPath (via <paramref name="referenceMapByEquip"/>)
/// BEFORE refs are extracted, byte-parity with the composer. <c>Expression</c> = the substituted
/// source (empty when the ScriptId is absent); <c>DependencyRefs</c> = the distinct
/// <c>ctx.GetTag("…")</c> literals in that source; <c>FolderPath</c> is always "" (VirtualTag has
/// no FolderPath today). Ordered by EquipmentId then Name to match the composer's deterministic
/// ordering.
/// </summary>
private static IReadOnlyList<EquipmentVirtualTagPlan> BuildEquipmentVirtualTagPlans(
JsonElement root, IReadOnlyList<EquipmentTagPlan> equipmentTags)
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
{
if (!root.TryGetProperty("VirtualTags", out var vtArr) || vtArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentVirtualTagPlan>();
// Per-equipment tag base = the shared substring-before-first-dot across each equipment's
// child-tag FullNames, used to expand the reserved {{equip}} token in shared VirtualTag
// scripts (equipment-relative tag paths). Derived from equipmentTags so the artifact-decode
// base matches the composer's exactly.
var baseByEquip = equipmentTags
.GroupBy(t => t.EquipmentId, StringComparer.Ordinal)
.ToDictionary(
g => g.Key,
g => EquipmentScriptPaths.DeriveEquipmentBase(g.Select(t => t.FullName)),
StringComparer.Ordinal);
var emptyRefMap = (IReadOnlyDictionary<string, string>)
new Dictionary<string, string>(StringComparer.Ordinal);
// scriptId → SourceCode (the expression source the VirtualTagActor evaluates).
var scriptSourceById = new Dictionary<string, string>(StringComparer.Ordinal);
@@ -626,11 +697,11 @@ public static class DeploymentArtifact
&& (hEl.ValueKind == JsonValueKind.True || hEl.ValueKind == JsonValueKind.False)
&& hEl.GetBoolean();
// Substitute the {{equip}} token with the owning equipment's tag base BEFORE extracting
// refs, so both Expression and DependencyRefs are machine-specific — byte-parity with
// AddressSpaceComposer.Compose.
// Substitute each {{equip}}/<RefName> with the owning equipment's backing RawPath BEFORE
// extracting refs, so both Expression and DependencyRefs are machine-specific — byte-parity
// with AddressSpaceComposer.Compose.
var expanded = EquipmentScriptPaths.SubstituteEquipmentToken(
source, baseByEquip.GetValueOrDefault(equipmentId!));
source, referenceMapByEquip.GetValueOrDefault(equipmentId!, emptyRefMap));
result.Add(new EquipmentVirtualTagPlan(
VirtualTagId: virtualTagId!,
@@ -659,11 +730,13 @@ public static class DeploymentArtifact
/// SKIPPED (matching the composer's skip behaviour) to preserve parity. <c>PredicateSource</c> = the
/// joined script source ("" when missing — but such alarms are skipped above); <c>DependencyRefs</c>
/// = the shared <see cref="EquipmentScriptPaths.ExtractAlarmDependencyRefs"/> merge of the predicate's
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. Scripted
/// alarms do NOT use <c>{{equip}}</c> substitution (only virtual tags do) — the predicate source is
/// used as-is. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
/// distinct <c>ctx.GetTag("…")</c> reads UNION the message template's <c>{TagPath}</c> tokens. The
/// predicate source's <c>{{equip}}/&lt;RefName&gt;</c> paths are substituted with the owning equipment's
/// backing RawPath (via <paramref name="referenceMapByEquip"/>) BEFORE the merge — byte-parity with
/// the composer. Ordered by EquipmentId then ScriptedAlarmId to match the composer's deterministic order.
/// </summary>
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(JsonElement root)
private static IReadOnlyList<EquipmentScriptedAlarmPlan> BuildEquipmentScriptedAlarmPlans(
JsonElement root, IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> referenceMapByEquip)
{
if (!root.TryGetProperty("ScriptedAlarms", out var alarmsArr) || alarmsArr.ValueKind != JsonValueKind.Array)
return Array.Empty<EquipmentScriptedAlarmPlan>();
@@ -684,6 +757,8 @@ public static class DeploymentArtifact
}
}
var emptyRefMap = (IReadOnlyDictionary<string, string>)
new Dictionary<string, string>(StringComparer.Ordinal);
var result = new List<EquipmentScriptedAlarmPlan>(alarmsArr.GetArrayLength());
foreach (var el in alarmsArr.EnumerateArray())
{
@@ -710,9 +785,14 @@ public static class DeploymentArtifact
// Skip alarms whose predicate script is missing — matching AddressSpaceComposer's skip behaviour
// so both sides emit the same set (byte-parity).
if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var source))
if (predicateScriptId is null || !scriptSourceById.TryGetValue(predicateScriptId, out var rawSource))
continue;
// Substitute each {{equip}}/<RefName> with the owning equipment's backing RawPath BEFORE the
// dependency merge — byte-parity with AddressSpaceComposer.Compose.
var source = EquipmentScriptPaths.SubstituteEquipmentToken(
rawSource, referenceMapByEquip.GetValueOrDefault(equipmentId ?? string.Empty, emptyRefMap));
result.Add(new EquipmentScriptedAlarmPlan(
ScriptedAlarmId: scriptedAlarmId!,
EquipmentId: equipmentId ?? string.Empty,