# 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 `<InputTextArea>`, 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<TContext> globals)
      {
          var ctx = globals.ctx;
  #line 1
          «user source»   // statement body, MUST end with an explicit `return`
      }
  }
  ```
- **Globals / context:** `ScriptGlobals<VirtualTagContext>` 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<VirtualTagContext>) wrapper        │
│        │   ├─ ForbiddenTypeAnalyzer  (sandbox-escape squiggles)                   │
│        │   ├─ DependencyExtractor    (dynamic-path squiggles)                     │
│        │   └─ IScriptTagCatalog      (tag-path string-literal completions)        │
│        ▼                                                                          │
│  wwwroot/lib/monaco/vs/…  (vendored Monaco, one <script> in App.razor)            │
└──────────────────────────────────────────────────────────────────────────────────┘
                         │ references
                         ▼
  Core.Scripting + Scripting.Abstractions (ScriptSandbox, VirtualTagContext,
  ScriptGlobals<>, ForbiddenTypeAnalyzer, DependencyExtractor)
  Configuration (OtOpcUaConfigDbContext → tag/virtual-tag paths for the catalog)
```

### Component placement

`ScriptAnalysis/` lives **inside `ZB.MOM.WW.OtOpcUa.AdminUI`** (mirrors
scadabridge's CentralUI layout). New build dependencies on the AdminUI project:

- Project refs: `Core.Scripting`, `Scripting.Abstractions` (and transitively
  `Core.Abstractions`). The AdminUI already references `Configuration`.
- Packages: `Microsoft.CodeAnalysis.CSharp.Scripting` and
  `Microsoft.CodeAnalysis.CSharp.Workspaces` (pin to scadabridge's 5.0.0 on
  net10; confirm at plan time).

### `ScriptAnalysisService` — the analysis core

Ported from scadabridge (~90% reusable: position mapping, completion/hover/
signature-help/format/inlay extraction, semantic-model access) but re-seated on
OtOpcUa's real compile context:

1. **Wrapped document.** Build the analysis source = OtOpcUa's evaluator wrapper
   with the user's text spliced after `#line 1`, return type `object` (a shared
   script must stay valid no matter which virtual tag's `DataType` consumes it;
   per-tag return coercion remains a runtime concern). Use
   `ScriptSandbox.Build(typeof(VirtualTagContext))` for imports + references —
   single source of truth shared with the evaluator.
2. **Compilation + semantic model.** `CSharpCompilation.Create` over the wrapped
   `SyntaxTree`; `compilation.GetSemanticModel(tree)`.
3. **Cursor mapping.** Editor `(line, column)` → offset within the user source →
   add the wrapper-prefix length → physical offset in the wrapped document. The
   `#line 1` directive makes Roslyn report **diagnostic** line numbers already in
   user-source coordinates; completions/hover/signature use the physical offset.
