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
+52 -40
View File
@@ -307,76 +307,88 @@ Register the replacement in `EndpointRouteBuilderExtensions.AddAdminUI`
### Why
Today each VirtualTag bound to a script typically needs its own near-duplicate
script because tag paths are hard-coded absolutes (e.g. `TestMachine_001.Speed`).
The `{{equip}}` token breaks this coupling: point many VirtualTags' `ScriptId` at
a single template script, and each resolves the token to its own equipment's tag
base prefix at deploy time. No schema change is required — sharing a `Script`
record across VirtualTags already works; `{{equip}}` is what makes the shared
script resolve per-equipment.
Each VirtualTag bound to a script would otherwise need its own near-duplicate
script because tag paths are hard-coded absolute RawPaths (e.g.
`Cell1/Modbus/Dev1/Speed`). The `{{equip}}/<RefName>` token breaks this coupling:
point many VirtualTags' `ScriptId` at a single template script, and each resolves
the token through **its own equipment's tag references** at deploy time. No schema
change is required — sharing a `Script` record across VirtualTags already works;
`{{equip}}/<RefName>` is what makes the shared script resolve per-equipment.
### Before / after
**Before — one script per machine:**
**Before — one script per machine (hard-coded RawPath):**
```csharp
// Script "Calc_TestMachine_001" — hard-coded, cannot reuse
return ctx.GetTag("TestMachine_001.Speed").Value;
return ctx.GetTag("Cell1/Modbus/Dev1/Speed").Value;
```
**After — one shared template:**
```csharp
// Script "Calc_Speed" — works for any machine
return ctx.GetTag("{{equip}}.Speed").Value;
return ctx.GetTag("{{equip}}/Speed").Value;
```
`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`.
At deploy, each VirtualTag receives its own expanded copy:
`TestMachine_001.Speed` and `TestMachine_002.Speed` respectively.
`TestMachine_001` and `TestMachine_002` both bind `ScriptId = "Calc_Speed"`, and
each has a **tag reference** whose effective name is `Speed`. At deploy, each
VirtualTag receives its own expanded copy — `{{equip}}/Speed` resolves to that
equipment's referenced raw tag's RawPath.
### Token rules
### Token rules (v3)
- The token is `{{equip}}` (double braces, lowercase).
- The token joint is a **slash**: `{{equip}}/<RefName>` (double-brace stem,
lowercase). `<RefName>` is a **reference effective name** — the reference's
`DisplayNameOverride`, else the backing raw tag's `Name`.
- It is substituted **only inside `ctx.GetTag(…)` / `ctx.SetVirtualTag(…)` first-argument
string literals** — comments, logger strings, and other code are untouched.
- The operator writes the separator and tail: `ctx.GetTag("{{equip}}.Speed")`,
`ctx.GetTag("{{equip}}.Sub.Field")`, etc.
- The token expands to the equipment's **tag base prefix** — the common
substring-before-the-first-dot of that equipment's configured driver-tag
`FullName` values. Example: tags `TestMachine_001.Speed` and
`TestMachine_001.Temp` → base `TestMachine_001`.
- The whole post-prefix literal content is the reference name, so effective names
with internal spaces are supported: `ctx.GetTag("{{equip}}/Motor Speed")`.
- `{{equip}}/<RefName>` **resolves through the owning equipment's `UnsTagReference`
rows** (by effective name) to the backing raw tag's `RawPath`. Example: a
reference named `Speed` backing `Cell1/Modbus/Dev1/Speed` → the token expands to
`Cell1/Modbus/Dev1/Speed`.
- Scripted-alarm predicate scripts resolve `{{equip}}/<RefName>` the same way; alarm
**message-template** tokens follow the same resolution rule.
### Validation requirement
Saving a VirtualTag that is bound to a `{{equip}}`-using script is rejected in
the AdminUI if the equipment does not have at least one configured driver tag, or
if its tags span multiple object prefixes (i.e. the common prefix is ambiguous or
absent). The rejection message is surfaced as a clear validation error on the save
form. This check is enforced eagerly so that an unresolved `{{equip}}` token —
which would leave a path that resolves to nothing at runtime (Bad quality) — can
never reach the deployed artifact.
Saving a VirtualTag (or ScriptedAlarm) whose script uses `{{equip}}/<RefName>` is
rejected in the AdminUI when `<RefName>` is not one of the owning equipment's
reference effective names. The same rule runs at the deploy gate
(`DraftValidator.ValidateEquipReferenceResolution`, error code
`EquipReferenceUnresolved`), which also catches a **rename-induced** breakage the
authoring check never saw. The rejection names the script/alarm, the equipment, and
the missing ref — so an unresolved token can never reach the deployed artifact.
### Editor support
In the Monaco script editor (ScriptEdit page and the `/uns` virtual-tag modal's
inline script panel):
- **Hover** — hovering a `{{equip}}` path literal shows an
*"Equipment-relative path — resolved at deploy"* note.
- **Completions** — typing `{{equip}}.` inside a `GetTag`/`SetVirtualTag` literal
offers completion of attribute leaf names (the part after the first dot of known
tag references in the catalog).
- **Hover** — hovering a `{{equip}}/<RefName>` path literal shows an
*"Equipment-relative path — resolved through the equipment's tag reference at
deploy"* note.
- **Completions** — typing `{{equip}}/` inside a `GetTag`/`SetVirtualTag` literal
offers the owning equipment's **reference effective names** (needs an equipment
context; inert on the shared ScriptEdit page).
- **Diagnostics** — an unresolved `{{equip}}/<RefName>` is flagged (`OTSCRIPT_EQUIPREF`)
identically to the deploy gate, preserving *editor accepts ⇔ publish accepts*.
### Maintainer note
Substitution runs at the two compose seams —
`Phase7Composer.Compose` and `DeploymentArtifact.BuildEquipmentVirtualTagPlans`
— via the shared `ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths` helper,
**before** dependency extraction. The runtime, the static change-trigger
dependency graph, and the literal-only path rule are therefore all unchanged:
by the time they see the script, `{{equip}}` has been replaced with a concrete
tag-base prefix and the path is a normal string literal.
Substitution runs at the two compose seams — `AddressSpaceComposer.Compose` and
`DeploymentArtifact.ParseComposition` — via the shared
`ZB.MOM.WW.OtOpcUa.Commons.Types.EquipmentScriptPaths.SubstituteEquipmentToken`
helper, fed the equipment's reference map (effective name → RawPath) built by
`EquipmentReferenceMap` over the shared `RawPathResolver`. Substitution runs
**before** dependency extraction, so the runtime, the static change-trigger
dependency graph, and the literal-only path rule are unchanged: by the time they
see the script, `{{equip}}/<RefName>` has been replaced with a concrete RawPath and
the path is a normal string literal. An unresolved ref is left un-substituted (the
validator rejects it first — substitution never throws). The two seams build the
reference map identically, so their plans stay byte-parity.
---