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
@@ -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);
}
}