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>