Files
lmxopcua/docs/ScriptEditor.md
T
Joseph Doherty 0796928504
v2-ci / unit-tests (push) Waiting to run
feat(scripting): batch submit, exact per-item deadlines, scripted-alarm seam on the out-of-process runner (lmxopcua#780)
- IScriptRunner.RunBatch: one EvalBatch frame per worker for every evaluation ready to run; per-item, streamed results.
  The default (InProcessScriptRunner) is RunAsync per item. Used by the Calculation pump (dequeue all, split at a
  repeated tag, publish in dequeue order), the VirtualTag seam (ScriptBatchCoalescer, no timer) and the scripted-alarm
  engine (whole dirty set). In process the pump and the engine stay one at a time, unchanged.
- Exact per-item deadlines: the worker hands work to an idle evaluation thread or defers it (EvalResults
  deferred_eval_ids); the host stops that run's clock and restarts T then G when it starts (started_eval_ids). A run
  queued behind a runaway is neither timed out nor struck and is a victim, not the culprit, at the kill.
- Scripted-alarm seam behind Scripting:Runner:Seams; unset Seams now means VirtualTag + Calculation, so alarms run out of
  process only when listed. Load still compiles each predicate (to PE bytes only) so a broken one fails the load; passes
  batch and apply in the serial order under the gate. Script faults freeze the condition BadInternalError; runner faults
  freeze it BadResourceUnavailable and re-evaluate on recovery; a dead worker never clears an alarm. IScriptRunnerProvider
  carries the runner to DriverHostActor.
- Perf harness: batch throughput (RunBatch vs RunAsync x n) and alarm-pass latency (in process / batched / serial);
  design §12.3 records macOS + windev. On windev the serial out-of-process alarm pass misses 250 ms from ~500 alarms;
  batched p99 <= 53 ms at 1,000.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-10 06:37:36 -05:00

30 KiB
Raw Blame History

Monaco Script Editor (Roslyn-backed IntelliSense)

Status lives in Gitea. Open follow-ups and known gaps noted in this doc are tracked as issues on gitea.dohertylan.com/dohertj2/lmxopcua (label migrated, 2026-11-06). This doc describes the system — do not track status here; file new work as an issue.

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.razor component 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 ScriptEvaluator compiles, 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                 │
│        │  fetch POST /api/script-analysis/*                                     │
│        ▼                                                                        │
│  ScriptAnalysis/ScriptAnalysisEndpoints.cs  (minimal-API group, ConfigEditor)   │
│        ▼                                                                        │
│  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 four Monaco language providers (completion, hover, signature help, document formatting) 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:

  1. Builds the wrapped document — the user's source is appended after the OtOpcUa evaluator preamble (usings from ScriptSandbox.Build, the CompiledScript.Run(ScriptGlobals<VirtualTagContext>) wrapper, var ctx = globals.ctx;, then #line 1 so Roslyn reports user-source coordinates for diagnostics). Return type is object so one shared script is valid regardless of any virtual tag's DataType.
  2. Parses the wrapped document as a CSharpSyntaxTree and creates a CSharpCompilation against the ScriptSandbox reference set.
  3. Returns the SemanticModel, SyntaxTree, and the preamble byte offset (used by every capability to map editor coordinates to wrapped-document offsets).

HTTP Endpoints

All five endpoints live under the /api/script-analysis group, registered by ScriptAnalysisEndpoints.MapScriptAnalysisEndpoints() (called from EndpointRouteBuilderExtensions.AddAdminUI / Host/Program.cs) and gated by the ConfigEditor authorization policy (AdminUiPolicies.ConfigEditor: Administrator or Designer).

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

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 — the whole namespace, with only CancellationToken, Interlocked and Volatile allowed back in — System.Threading.Tasks, System.Runtime.InteropServices, System.Runtime.Loader, Microsoft.Win32; type-granular denials System.Environment, System.AppDomain, System.GC, System.Activator, System.Runtime.CompilerServices.Unsafe / RuntimeHelpers; and member-granular denials on System.Type's by-name reflection entry points — GetType(string), GetMethod(s), InvokeMember, and the GetProperty/GetField/GetMember/GetConstructor/GetEvent families). The runtime publish gate uses the same analyzer, so a deny-list change needs a DiagnoseTests case to keep editor ⇔ publish parity. Each violation is mapped via tree.GetMappedLineSpan(span) to user-source coordinates and emitted as an error marker with code OTSCRIPT_FORBIDDEN.

Runtime safety (what happens after publish)

The editor cannot catch a script that compiles cleanly but never returns or allocates without bound, so every runtime seam (virtual tags, Calculation tags, scripted-alarm predicates) runs scripts through the shared ScriptRunGuard:

  • Timeout + one orphan. A run that exceeds its budget is abandoned (it cannot be pre-empted in-process) and the tag/alarm reads Bad. While that abandoned run is still executing, every further trigger is refused — Bad "previous evaluation still running", counted on otopcua.script.run_refused — so a while(true) costs one pool thread, not one per trigger.

  • Quarantine after 3 consecutive timeouts (Scripting:QuarantineAfterConsecutiveTimeouts). Only a timeout is a strike: a refusal neither adds to nor resets the streak, and a run that returns (value or throw) resets it. A quarantined script is never dispatched again; it reads Bad "script quarantined: …", logs one Error and increments otopcua_script_quarantined.

  • Stuck-orphan quarantine. A never-returning script (while(true)) times out once and is then only refused, so the timeout streak alone never reaches 3. Instead, a refusal behind an orphan that has been running longer than Scripting:StuckOrphanQuarantineFactor × the timeout (default 10 × — 20 s at the 2 s default) quarantines immediately, through the same path (Error log, metric reason=stuck, Bad). A short overrun (a young orphan) stays a plain refusal and the script resumes once the orphan completes.

  • Cooperative cancellation (ctx.CancellationToken). The guard cancels the run token when a run times out, and scripts see it as ctx.CancellationToken. A long-running loop that polls ctx.CancellationToken.IsCancellationRequested (or calls ctx.CancellationToken.ThrowIfCancellationRequested()) ends its own abandoned run and frees the slot, instead of holding a pool thread until it returns. A script that ignores the token is still an orphan. The editor completes the member, and CancellationToken is on the sandbox allow-list.

  • Allocation budget. A single run allocating more than Scripting:MaxAllocBytesPerRun (64 MB) is quarantined. This is sampled after the run returns, so a run that OOMs the process first is not stopped — that needs an out-of-process runner (→ #750).

  • Out of process (opt-in, lmxopcua#780). Scripting:Runner=OutOfProcess moves the virtual-tag and Calculation seams' compiled runs — and the scripted-alarm predicates, when Scripting:Runner:Seams lists ScriptedAlarm — into ScriptWorker processes, where a run past T + Scripting:Worker:HardGrace is stopped by killing its worker and an OOM kills a worker instead of the server. A run whose worker died under it reads BadResourceUnavailable (an alarm is frozen with that annotation, never cleared). See docs/VirtualTags.md §"Out-of-process runner".

  • Recursion. Unbounded recursion fails the script with ScriptStackExhaustedException (Bad, script log) instead of overflowing the stack, which would terminate the whole server. The runtime compile inserts a stack check at the entry of the script and of every lambda, anonymous method and local function (lmxopcua#779); the editor does not show it and does not flag recursion, and bounded recursion still works.

  • Configuration keys and their ranges. All four knobs are node-wide (the Scripting appsettings section, shared by virtual tags, Calculation tags and scripted alarms) and are clamped when bound, with one startup Warning per clamped value:

    Key Default Range after the clamp
    Scripting:RunTimeout 00:00:02 (2 s) zero or negative becomes the default; above 00:01:00 becomes 60 s. The per-run budget of virtual tags and scripted-alarm predicates (lmxopcua#779 — alarms used to fall back to 250 ms). Calculation tags keep their per-driver runTimeoutMs.
    Scripting:MaxAllocBytesPerRun 67108864 (64 MB) 0 (or negative) disables the budget; a positive value below 1048576 (1 MB) becomes 1 MB
    Scripting:QuarantineAfterConsecutiveTimeouts 3 1–1000
    Scripting:StuckOrphanQuarantineFactor 10 0 (or negative) disables the rule; above 1000 becomes 1000; NaN becomes the default

    A Calculation driver may override maxAllocBytesPerRun and quarantineAfterConsecutiveTimeouts in its own driver config. Those keys are layered on the node values and clamped to the same ranges, and an out-of-range value logs a Warning naming the driver. The stuck factor has no per-driver key.

  • Lifting. A quarantine lifts when the script source changes or on redeploy, never by itself.

  • Script-log rate limit. The cluster-wide script-log fan-out admits 20 entries per source per minute; the rest are counted and replaced by one "N suppressed" summary. The summary is emitted on that source's next entry after the window rolls, so a source that bursts and then goes quiet shows its summary only when it logs again (the full stream is always in scripts-*.log).

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.AttributeValueItem.FullReference, which is the FullName field from Tag.TagConfig (see ScriptTagCatalog.ExtractFullNameFromTagConfig + EquipmentNodeWalker.ExtractFullName). The former UNS-path cascade engine (Core.VirtualTags.VirtualTagEngine, keyed by the slash-joined Enterprise/Site/Area/Line/Equipment/TagName browse path) was retired (#603) — it was never 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.

v3 Batch 4 (dual namespace) — unchanged for scripts. A ctx.GetTag(...) literal still resolves by the tag's FullName / RawPath — which is precisely the identity of the Raw-namespace node (ns=Raw, s=<RawPath>). A {{equip}}/<RefName> token resolves through the equipment's UnsTagReference to that same backing RawPath (see below), so scripts key off the single value source regardless of the UNS projection. The dual namespace changes only the OPC UA address-space surface, not script tag-path semantics.

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 AddressSpaceComposer 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 engine that would have honoured them (VirtualTagEngine) was retired unwired (#603). 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

  1. Implement a new case in ScriptAnalysisService.CompleteAsync. The Analyze(code) seam gives you the SemanticModel, SyntaxTree, and preamble offset.
  2. Add the corresponding request/response DTOs to ScriptAnalysisContracts.cs if a new endpoint is needed, or fold into an existing endpoint with an extra field.
  3. Register the new Monaco provider in monaco-init.js using the same fetch('/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'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 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 (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 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 — 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.


Input quality: what a script sees, and when it runs at all

The rule is stated once, in docs/DriverLifecycle.md §"The quality contract", and it applies identically to virtual-tag scripts and to Calculation driver scripts — they share VirtualTagContext and this editor, so one policy keeps this page true for every ctx.GetTag call site.

  • ctx.GetTag(path).StatusCode and .SourceTimestampUtc are real. The read cache used to stamp every dependency Good with an evaluation-time timestamp, so a quality-aware script could not be written at all — it was told Good even for an input whose driver had stopped communicating.
  • A script is NOT run while any of its declared inputs is Bad. The tag instead reports that input's own status (BadNoCommunication, BadWaitingForInitialData, …) carrying its last known value. So the (double)ctx.GetTag("x").Value idiom is safe against a dead input: the script simply does not run, rather than throwing on every trigger and filling /script-log.
  • An Uncertain input IS passed to the script, and the output inherits that Uncertain sub-code. An Uncertain value is present and a script can make a qualified decision from it; that is the alarm engine's long-standing rule, now shared.
  • The output never reports better than its worst input. A Good result over an Uncertain input is published Uncertain; nothing is published Good while an input is Bad.
  • ⚠️ If you wrote a script that deliberately handled null for a dead input, it no longer runs while that input is Bad. Assume your inputs are usable — the runtime has already decided they are.

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 an open, optional enhancement.
  • Language providers register globally at Monaco load. The four providers (completion, hover, signature help, document formatting) are registered for the csharp language on the global Monaco instance (loaded once per page in App.razor). Only pages gated by the ConfigEditor policy host a MonacoEditor today, but if a read-only surface ever embeds the editor the providers will fire (harmlessly: they get 401/403 responses from the gated endpoints and return empty results).

Items removed from this list because they shipped or were dropped: inlay hints (provider and /inlay-hints endpoint dropped in Lane D, #745); creating a new script from the VirtualTagModal inline panel (shipped, CreateNewScriptAsync in VirtualTagModal.razor); the method-name-only literal gate (fixed by the ctx-receiver guard, see Literal gate); and the synchronous Roslyn analysis (every analysis now derives from one process-wide base compilation and honours the request CancellationToken).


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).