# 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`](plans/2026-06-09-monaco-script-editor-design.md).

---

## Overview

Virtual-tag scripts are C# expression bodies (see [VirtualTags.md](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 / 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:

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

> **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 `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

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):**

```csharp
// Script "Calc_TestMachine_001" — hard-coded, cannot reuse
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;
```

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

---

## 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 just `ctx.GetTag("…")`). Harmless
  in the sandbox today — no other `GetTag` methods exist in the allowed reference
  set — but tighter binding to `ctx` / `VirtualTagContext` is a follow-up.
- **Analysis runs synchronous Roslyn (CPU-bound).** Each endpoint call builds a
  `CSharpCompilation` synchronously. This is intentional: the endpoints are
  admin-only, client-side debounced (~500 ms), and `Task.Run` offload adds
  unnecessary complexity for infrequent admin use. If concurrency becomes a
  concern under heavy admin usage, wrapping each `Analyze` call in `Task.Run` is
  the correct fix.
- **InlayHints is a stub.** `/api/script-analysis/inlay-hints` returns 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 `csharp` language on the global Monaco instance (loaded once
  per page in `App.razor`). There is no impact today because only
  `Administrator`-reachable pages host a `MonacoEditor`, but if a read-only
  surface ever embeds the editor the providers will fire (harmlessly — they just
  get 401 responses from the `FleetAdmin`-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 `Script` row; 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).