4. **Completions.** `semanticModel.LookupSymbols(position)` for scope; member
   completion after `.`; **string-literal completion** inside `ctx.GetTag("…")` /
   `ctx.SetVirtualTag("…")` delegates to `IScriptTagCatalog` (replaces
   scadabridge's `Parameters["…"]`/`Attributes["…"]`/`Children["…"]` cases).
5. **Diagnostics** (debounced ~500 ms client-side) = union of:
   - Roslyn compile diagnostics (errors + warnings) on the wrapped compilation.
   - `ForbiddenTypeAnalyzer` violations → error markers.
   - `DependencyExtractor` dynamic-path rejections → error markers with the
     offending span. (These three are precisely what publish enforces.)
6. **Hover / signature help / format / inlay hints.** Ported near-verbatim from
   scadabridge's Roslyn logic; the scadabridge domain-specific string-literal
   branches are replaced/removed.

Registered **scoped** (the catalog is scoped over the DB context); metadata
references from `ScriptSandbox` are static and cached. A small memory cache
(per scadabridge) backs repeated analysis of identical source.

### `IScriptTagCatalog`

New scoped service. `GetPathsAsync(string? filter, CancellationToken)` → distinct
configured tag + virtual-tag paths from `OtOpcUaConfigDbContext`, used for
string-literal completion. Global (scripts are not equipment-scoped). The
endpoint passes the current literal prefix as `filter` to bound result size.

### HTTP endpoints

`ScriptAnalysis/ScriptAnalysisEndpoints.cs` — `MapScriptAnalysisEndpoints()`
maps a `/api/script-analysis` group, gated by the AdminUI's design/write
authorization policy:

| Route | Request | Purpose |
|---|---|---|
| `POST /diagnostics` | `DiagnoseRequest` | Roslyn + ForbiddenType + dynamic-path markers |
| `POST /completions` | `CompletionsRequest` | scope/member + tag-path completion |
| `POST /hover` | `HoverRequest` | type/quick-info markdown |
| `POST /signature-help` | `SignatureHelpRequest` | parameter help |
| `POST /format` | `FormatRequest` | `NormalizeWhitespace()` formatting |
| `POST /inlay-hints` | `InlayHintsRequest` | inline type hints |

DTOs ported from scadabridge's `ScriptAnalysisContracts.cs` minus the
`ScriptKind` enum (OtOpcUa has a single script kind: virtual-tag).

### Front-end

- **`Components/Shared/MonacoEditor.razor`** — reusable: `[Parameter] Value`,
  `ValueChanged`, `Height`, `ReadOnly`, `ShowToolbar`, theme toggle (`vs`/
  `vs-dark`), Format button. Creates the editor in `OnAfterRenderAsync`, two-way
  binding via `DotNetObjectReference` (`OnValueChanged` → `ValueChanged`),
  external `Value` changes pushed via `setValue`, `dispose` on teardown,
  GUID-keyed for multi-instance safety.
- **`wwwroot/js/monaco-init.js`** — ported loader + `window.MonacoBlazor` with
  `createEditor/setValue/getValue/setMarkers/setEditorOption/format/dispose`,
  and the six Monaco language providers POSTing to `/api/script-analysis/*`
  (diagnostics debounced).
- **Vendored Monaco** under `wwwroot/lib/monaco/vs/…`, pulled in by a single
  `<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js">` in
  `App.razor`. The CDN `monaco-loader.js` + the `eval`/`Task.Delay` injection in
  `ScriptEdit.razor` are removed.

### Wire-in points

- **ScriptEdit page (`/scripts/{id}`):** replace `<InputTextArea>` with
  `<MonacoEditor @bind-Value="_form.SourceCode" Height="…">`. Save path unchanged
  (SHA-256 on save). The old `OnAfterRenderAsync` JS-injection block is deleted.
- **VirtualTagModal:** below the Script dropdown, a collapsible **"Script source"**
  panel that loads the selected script's `SourceCode` into a `MonacoEditor`,
  shows a **"Used by N virtual tags — edits affect all of them"** notice (count
  from the config DB), and a dedicated **Save script** button performing a
  `RowVersion`-guarded update of the `Script` row — a separate concurrency unit
  from the virtual-tag Create/Save. Creating a brand-new script inline is a
  follow-up, not v1.

## Data flow (a keystroke)

1. User types in Monaco → `onDidChangeModelContent` → `ValueChanged` updates the
   Blazor model; debounce schedules a diagnostics fetch.
2. Provider POSTs `{ source, line, column }` to `/api/script-analysis/*`.
3. `ScriptAnalysisService` wraps the source, compiles, computes the result at the
   mapped offset (consulting `IScriptTagCatalog` for path literals), returns DTOs.
4. JS provider maps DTOs to Monaco completions/markers/hover and renders them.

## Error handling

- **CDN/JS load failure:** with vendoring there is no network dependency; if the
  vendored asset is somehow missing, the editor surfaces a clear init error
  rather than silently degrading. (No more silent textarea fallback masking a
  broken editor.)
- **Analysis exceptions:** each endpoint is defensive — a malformed request or an
  internal Roslyn failure returns an empty result (no completions/markers) rather
  than 500-ing the editor; logged server-side.
- **Concurrency:** modal script Save uses `RowVersion`; a conflict surfaces the
  standard "changed by someone else, reload" message (existing pattern).
- **Auth:** endpoints require the AdminUI design/write policy; read-only users
  get the editor read-only.

## Testing

No bUnit (established AdminUI pattern — razor/JS proven only by live `/run`).

- **`ScriptAnalysisService` (unit, xUnit + Shouldly):** member completion after
  `ctx.`; scope completion; tag-path completion inside `GetTag`/`SetVirtualTag`
  (via a fake `IScriptTagCatalog`); hover on `ctx.GetTag`; signature help; format
  round-trip; **diagnostics overlays** — a clean script (no markers), a Roslyn
  error, a `ForbiddenTypeAnalyzer` violation (e.g. `HttpClient`), and a
  dynamic-path rejection (`ctx.GetTag(someVar)`); cursor-offset/`#line` mapping
  (marker lands on the right user-source line).
- **`IScriptTagCatalog` (unit, in-memory EF — Configuration.Tests pattern):**
  returns distinct tag + virtual-tag paths; honors the filter prefix.
- **Live docker-dev `/run`:** on `/scripts/{id}` — `ctx.` shows members; bad code
  shows a red squiggle; `ctx.GetTag("` lists tag paths; Format works; theme
  toggle. Then the VirtualTagModal — open it, pick a script, edit the inline
  panel, see the "used by N" notice, save, reopen to confirm persistence.

Done = build clean + `dotnet test` green + live `/run` pass.

## Out of scope (follow-ups)

- Creating a brand-new `Script` inline from the VirtualTagModal.
- Per-virtual-tag return-type-aware diagnostics (analysis uses `object` return).
- Embedding the editor in any surface beyond ScriptEdit + VirtualTagModal.
- Sharing the analysis backend as a cross-repo package with scadabridge (this is
  a copy-and-adapt fork; the context models differ).

## Hard rules (carried from prior AdminUI work)

- Stage by path — never `git add .`; never stage `sql_login.txt` or
  `src/Server/ZB.MOM.WW.OtOpcUa.Host/pki/`; never echo the gateway API key into a
  new tracked file; no force-push; no `--no-verify`.
- The agent does **not** sign in to the AdminUI — the user signs in for live
  verification.
- No Configuration entity/migration change is required by this feature (the
  catalog reads existing tables; `Script`/`VirtualTag` schemas are unchanged).
