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
@@ -130,6 +130,58 @@ public sealed class ScriptAnalysisService
}
}
// ctx.GetTag("…") / ctx.SetVirtualTag("…") first-arg literal — three-part capture, mirroring
// EquipmentScriptPaths.PathLiteralRegex so the editor's {{equip}}/<RefName> diagnostic is scoped to the
// SAME path literals the compose seams substitute (never a comment / logger string).
private static readonly System.Text.RegularExpressions.Regex EquipPathLiteralRegex =
new(@"(ctx\s*\.\s*(?:GetTag|SetVirtualTag)\s*\(\s*"")([^""]*)("")",
System.Text.RegularExpressions.RegexOptions.Compiled);
/// <summary>Diagnostics plus the v3 equipment-relative <c>{{equip}}/&lt;RefName&gt;</c> resolution check.
/// Runs the synchronous Roslyn/policy <see cref="Diagnose"/> then, when an equipment context + catalog
/// are present, appends a marker for each <c>{{equip}}/&lt;RefName&gt;</c> whose <c>&lt;RefName&gt;</c> is
/// not one of the equipment's reference effective names — flagging exactly what the deploy gate
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req)
{
var baseResp = Diagnose(req);
if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code))
return baseResp;
try
{
var code = Normalize(req.Code);
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
return baseResp;
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None);
var resolvable = new HashSet<string>(names, StringComparer.Ordinal);
var markers = new List<DiagnosticMarker>(baseResp.Markers);
foreach (System.Text.RegularExpressions.Match m in EquipPathLiteralRegex.Matches(code))
{
var content = m.Groups[2].Value;
if (!content.StartsWith(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal)) continue;
var refName = content.Substring(EquipmentScriptPaths.EquipTokenPrefix.Length);
if (refName.Length == 0 || resolvable.Contains(refName)) continue;
// Mark the whole literal content span (the {{equip}}/<RefName> path).
var span = new Microsoft.CodeAnalysis.Text.TextSpan(m.Groups[2].Index, content.Length);
markers.Add(ToUserMarker(code, span,
$"'{EquipmentScriptPaths.EquipTokenPrefix}{refName}' does not resolve — this equipment has no " +
$"reference named '{refName}'. Add a matching reference or correct the path.",
"OTSCRIPT_EQUIPREF"));
}
return new DiagnoseResponse(markers.Distinct().ToList());
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers.");
return baseResp;
}
}
private static int Sev(DiagnosticSeverity s) => s switch
{
DiagnosticSeverity.Error => 8,
@@ -173,13 +225,17 @@ public sealed class ScriptAnalysisService
if (_catalog != null && TryGetTagPathLiteral(token, out var pathPrefix, out _))
{
const string equipDot = EquipmentScriptPaths.EquipToken + "."; // "{{equip}}."
if (pathPrefix.StartsWith(equipDot, StringComparison.Ordinal))
// v3: {{equip}}/<RefName> completes the owning equipment's UnsTagReference effective names
// (needs the equipment context — inert on the shared ScriptEdit page where EquipmentId is null).
const string equipPrefix = EquipmentScriptPaths.EquipTokenPrefix; // "{{equip}}/"
if (pathPrefix.StartsWith(equipPrefix, StringComparison.Ordinal))
{
var leaves = await _catalog.GetEquipmentRelativeLeavesAsync(
pathPrefix.Substring(equipDot.Length), CancellationToken.None);
return new CompletionsResponse(leaves
.Select(n => new CompletionItem(equipDot + n, equipDot + n, "tag path", "Field"))
if (string.IsNullOrEmpty(req.EquipmentId))
return new CompletionsResponse(Array.Empty<CompletionItem>());
var names = await _catalog.GetEquipmentReferenceNamesAsync(
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None);
return new CompletionsResponse(names
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
.ToList());
}
@@ -281,7 +337,8 @@ public sealed class ScriptAnalysisService
{
return new HoverResponse(
$"**Equipment-relative path** `{Code(tagPath)}`\n\n" +
"The {{equip}} token is replaced with the owning equipment's tag base when the VirtualTag is deployed.");
"`{{equip}}/<RefName>` resolves through the owning equipment's tag reference " +
"(by effective name) to the backing raw tag's path when the VirtualTag is deployed.");
}
var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);