# Monaco Script Editor (Roslyn-backed IntelliSense) — Design **Date:** 2026-06-09 **Status:** Approved (brainstorming complete; ready for implementation plan) **Author:** brainstorming session **Related:** `docs/Uns.md`, `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/`, sister project `scadabridge` (`/Users/dohertj2/Desktop/scadabridge/src`) ## Goal Replace OtOpcUa's proof-of-concept script editor (a CDN Monaco instance mounted over a hidden textarea via an `eval` + `Task.Delay(50)` race) with a proper, reusable Monaco code editor offering **full Roslyn-backed IntelliSense** — the same class of experience as the sister project **scadabridge**: completions, hover, signature help, live diagnostics, document formatting, inlay hints, and context-aware tag-path completion — but re-seated on OtOpcUa's *actual* script compile context so what the editor accepts/rejects exactly matches what publish enforces. ## Background — current state | Aspect | scadabridge (reference) | OtOpcUa (today) | |---|---|---| | Monaco delivery | Vendored locally (`wwwroot/lib/monaco/vs/`, served from `_content/`), v0.42 | CDN (jsDelivr `monaco-editor@0.52.2`), injected at runtime | | Editor component | Reusable `MonacoEditor.razor` (`Value`/`ValueChanged`, theme, toolbar, `DotNetObjectReference`) | None — `eval`-injected loader over a hidden ``, sync-back via dispatched `input` events | | IntelliSense | Full: completions/hover/signature-help/diagnostics/format/inlay via `/api/script-analysis/*` → `ScriptAnalysisService` (Roslyn) | None — plaintext `csharp` highlighting only | | Load robustness | Deterministic | Fragile 50 ms delay; silent fallback to textarea | | Air-gap | Safe (vendored) | Falls back to a plain textarea when CDN unreachable | The current code self-documents the gap: comments say richer editing "lands in Phase D.2." ### OtOpcUa scripting runtime (the context IntelliSense must reproduce) - **Evaluator:** `ScriptEvaluator` (`src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs`) builds a hand-rolled `CSharpCompilation` (NOT `CSharpScript`). It wraps the user's source in: ```csharp using System; using System.Linq; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Core.Scripting; namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled; public static class CompiledScript { public static TResult Run(ScriptGlobals globals) { var ctx = globals.ctx; #line 1 «user source» // statement body, MUST end with an explicit `return` } } ``` - **Globals / context:** `ScriptGlobals` exposes `ctx` of type `VirtualTagContext : ScriptContext`. The in-scope API a script author can call: - `ctx.GetTag(string path)` → `DataValueSnapshot` (`.Value`, `.StatusCode`, `.SourceTimestampUtc`, `.ServerTimestampUtc`) - `ctx.SetVirtualTag(string path, object? value)` - `ctx.Now` → `DateTime` - `ctx.Logger` → `Serilog.ILogger` - `ScriptContext.Deadband(double current, double previous, double tolerance)` (static) **These are real, referenceable types** (`Scripting.Abstractions`), so Roslyn analysis can point at the genuine types — no mirror "host" classes needed (scadabridge had to build mirrors because its runtime globals weren't directly referenceable). - **Sandbox:** `ScriptSandbox.Build(typeof(VirtualTagContext))` (`src/Core/Scripting/ScriptSandbox.cs`) supplies the imports (above) and the restricted metadata-reference set (pinned OtOpcUa assemblies + a BCL subset; no `System.IO`/`System.Net`/`Process`/`Reflection`). - **Hard gates publish enforces:** - `ForbiddenTypeAnalyzer` — semantic gate against type-forwarding sandbox escapes (e.g. resolving `HttpClient`). - `DependencyExtractor.Extract(source)` — AST walk requiring `ctx.GetTag` / `ctx.SetVirtualTag` paths to be **string literals**; rejects variables, concatenation, interpolation. Also yields the read/write dependency sets. - **Binding:** `VirtualTag.ScriptId` → `Script.ScriptId` (logical FK). A `Script` is **global** — one script may be referenced by multiple virtual tags across different equipment, and those tags may have different `DataType`s. The script is edited as a `Script` row (`SourceCode` + SHA-256 `SourceHash`), not per tag. ## Decisions (from brainstorming) 1. **Scope:** Full IntelliSense parity with scadabridge (completions, hover, signature help, diagnostics, formatting, inlay hints) — Roslyn-backed. 2. **Location:** Reusable `MonacoEditor.razor`, wired into **both** the ScriptEdit page (`/scripts/{id}`) **and** the Edit-Virtual-Tag modal (inline script-source panel). 3. **Tag-path completion:** Yes, in v1 — global completion of all configured tag + virtual-tag paths inside `ctx.GetTag("…")` / `ctx.SetVirtualTag("…")` string literals. 4. **Vendoring:** Monaco vendored locally (air-gap safe), replacing the CDN. 5. **Modal save-model:** Inline script editing in the modal is its **own** `RowVersion`-guarded Save, separate from the virtual-tag Create/Save, with a "Used by N virtual tags — edits affect all of them" warning. ## Architecture ``` ┌──────────────────────────── AdminUI (Blazor Server) ────────────────────────────┐ │ │ │ Components/Shared/MonacoEditor.razor ◄── Value/ValueChanged, theme, 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, authz-gated) │ │ ▼ │ │ ScriptAnalysis/ScriptAnalysisService.cs ── Roslyn over OtOpcUa's REAL wrapper │ │ │ ├─ ScriptSandbox.Build(typeof(VirtualTagContext)) (imports + refs) │ │ │ ├─ CompiledScript.Run(ScriptGlobals) wrapper │ │ │ ├─ ForbiddenTypeAnalyzer (sandbox-escape squiggles) │ │ │ ├─ DependencyExtractor (dynamic-path squiggles) │ │ │ └─ IScriptTagCatalog (tag-path string-literal completions) │ │ ▼ │ │ wwwroot/lib/monaco/vs/… (vendored Monaco, one