9e8b5b0ea7
Reproduced first, out of process (ScriptRecursionContainmentTests re-launches the xunit v3 test assembly to run one recursive script through ScriptEvaluator + ScriptRunGuard, so the runner never executes one): before this change every shape — local function, mutual local functions, self-referencing Func<>, anonymous self-application, recursion through LINQ, an expression-bodied void lambda, an anonymous method — ended the child with exit 134 (SIGABRT, "Stack overflow."). A StackOverflowException cannot be caught, so in production one script takes the whole node down. Fix: ScriptEvaluator.Compile now rewrites the validated wrapper (ScriptStackGuardInjector, after ForbiddenTypeAnalyzer) so the script body and every lambda, anonymous method and local function starts with ScriptStackGuard.Check(), which throws ScriptStackExhaustedException when RuntimeHelpers.TryEnsureSufficientExecutionStack reports the stack nearly spent. The script then fails like any throwing script (Bad, script log) and the process lives. Why a runtime check, not an analyzer rule: recursion is not decidable from syntax (anonymous self-application never names itself), and bounded recursion must keep working. Expression-bodied lambdas/local functions become blocks (`return e;` or `e;`, chosen from the bound symbol, so the same overload binds); expression-tree and async lambdas are left alone. Limits (documented on ScriptStackGuard and in docs/VirtualTags.md): depth depends on the thread's stack size; a recursion that never re-enters script code is not seen; a script may catch the exception, which only unwinds it. Tests: ScriptRecursionContainmentTests (7 shapes, child process each; all 7 fail with exit 134 when the rewrite is disabled), ScriptStackGuardInjectionTests (bounded recursion and every rewritten lambda/local-function shape keep their value; the expression-tree case fails if those lambdas are rewritten). Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
544 lines
30 KiB
Markdown
544 lines
30 KiB
Markdown
# 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`](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 │
|
||
│ │ 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).
|
||
- **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):**
|
||
|
||
```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.
|
||
|
||
---
|
||
|
||
## Input quality: what a script sees, and when it runs at all
|
||
|
||
The rule is stated once, in
|
||
[`docs/DriverLifecycle.md` §"The quality contract"](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](#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).
|