feat(v3-batch3): B3-WP4 — {{equip}}/<RefName> reference-relative resolution

Replace the deleted equipment-base-prefix {{equip}} mechanism (impossible now
that equipment is reference-only) with per-reference resolution:
{{equip}}/<RefName> resolves through the owning equipment's UnsTagReference rows
(by effective name) to the backing raw tag's RawPath. Every unresolved <RefName>
is a clear deploy error + authoring rejection + Monaco diagnostic — preserving
"editor accepts <=> publish accepts".

Commons:
- EquipmentScriptPaths: delete DeriveEquipmentBase; SubstituteEquipmentToken now
  takes the equipment's reference map (effectiveName -> RawPath) and substitutes
  {{equip}}/<RefName> inside path literals (unresolved left intact, never throws);
  add ExtractEquipReferenceNames (path-literal scoped) +
  ExtractEquipReferenceNamesFromText (message templates). Slash syntax replaces
  the v2 dot joint.
- New EquipmentReferenceMap: shared, input-shape-agnostic builder (entity + JSON
  sides) over RawPathResolver — the single authority both compose seams + the
  validator use, so resolved RawPaths agree byte-for-byte.

Compose seams (byte-parity kept):
- AddressSpaceComposer.Compose + DeploymentArtifact.ParseComposition both build
  the per-equipment reference map from UnsTagReferences + Tags + raw topology and
  substitute VirtualTag AND ScriptedAlarm-predicate sources before dependency
  extraction (runtime dep refs = resolved RawPaths).

Deploy gate + authoring + editor:
- DraftValidator.ValidateEquipReferenceResolution: EquipReferenceUnresolved error
  for unresolved {{equip}}/<RefName> in VirtualTag/ScriptedAlarm scripts + alarm
  message-template tokens (naming script, equipment, missing ref).
- UnsTreeService.ValidateEquipTokenAsync (Wave-A M1): reference-relative authoring
  rejection, replacing the stale DeriveEquipmentBase(empty)->reject-all logic.
- ScriptAnalysisService: {{equip}}/ completion offers the equipment's reference
  effective names; DiagnoseAsync flags unresolved refs (OTSCRIPT_EQUIPREF) same as
  the deploy gate. Requests carry optional EquipmentId (threaded MonacoEditor ->
  monaco-init.js -> fetch bodies). IScriptTagCatalog.GetEquipmentRelativeLeavesAsync
  repurposed to GetEquipmentReferenceNamesAsync(equipmentId, filter).

