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