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
@@ -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,