Tests: EquipmentScriptPaths substitution/extraction (slash pinned); composer<->
artifact reference-resolution byte-parity; DraftValidator unresolved-ref;
ScriptAnalysis completion+diagnostic; M1 authoring gate. Build clean (0/0); all
five affected suites green.

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 06:58:17 -04:00
parent a88dc86173
commit dc0d7653b9
23 changed files with 1142 additions and 426 deletions
@@ -0,0 +1,94 @@
namespace ZB.MOM.WW.OtOpcUa.Commons.Types;
/// <summary>
/// Builds the per-equipment reference map — <c>equipmentId → (effectiveName → backing-tag RawPath)</c> —
/// the single authority both compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>)
/// and the draft validator use to resolve <c>{{equip}}/&lt;RefName&gt;</c> script paths. A reference's
/// <b>effective name</b> is its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>; the
/// RawPath is computed through the shared <see cref="RawPathResolver"/> (the same identity authority the
/// rest of v3 uses), so the entity side (authoring/validation) and the artifact-decode side agree
/// byte-for-byte.
/// <para>Input-shape agnostic + pure: callers flatten their references (EF entities on one side,
/// artifact JSON on the other) into the same primitive tuple + tag lookup, so the join, the
/// effective-name rule, and the collision policy live ONLY here. References are consumed in
/// <c>UnsTagReferenceId</c>-ordinal order and effective-name collisions keep the FIRST — a valid draft
/// has none (the uniqueness validator rejects them), and the deterministic first-wins keeps the two
/// seams parity-stable on any input.</para>
/// </summary>
public static class EquipmentReferenceMap
{
/// <summary>A flattened UNS tag reference: which equipment references which raw tag under an optional override.</summary>
/// <param name="UnsTagReferenceId">Stable logical id — the ordering key for deterministic first-wins.</param>
/// <param name="EquipmentId">The referencing equipment.</param>
/// <param name="TagId">The backing raw tag.</param>
/// <param name="DisplayNameOverride">Optional effective-name override; <see langword="null"/> = use the raw tag's Name.</param>
public readonly record struct ReferenceRow(string UnsTagReferenceId, string EquipmentId, string TagId, string? DisplayNameOverride);
/// <summary>The backing raw tag's identity inputs for its RawPath + effective name.</summary>
/// <param name="Name">The raw tag's leaf name (also the default effective name).</param>
/// <param name="DeviceId">The tag's owning device id.</param>
/// <param name="TagGroupId">The tag's owning tag-group id, or <see langword="null"/> when directly under the device.</param>
public readonly record struct TagRow(string Name, string DeviceId, string? TagGroupId);
/// <summary>
/// Build the reference map. For each reference (ordered by <c>UnsTagReferenceId</c>), resolve the
/// backing tag's RawPath via <paramref name="resolver"/> and key it under the effective name within
/// the owning equipment. References whose backing tag is unknown or whose RawPath cannot be built
/// (broken chain) are skipped. Effective-name collisions within an equipment keep the first.
/// </summary>
/// <param name="references">The flattened UNS tag references (any order; sorted internally).</param>
/// <param name="tagsById">Backing raw tags keyed by <c>TagId</c>.</param>
/// <param name="resolver">The shared RawPath resolver over the raw topology.</param>
/// <returns><c>equipmentId → (effectiveName → RawPath)</c>. Equipments with no resolvable reference are absent.</returns>
public static IReadOnlyDictionary<string, IReadOnlyDictionary<string, string>> Build(
IEnumerable<ReferenceRow> references,
IReadOnlyDictionary<string, TagRow> tagsById,
RawPathResolver resolver)
{
ArgumentNullException.ThrowIfNull(references);
ArgumentNullException.ThrowIfNull(tagsById);
ArgumentNullException.ThrowIfNull(resolver);
var byEquip = new Dictionary<string, Dictionary<string, string>>(StringComparer.Ordinal);
foreach (var r in references.OrderBy(x => x.UnsTagReferenceId, StringComparer.Ordinal))
{
if (string.IsNullOrEmpty(r.EquipmentId) || string.IsNullOrEmpty(r.TagId)) continue;
if (!tagsById.TryGetValue(r.TagId, out var tag)) continue;
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride!;
if (string.IsNullOrEmpty(effectiveName)) continue;
var rawPath = resolver.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name);
if (rawPath is null) continue; // broken chain — dropped (deploy gate rejects invalid names)
if (!byEquip.TryGetValue(r.EquipmentId, out var map))
byEquip[r.EquipmentId] = map = new Dictionary<string, string>(StringComparer.Ordinal);
map.TryAdd(effectiveName, rawPath); // first-wins on collision (validator rejects real collisions)
}
return byEquip.ToDictionary(
kv => kv.Key,
kv => (IReadOnlyDictionary<string, string>)kv.Value,
StringComparer.Ordinal);
}
/// <summary>The set of effective names a single equipment's references contribute (for the authoring
/// gate + Monaco completion, which need only the names — not the RawPaths).</summary>
/// <param name="references">The flattened UNS tag references for (typically) one equipment.</param>
/// <param name="tagNameById">Backing raw tag Name keyed by <c>TagId</c>.</param>
/// <returns>The distinct effective names, ordinal.</returns>
public static IReadOnlySet<string> EffectiveNames(
IEnumerable<ReferenceRow> references,
IReadOnlyDictionary<string, string> tagNameById)
{
ArgumentNullException.ThrowIfNull(references);
ArgumentNullException.ThrowIfNull(tagNameById);
var names = new HashSet<string>(StringComparer.Ordinal);
foreach (var r in references)
{
var effectiveName = r.DisplayNameOverride
?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (!string.IsNullOrEmpty(effectiveName)) names.Add(effectiveName);
}
return names;
}
}
@@ -3,19 +3,29 @@ 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.
/// Helpers for equipment-relative virtual-tag / scripted-alarm script paths. The reserved token
/// <c>{{equip}}/&lt;RefName&gt;</c> inside a <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literal is
/// replaced at the compose seams (<c>AddressSpaceComposer</c> + <c>DeploymentArtifact</c>) with the
/// backing raw tag's <c>RawPath</c>, resolved through the owning equipment's
/// <c>UnsTagReference</c> rows by effective name (<c>&lt;RefName&gt;</c>).
/// <para><b>v3 syntax:</b> the token joint is a <b>slash</b> — <c>{{equip}}/&lt;RefName&gt;</c> — replacing
/// the v2 dot-prefix derivation (<c>{{equip}}.X</c>). <c>&lt;RefName&gt;</c> is a reference's effective name
/// (its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>). An unresolved
/// <c>&lt;RefName&gt;</c> is left un-substituted (substitution never throws); the deploy-time validator +
/// the authoring gate + the Monaco diagnostic reject it first, preserving the invariant
/// <b>the editor accepts ⇔ publish accepts</b>.</para>
/// 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>
/// <summary>The reserved equipment token stem.</summary>
public const string EquipToken = "{{equip}}";
/// <summary>The reserved equipment reference-relative prefix — the token stem plus the slash joint.</summary>
public const string EquipTokenPrefix = "{{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);
@@ -32,6 +42,14 @@ public static class EquipmentScriptPaths
private static readonly Regex PathLiteralRegex =
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")", RegexOptions.Compiled);
// {{equip}}/<RefName> occurrence in FREE TEXT (message templates). <RefName> runs until the next
// delimiter that cannot appear in an intra-literal RawPath segment used inline in a template —
// whitespace, quote, brace, paren, semicolon, or the '/' separator. (Reference effective names may
// contain internal spaces; that space-bearing form is unambiguous only inside a path literal, so the
// free-text scan is best-effort — see ExtractEquipReferenceNamesFromText.)
private static readonly Regex EquipRefTextRegex =
new(@"\{\{equip\}\}/([^\s""{}();/]+)", RegexOptions.Compiled);
// A pure pass-through virtual-tag body: exactly `return ctx.GetTag("<ref>").Value;`
// (whitespace-insensitive). Captures <ref> for relay→alias conversion. Anything with extra
// statements, arithmetic, a different member than .Value, or multiple GetTag calls is NOT a relay.
@@ -46,44 +64,89 @@ public static class EquipmentScriptPaths
!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).
/// Replace each <c>{{equip}}/&lt;RefName&gt;</c> reference-relative path with the backing raw tag's
/// <c>RawPath</c> from <paramref name="referenceMap"/> (effective name → RawPath), inside
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals only. A path literal whose content is
/// <c>{{equip}}/&lt;RefName&gt;</c> is treated as a single reference: <c>&lt;RefName&gt;</c> is the
/// entire remainder after the prefix (so reference effective names may contain internal spaces).
/// Identity when <paramref name="source"/> is null/empty, <paramref name="referenceMap"/> is empty,
/// or the token is absent (so every existing script — none of which use the token — is byte-unchanged).
/// An <c>&lt;RefName&gt;</c> absent from the map is left un-substituted (never throws) — the validator
/// rejects it first at deploy.
/// </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)
/// <param name="source">The script source.</param>
/// <param name="referenceMap">The equipment's reference map: effective name → backing-tag RawPath.</param>
/// <returns>The source with each resolvable <c>{{equip}}/&lt;RefName&gt;</c> substituted inside path literals.</returns>
public static string SubstituteEquipmentToken(string source, IReadOnlyDictionary<string, string> referenceMap)
{
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;
if (string.IsNullOrEmpty(source) || referenceMap is null || referenceMap.Count == 0) return source;
if (!source.Contains(EquipTokenPrefix, StringComparison.Ordinal)) return source;
return PathLiteralRegex.Replace(source, m =>
m.Groups[1].Value
+ SubstituteContent(m.Groups[2].Value, referenceMap)
+ m.Groups[3].Value);
}
// Substitute the reference-relative token inside a single path-literal's content. The content is one
// whole tag path; when it starts with the {{equip}}/ prefix the remainder is the reference name.
private static string SubstituteContent(string content, IReadOnlyDictionary<string, string> referenceMap)
{
if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) return content;
var refName = content.Substring(EquipTokenPrefix.Length);
if (refName.Length == 0) return content;
return referenceMap.TryGetValue(refName, out var rawPath) ? rawPath : content;
}
/// <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).
/// Distinct <c>&lt;RefName&gt;</c> values used via <c>{{equip}}/&lt;RefName&gt;</c> inside
/// <c>ctx.GetTag</c>/<c>ctx.SetVirtualTag</c> path literals, in first-seen order. Scoped to the SAME
/// path literals <see cref="SubstituteEquipmentToken"/> operates on (so a token in a comment / logger
/// string is not reported), and the entire post-prefix remainder is the reference name (matching
/// substitution) — this keeps the validator / authoring gate / Monaco diagnostic in lockstep with what
/// resolves. Feeds the deploy-gate + authoring rejection + editor completion.
/// </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)
/// <param name="scriptSource">The virtual-tag / scripted-alarm predicate script source.</param>
/// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
public static IReadOnlyList<string> ExtractEquipReferenceNames(string? scriptSource)
{
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);
if (string.IsNullOrEmpty(scriptSource)
|| !scriptSource.Contains(EquipTokenPrefix, StringComparison.Ordinal))
return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
foreach (Match m in PathLiteralRegex.Matches(scriptSource))
{
var content = m.Groups[2].Value;
if (!content.StartsWith(EquipTokenPrefix, StringComparison.Ordinal)) continue;
var refName = content.Substring(EquipTokenPrefix.Length);
if (refName.Length > 0 && seen.Add(refName)) result.Add(refName);
}
return result;
}
/// <summary>
/// Distinct <c>&lt;RefName&gt;</c> values used via <c>{{equip}}/&lt;RefName&gt;</c> in FREE TEXT — the
/// scripted-alarm <c>MessageTemplate</c>, which is not a path literal — in first-seen order. Best-effort:
/// a reference name is captured up to the next whitespace / quote / brace / paren / semicolon / slash, so
/// a space-bearing effective name used inline in a template resolves only up to its first space (a
/// documented, benign limitation — template rendering is dark until Batch 4). Feeds the deploy-gate rule
/// for alarm message tokens.
/// </summary>
/// <param name="text">The free text (e.g. a scripted-alarm message template).</param>
/// <returns>Distinct reference names, first-seen order; empty when none / no token.</returns>
public static IReadOnlyList<string> ExtractEquipReferenceNamesFromText(string? text)
{
if (string.IsNullOrEmpty(text)
|| !text.Contains(EquipTokenPrefix, StringComparison.Ordinal))
return Array.Empty<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
var result = new List<string>();
foreach (Match m in EquipRefTextRegex.Matches(text))
{
var refName = m.Groups[1].Value;
if (refName.Length > 0 && seen.Add(refName)) result.Add(refName);
}
return result;
}
/// <summary>
@@ -115,10 +178,11 @@ public static class EquipmentScriptPaths
/// <c>{{equip}}</c> double-brace form is excluded by the token regex. Deterministic so the live
/// composer (<c>AddressSpaceComposer</c>) and the artifact-decode mirror (<c>DeploymentArtifact</c>)
/// produce the exact same ordered list — the byte-parity contract <c>EquipmentScriptedAlarmPlan</c>
/// equality depends on. Scripted alarms do NOT use <c>{{equip}}</c> substitution (only virtual
/// tags do) — pass the predicate source as-is.
/// equality depends on. The predicate source passed here is the ALREADY-substituted source (the two
/// compose seams substitute <c>{{equip}}/&lt;RefName&gt;</c> before extraction, identically), so the
/// merged refs are resolved RawPaths.
/// </summary>
/// <param name="predicateSource">The resolved predicate script source.</param>
/// <param name="predicateSource">The resolved (substituted) predicate script source.</param>
/// <param name="messageTemplate">The alarm message template carrying <c>{TagPath}</c> tokens.</param>
/// <returns>The merged, distinct, deterministically-ordered dependency refs.</returns>
public static IReadOnlyList<string> ExtractAlarmDependencyRefs(string? predicateSource, string? messageTemplate)
@@ -154,7 +218,7 @@ public static class EquipmentScriptPaths
/// <param name="source">The virtual-tag script source to inspect.</param>
/// <param name="tagReference">
/// When the method returns <see langword="true"/>, the captured <c>GetTag</c> path literal
/// (e.g. <c>TestMachine_020.TestChangingInt</c> or <c>{{equip}}.Speed</c>);
/// (e.g. <c>TestMachine_020.TestChangingInt</c> or <c>{{equip}}/Speed</c>);
/// otherwise <see langword="null"/>.
/// </param>
/// <returns>
@@ -39,6 +39,7 @@ public static class DraftValidator
ValidateRawNameCharset(draft, errors);
ValidateHistorizedTagnameLength(draft, errors);
ValidateUnsEffectiveLeafUniqueness(draft, errors);
ValidateEquipReferenceResolution(draft, errors);
ValidateCalculationTags(draft, errors);
return errors;
}
@@ -238,6 +239,64 @@ public static class DraftValidator
}
}
/// <summary>v3: every <c>{{equip}}/&lt;RefName&gt;</c> used in an equipment's VirtualTag script,
/// ScriptedAlarm predicate script, or ScriptedAlarm message template must resolve to one of the owning
/// equipment's <c>UnsTagReference</c> effective names (<c>DisplayNameOverride</c> else the backing raw
/// tag's <c>Name</c>) — the same set the compose seams substitute against. An unresolved <c>&lt;RefName&gt;</c>
/// is a deploy error (replacing the v2 silent null-base no-substitution), naming the script/alarm, the
/// equipment, and the missing ref. This is the deploy-gate half of the invariant the Monaco diagnostic +
/// the authoring gate enforce (editor accepts ⇔ publish accepts).</summary>
private static void ValidateEquipReferenceResolution(DraftSnapshot draft, List<ValidationError> errors)
{
var tagNameById = draft.Tags.GroupBy(t => t.TagId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().Name, StringComparer.Ordinal);
// equipmentId → set of reference effective names ({{equip}}/<RefName> resolves through REFERENCES
// only — VirtualTags/ScriptedAlarms are computed signals with no backing RawPath).
var refNamesByEquip = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
foreach (var r in draft.UnsTagReferences)
{
var effective = r.DisplayNameOverride ?? (tagNameById.TryGetValue(r.TagId, out var n) ? n : null);
if (string.IsNullOrEmpty(effective)) continue;
if (!refNamesByEquip.TryGetValue(r.EquipmentId, out var set))
refNamesByEquip[r.EquipmentId] = set = new HashSet<string>(StringComparer.Ordinal);
set.Add(effective!);
}
var scriptSourceById = draft.Scripts.GroupBy(s => s.ScriptId, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.First().SourceCode, StringComparer.Ordinal);
void CheckRefs(string equipmentId, IEnumerable<string> refNames, string source)
{
var have = refNamesByEquip.TryGetValue(equipmentId, out var set) ? set : null;
foreach (var name in refNames)
{
if (have is not null && have.Contains(name)) continue;
errors.Add(new("EquipReferenceUnresolved",
$"{source} uses '{EquipmentScriptPaths.EquipTokenPrefix}{name}' but equipment '{equipmentId}' has " +
$"no reference named '{name}'; add a reference with that effective name or correct the script.",
equipmentId));
}
}
foreach (var v in draft.VirtualTags)
{
var src = scriptSourceById.TryGetValue(v.ScriptId, out var s) ? s : null;
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src);
if (used.Count > 0) CheckRefs(v.EquipmentId, used, $"VirtualTag '{v.VirtualTagId}'");
}
foreach (var a in draft.ScriptedAlarms)
{
var src = scriptSourceById.TryGetValue(a.PredicateScriptId, out var s) ? s : null;
var used = EquipmentScriptPaths.ExtractEquipReferenceNames(src)
.Concat(EquipmentScriptPaths.ExtractEquipReferenceNamesFromText(a.MessageTemplate))
.Distinct(StringComparer.Ordinal)
.ToList();
if (used.Count > 0) CheckRefs(a.EquipmentId, used, $"ScriptedAlarm '{a.ScriptedAlarmId}'");
}
}
private static bool IsValidSegment(string? s) =>
s is not null && (UnsSegment.IsMatch(s) || s == UnsDefaultSegment);
@@ -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);
@@ -866,29 +866,45 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
/// <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,