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:
@@ -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}}/<RefName></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}}/<RefName></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><RefName></c>).
|
||||
/// <para><b>v3 syntax:</b> the token joint is a <b>slash</b> — <c>{{equip}}/<RefName></c> — replacing
|
||||
/// the v2 dot-prefix derivation (<c>{{equip}}.X</c>). <c><RefName></c> is a reference's effective name
|
||||
/// (its <c>DisplayNameOverride</c> else the backing raw tag's <c>Name</c>). An unresolved
|
||||
/// <c><RefName></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}}/<RefName></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}}/<RefName></c> is treated as a single reference: <c><RefName></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><RefName></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}}/<RefName></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><RefName></c> values used via <c>{{equip}}/<RefName></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><RefName></c> values used via <c>{{equip}}/<RefName></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}}/<RefName></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}}/<RefName></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><RefName></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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user