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
23 KiB
Monaco Script Editor (Roslyn-backed IntelliSense)
The script editor gives AdminUI users a first-class C# authoring experience for virtual-tag scripts: completions, diagnostics, hover, signature help, document formatting, and context-aware tag-path completions — all backed by the same Roslyn compiler the runtime publish gate uses.
See the design doc for the original rationale and brainstorming record:
docs/plans/2026-06-09-monaco-script-editor-design.md.
Overview
Virtual-tag scripts are C# expression bodies (see VirtualTags.md)
compiled at publish time by ScriptEvaluator against a restricted ScriptSandbox.
Before this feature the AdminUI's script page used a CDN Monaco instance wired
over a hidden textarea via an eval + Task.Delay(50) race — no IntelliSense,
fragile in air-gapped environments, and the editor accepted code that publish
would reject.
The replacement:
- Vendored Monaco served locally — no CDN, air-gap safe.
- Reusable
MonacoEditor.razorcomponent wired into both the ScriptEdit page (/scripts/{id}) and the virtual-tag inline script panel in the UNS TagModal. - Full IntelliSense via a Roslyn backend that analyses the user's source inside
the exact evaluator wrapper
ScriptEvaluatorcompiles, so what the editor accepts/rejects matches publish exactly.
Architecture
┌──────────────────────────── AdminUI (Blazor Server) ───────────────────────────┐
│ │
│ Components/Shared/MonacoEditor.razor ◄── Value/ValueChanged, Height, │
│ │ ReadOnly, ShowToolbar, theme/Format/wrap/minimap toolbar │
│ │ (DotNetObjectReference both ways) │
│ ▼ │
│ wwwroot/js/monaco-init.js ──registers──► completion / hover / signatureHelp / │
│ │ diagnostics / format / inlayHints │
│ │ fetch POST /api/script-analysis/* │
│ ▼ │
│ ScriptAnalysis/ScriptAnalysisEndpoints.cs (minimal-API group, FleetAdmin) │
│ ▼ │
│ ScriptAnalysis/ScriptAnalysisService.cs ── Roslyn over OtOpcUa's real wrapper │
│ ├─ ScriptSandbox.Build(typeof(VirtualTagContext)) (imports + refs) │
│ ├─ CompiledScript.Run(ScriptGlobals<VirtualTagContext>) wrapper │
│ ├─ ForbiddenTypeAnalyzer (sandbox-escape squiggles) │
│ ├─ DependencyExtractor (dynamic-path squiggles) │
│ └─ IScriptTagCatalog (tag-path string-literal completions) │
│ ▼ │
│ wwwroot/lib/monaco/vs/… (vendored Monaco, loaded by monaco-init.js) │
└─────────────────────────────────────────────────────────────────────────────────┘
│ references
▼
Core.Scripting + Scripting.Abstractions (ScriptSandbox, VirtualTagContext,
ScriptGlobals<>, ForbiddenTypeAnalyzer, DependencyExtractor)
Configuration (OtOpcUaConfigDbContext → tag / virtual-tag paths for catalog)
MonacoEditor.razor
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor
A reusable Blazor Server component. Key parameters:
| Parameter | Type | Notes |
|---|---|---|
Value |
string |
Two-way bound script source |
ValueChanged |
EventCallback<string> |
Fires on every model change |
Height |
string |
CSS height (default 400px) |
ReadOnly |
bool |
Disables editing; IntelliSense still works |
ShowToolbar |
bool |
Shows the theme/Format/wrap/minimap toolbar |
The component creates the Monaco editor in OnAfterRenderAsync, registers a
DotNetObjectReference so JS can call back into Blazor on value change, and
uses a GUID-keyed container so multiple instances on the same page coexist safely.
External Value changes are pushed to the editor via window.MonacoBlazor.setValue.
monaco-init.js
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js
Exposes window.MonacoBlazor with createEditor / setValue / getValue / setMarkers / setEditorOption / format / dispose. Registers the six Monaco
language providers for the csharp language at load time; each provider POSTs
to the corresponding /api/script-analysis/* endpoint. Diagnostics are
debounced (~500 ms) to avoid firing on every keystroke.
ScriptAnalysisService
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
The analysis seam is the private Analyze(userSource) method, which:
- Builds the wrapped document — the user's source is appended after the
OtOpcUa evaluator preamble (usings from
ScriptSandbox.Build, theCompiledScript.Run(ScriptGlobals<VirtualTagContext>)wrapper,var ctx = globals.ctx;, then#line 1so Roslyn reports user-source coordinates for diagnostics). Return type isobjectso one shared script is valid regardless of any virtual tag'sDataType. - Parses the wrapped document as a
CSharpSyntaxTreeand creates aCSharpCompilationagainst theScriptSandboxreference set. - Returns the
SemanticModel,SyntaxTree, and the preamble byte offset (used by every capability to map editor coordinates to wrapped-document offsets).
HTTP Endpoints
All six endpoints live under the /api/script-analysis group, registered by
ScriptAnalysisEndpoints.MapScriptAnalysisEndpoints() (called from
EndpointRouteBuilderExtensions.AddAdminUI / Host/Program.cs) and gated by
the FleetAdmin authorization policy (requires the Administrator role).
| Route | Request DTO | Response DTO | Purpose |
|---|---|---|---|
POST /api/script-analysis/diagnostics |
DiagnoseRequest(Code) |
DiagnoseResponse(Markers) |
Roslyn + ForbiddenType + dynamic-path error markers |
POST /api/script-analysis/completions |
CompletionsRequest(CodeText, Line, Column) |
CompletionsResponse(Items) |
Scope, dot-member, and tag-path completions |
POST /api/script-analysis/hover |
HoverRequest(CodeText, Line, Column) |
HoverResponse(Markdown?) |
Type + XML doc markdown |
POST /api/script-analysis/signature-help |
SignatureHelpRequest(CodeText, Line, Column) |
SignatureHelpResponse(Label?, Parameters?, ActiveParameter) |
Parameter help |
POST /api/script-analysis/format |
FormatRequest(Code) |
FormatResponse(Code) |
NormalizeWhitespace() formatting |
POST /api/script-analysis/inlay-hints |
InlayHintsRequest(Code) |
InlayHintsResponse(Hints) |
Inline type hints (stub — returns empty) |
DTO definitions live in ScriptAnalysis/ScriptAnalysisContracts.cs.
DiagnosticMarker fields: Severity (Monaco values: 8 = error, 4 = warning,
2 = info, 1 = hint), StartLineNumber, StartColumn, EndLineNumber,
EndColumn, Message, Code.
CompletionItem fields: Label, InsertText, Detail, Kind (Monaco kind
string: "Method", "Property", "Field", "Variable", "Class", etc.),
InsertTextRules (0 for plain text, 4 for snippet).
Diagnostics Composition
The diagnostics endpoint combines three sources and returns errors only:
1. Roslyn compile diagnostics
compilation.GetDiagnostics() filtered to DiagnosticSeverity.Error. Warnings
are intentionally excluded: the canonical passthrough pattern
return ctx.GetTag("X").Value; triggers nullable warning CS8605 under
NullableContextOptions.Enable but compiles and publishes fine — flagging it in
the editor would be misleading noise that does not reflect publish reality.
Line numbers are read from GetMappedLineSpan() (not GetLineSpan()).
GetMappedLineSpan honours the #line 1 directive in the preamble, so it
returns user-source coordinates directly. Diagnostics whose
SourceSpan.Start < preambleLength are dropped — they are phantom wrapper
diagnostics (e.g. CS0161 "not all code paths return a value" on the synthesized
Run method body when the user has not yet typed return) and are not the
user's fault.
2. ForbiddenTypeAnalyzer
ForbiddenTypeAnalyzer.Analyze(compilation) walks the wrapped syntax tree and
resolves every referenced symbol against the sandbox deny-list (namespace
prefixes System.IO, System.Net, System.Diagnostics, System.Reflection,
System.Threading.Tasks, etc., plus type-granular denials like
System.Environment and System.Threading.Thread). Each violation is mapped
via tree.GetMappedLineSpan(span) to user-source coordinates and emitted as an
error marker with code OTSCRIPT_FORBIDDEN.
3. DependencyExtractor
DependencyExtractor.Extract(code).Rejections enumerates every ctx.GetTag /
ctx.SetVirtualTag call whose first argument is NOT a string literal (variable,
concatenation, interpolation). Each rejection carries a TextSpan in
user-source coordinates, emitted as code OTSCRIPT_DYNPATH. This is the exact
same check publish enforces — the editor shows the squiggle where the literal
must appear.
Tag-Path Completion
When the caret is inside the first string-literal argument of a GetTag("…") or
SetVirtualTag("…") invocation, the completion provider delegates to
IScriptTagCatalog instead of the semantic model.
IScriptTagCatalog contract
ScriptAnalysis/IScriptTagCatalog.cs
GetPathsAsync(string? filter, CancellationToken) returns the distinct
runtime-resolvable keys a script may pass to ctx.GetTag /
ctx.SetVirtualTag, optionally filtered by a case-insensitive StartsWith
prefix. The concrete implementation ScriptTagCatalog reads Tag +
VirtualTag rows from OtOpcUaConfigDbContext and projects them as follows:
| Tag category | Resolvable key emitted |
|---|---|
Equipment driver tag (EquipmentId != null) |
Driver FullName extracted from Tag.TagConfig JSON — the verified DependencyMux key |
SystemPlatform / Galaxy tag (EquipmentId == null) |
MXAccess dot-ref: FolderPath.Name when a folder is set, else Name |
| Virtual tag | Leaf Name (best-effort) |
Why only resolvable keys? The live runtime resolves a ctx.GetTag("X")
literal against DriverInstanceActor.AttributeValuePublished.FullReference,
which is the FullName field from Tag.TagConfig
(see Phase7Composer.ExtractTagFullName + EquipmentNodeWalker.ExtractFullName).
The UNS-path engine (Core.VirtualTags.VirtualTagEngine, keyed by the
slash-joined Enterprise/Site/Area/Line/Equipment/TagName browse path) is
dormant — it is not wired into the host — so UNS browse paths never resolve at
runtime and are intentionally NOT offered as completions.
The catalog is scoped per Blazor circuit; each call creates and disposes its own
DbContext via the pooled factory (same pattern as UnsTreeService). Results
are bounded to 200 entries to keep the completion list responsive on large fleets.
Literal gate
TryGetTagPathLiteral identifies the tag-path context by climbing from the
syntax token under the caret to: LiteralExpressionSyntax → ArgumentSyntax
(first argument only) → ArgumentListSyntax → InvocationExpressionSyntax →
MemberAccessExpressionSyntax. It then requires (a) the method name is
GetTag or SetVirtualTag, and (b) the receiver is the identifier ctx —
a purely syntactic check that deliberately mirrors the runtime dependency
harvest (EquipmentScriptPaths.GetTagRefRegex is ctx-anchored), so the editor
offers tag-path completion/hover for exactly what Phase7Composer harvests at
deploy time. An unrelated anything.GetTag("…") no longer triggers it.
SetVirtualTag is a no-op in production. The live single-tag evaluator
(RoslynVirtualTagEvaluator) drops cross-tag ctx.SetVirtualTag(...) writes
(logged); the cascade VirtualTagEngine that honours them is dormant (not wired
into the host). So hover on a ctx.SetVirtualTag("…") path literal appends a
note that the write is dropped in single-tag mode and will not take effect at
runtime — the path completions still help author the literal, but the author is
not misled into relying on a no-op. Making SetVirtualTag functional would
require wiring the cascade engine into the host (out of scope).
Vendoring / Air-gap Safety
Monaco is served from
_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/monaco/vs/ (the Blazor static-asset
path for the AdminUI RCL). The loader script is at
_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js. No CDN is contacted at
runtime. The legacy CDN loader (monaco-loader.js) and the eval/Task.Delay
injection previously in ScriptEdit.razor have been removed.
Wire-in Points
ScriptEdit page (/scripts/{id})
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor
Hosts <MonacoEditor @bind-Value="_form.SourceCode">. On save, SHA-256 is
recomputed from the editor value and stored in Script.SourceHash — unchanged
from the pre-Monaco save path.
VirtualTagModal (/uns TagModal for virtual tags)
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor
When the selected script has a ScriptId, an inline "Script source" panel
renders <MonacoEditor> bound to the script source. The panel shows a
"Used by N virtual tag(s)" notice (populated from
IUnsTreeService.CountVirtualTagsUsingScriptAsync). A dedicated Save script
button calls IUnsTreeService.UpdateScriptSourceAsync, which performs a
RowVersion-guarded update of the Script row — a separate concurrency unit
from the virtual-tag Create/Save operation. Creating a brand-new script inline
from the modal is a follow-up (not v1).
Three IUnsTreeService methods back the inline panel:
GetScriptSourceAsync / CountVirtualTagsUsingScriptAsync /
UpdateScriptSourceAsync.
How to Extend
Adding a new completion source
- Implement a new case in
ScriptAnalysisService.CompleteAsync. TheAnalyze(code)seam gives you theSemanticModel,SyntaxTree, and preamble offset. - Add the corresponding request/response DTOs to
ScriptAnalysisContracts.csif a new endpoint is needed, or fold into an existing endpoint with an extra field. - Register the new Monaco provider in
monaco-init.jsusing the samefetch('/api/script-analysis/…', { method: 'POST', … })pattern.
Adding a new analysis check (diagnostics)
Extend Diagnose in ScriptAnalysisService. The existing three-source pattern
(Roslyn → ForbiddenTypeAnalyzer → DependencyExtractor) is additive: add a
fourth source and append to markers. Keep severity at 8 (Monaco error) for
anything that blocks publish; use 4 (warning) for advisory-only hints.
Changing the tag-path catalog source
Replace or extend the ScriptTagCatalog implementation of IScriptTagCatalog.
Register the replacement in EndpointRouteBuilderExtensions.AddAdminUI
(or inject via the DI container as the IScriptTagCatalog scoped service).
Equipment-relative tag paths ({{equip}})
Why
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 (hard-coded RawPath):
// Script "Calc_TestMachine_001" — hard-coded, cannot reuse
return ctx.GetTag("Cell1/Modbus/Dev1/Speed").Value;
After — one shared template:
// Script "Calc_Speed" — works for any machine
return ctx.GetTag("{{equip}}/Speed").Value;
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 (v3)
- The token joint is a slash:
{{equip}}/<RefName>(double-brace stem, lowercase).<RefName>is a reference effective name — the reference'sDisplayNameOverride, else the backing raw tag'sName. - It is substituted only inside
ctx.GetTag(…)/ctx.SetVirtualTag(…)first-argument string literals — comments, logger strings, and other code are untouched. - 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'sUnsTagReferencerows (by effective name) to the backing raw tag'sRawPath. Example: a reference namedSpeedbackingCell1/Modbus/Dev1/Speed→ the token expands toCell1/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 (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}}/<RefName>path literal shows an "Equipment-relative path — resolved through the equipment's tag reference at deploy" note. - Completions — typing
{{equip}}/inside aGetTag/SetVirtualTagliteral 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 — 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.
Known Limitations / Follow-ups
- Tag-path completion shows resolvable keys only. Surfacing the UNS browse path as a completion detail (a non-inserted hint shown alongside the resolvable key) for discoverability is a tracked follow-up.
- Literal gate matches by method name, not receiver.
anything.GetTag("…")would also trigger tag-path completion (not justctx.GetTag("…")). Harmless in the sandbox today — no otherGetTagmethods exist in the allowed reference set — but tighter binding toctx/VirtualTagContextis a follow-up. - Analysis runs synchronous Roslyn (CPU-bound). Each endpoint call builds a
CSharpCompilationsynchronously. This is intentional: the endpoints are admin-only, client-side debounced (~500 ms), andTask.Runoffload adds unnecessary complexity for infrequent admin use. If concurrency becomes a concern under heavy admin usage, wrapping eachAnalyzecall inTask.Runis the correct fix. - InlayHints is a stub.
/api/script-analysis/inlay-hintsreturns an empty list. Filling it in (parameter name hints from the semantic model) is a follow-up task. - Language providers register globally at Monaco load. The six providers are
registered for the
csharplanguage on the global Monaco instance (loaded once per page inApp.razor). There is no impact today because onlyAdministrator-reachable pages host aMonacoEditor, but if a read-only surface ever embeds the editor the providers will fire (harmlessly — they just get 401 responses from theFleetAdmin-gated endpoints and return empty results). - Creating a new script from the VirtualTagModal inline panel is deferred.
The current inline panel loads and saves an existing
Scriptrow; creating a brand-new script requires a separate create flow and is a follow-up.
Testing
Unit tests live in
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/:
| File | Covers |
|---|---|
DiagnoseTests.cs |
Clean script (no markers), Roslyn error, ForbiddenTypeAnalyzer violation, dynamic-path rejection, #line mapping |
CompletionTests.cs |
Scope completions, dot-member completions after ctx. |
TagPathCompletionTests.cs |
Tag-path literal completions inside GetTag/SetVirtualTag via a fake IScriptTagCatalog |
HoverSignatureTests.cs |
Hover markdown, signature-help parameter tracking |
FormatTests.cs |
NormalizeWhitespace round-trip |
ScriptTagCatalogTests.cs |
Equipment-tag FullName, Galaxy dot-ref, virtual-tag leaf Name, filter prefix, MaxResults bound |
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs
covers GetScriptSourceAsync / CountVirtualTagsUsingScriptAsync /
UpdateScriptSourceAsync via the in-memory EF pattern.
No bUnit tests exist for MonacoEditor.razor or VirtualTagModal.razor — the
AdminUI has no bUnit dependency. Razor/JS correctness was proven by live
docker-dev /run (ScriptEdit + UNS TagModal with Monaco, diagnostics, hover,
tag-path completions, Format, inline script save).