Files
lmxopcua/docs/plans/2026-06-09-monaco-script-editor.md
T
Joseph Doherty 93aa6c2f81 docs(scripting): implementation plan for Roslyn-backed Monaco script editor
14-task plan: vendor Monaco + reusable MonacoEditor.razor, ScriptAnalysis
backend re-seated on the real evaluator wrapper (ScriptSandbox +
ForbiddenTypeAnalyzer + DependencyExtractor diagnostics), IScriptTagCatalog
path completion, six Monaco providers, ScriptEdit + virtual-tag-modal wire-in,
live docker-dev verification. Co-located .tasks.json for resume.
2026-06-09 13:56:59 -04:00

798 lines
46 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Monaco Script Editor (Roslyn-backed IntelliSense) Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
**Goal:** Replace OtOpcUa's CDN-Monaco-over-textarea script editor with a reusable, Roslyn-backed Monaco editor offering full IntelliSense (completions, hover, signature help, live diagnostics, formatting, tag-path completion), re-seated on OtOpcUa's *real* script compile context so the editor accepts/rejects exactly what publish enforces.
**Architecture:** A new `ScriptAnalysis/` backend inside the AdminUI exposes `/api/script-analysis/*` minimal-API endpoints backed by `ScriptAnalysisService`, which compiles the user's source inside OtOpcUa's genuine evaluator wrapper (`public static object Run(ScriptGlobals<VirtualTagContext> globals){ var ctx = globals.ctx; #line 1 «source» }`) using `ScriptSandbox.Build(typeof(VirtualTagContext))` for imports + references, then computes Roslyn completions/hover/diagnostics from the semantic model and layers OtOpcUa's `ForbiddenTypeAnalyzer` + `DependencyExtractor` as extra diagnostics. A vendored Monaco + a reusable `MonacoEditor.razor` + ported `monaco-init.js` register language providers that POST to those endpoints. The editor is wired into the ScriptEdit page and the virtual-tag modal.
**Tech Stack:** .NET 10, Blazor Server (`InteractiveServer`), Roslyn (`Microsoft.CodeAnalysis.CSharp`, transitive via `Core.Scripting`), Monaco Editor (vendored, v0.42 from scadabridge), xUnit + Shouldly, EF Core InMemory (tests).
**Reference sources to port FROM (sister repo `scadabridge`):**
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/MonacoEditor.razor`
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/monaco-init.js`
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/lib/monaco/` (vendored Monaco, 15 MB / 121 files)
- `src/ZB.MOM.WW.ScadaBridge.CentralUI/ScriptAnalysis/{ScriptAnalysisContracts,ScriptAnalysisEndpoints,ScriptAnalysisService}.cs`
**OtOpcUa types to reuse (do NOT modify):**
- `ScriptSandbox.Build(Type) → SandboxConfig(IReadOnlyList<MetadataReference> References, IReadOnlyList<string> Imports)``src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs:46`
- `ForbiddenTypeAnalyzer.Analyze(Compilation) → IReadOnlyList<ForbiddenTypeRejection(TextSpan Span, string TypeName, string Namespace, string Message)>``ForbiddenTypeAnalyzer.cs:175,309`
- `DependencyExtractor.Extract(string) → DependencyExtractionResult(IReadOnlySet<string> Reads, IReadOnlySet<string> Writes, IReadOnlyList<DependencyRejection(TextSpan Span, string Message)> Rejections){ bool IsValid }``DependencyExtractor.cs:42,147,156`
- `VirtualTagContext : ScriptContext` (`GetTag(string)→DataValueSnapshot`, `SetVirtualTag(string,object?)`, `Now`, `Logger`, static `ScriptContext.Deadband(double,double,double)`) and `ScriptGlobals<TContext>{ TContext ctx }``src/Core/Scripting.Abstractions/`
- `DataValueSnapshot(object? Value, uint StatusCode, DateTime? SourceTimestampUtc, DateTime ServerTimestampUtc)``Core.Abstractions/DataValueSnapshot.cs:17`
- `OtOpcUaConfigDbContext` with `DbSet<Tag> Tags`, `DbSet<VirtualTag> VirtualTags`, `DbSet<Equipment> Equipment`, `DbSet<Script> Scripts``Configuration/OtOpcUaConfigDbContext.cs`
**Existing OtOpcUa files to change:** `Components/Pages/ScriptEdit.razor`, `Components/Shared/Uns/VirtualTagModal.razor`, `Uns/IUnsTreeService.cs` + `UnsTreeService.cs`, `Components/App.razor`, the AdminUI `.csproj`; **delete** `wwwroot/js/monaco-loader.js`. Endpoints mapped in `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (the `hasAdmin` block, after `app.MapAdminUI<App>()`).
**Hard rules (carry through every task):** AdminUI + new `ScriptAnalysis/` + AdminUI.Tests + the one `Program.cs` map-line only. NO Configuration entity/migration change. 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 the live `/run`.
**Branch:** Create `feat/monaco-script-editor` off `master` (HEAD = the commit after `7a03d01`/this plan) before any code task. The design doc (`7a03d01`) and this plan + `.tasks.json` are committed to `master` first; the build runs on the feature branch.
**Authorization:** New endpoints use `.RequireAuthorization("FleetAdmin")` (policy defined in `src/Server/ZB.MOM.WW.OtOpcUa.Security/ServiceCollectionExtensions.cs:113` as `RequireRole("Administrator")` — script design is an admin action, same tier as deploy).
**Key fidelity notes (read once):**
- The analysis return type is `object` (a `Script` is shared across virtual tags of differing `DataType`; per-tag return coercion stays a runtime concern).
- The wrapper preamble (usings + `Run` signature + `var ctx = globals.ctx;`) precedes a `#line 1` directive, then the raw user source (column-aligned, not re-indented). Therefore: **diagnostics**`tree.GetLineSpan(span)` / `d.Location.GetLineSpan()` return *mapped* (user-source) line numbers automatically; **completions/hover/signature** — use the *physical* offset `prefixLength + offsetWithinUserSource`.
- Filter out diagnostics physically located in the preamble (`location.SourceSpan.Start < prefixLength`) so wrapper-level noise (e.g. CS0161 "not all code paths return a value" on the `Run` method when the user hasn't typed `return` yet) doesn't show as a phantom marker. (Missing-`return` enforcement stays a runtime/publish concern for v1.)
- We use `CSharpCompilation` (not `CSharpScript`), so `Microsoft.CodeAnalysis.CSharp.Scripting` is **not** needed; Roslyn core (`Microsoft.CodeAnalysis.CSharp`, incl. `NormalizeWhitespace`) arrives transitively through the `Core.Scripting` project reference. Add `Microsoft.CodeAnalysis.CSharp.Workspaces` only if a build error names a missing type.
---
### Task 0: AdminUI project references + feature branch
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (prerequisite for backend tasks)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj` (ItemGroup of ProjectReferences, ~lines 1940)
**Step 1: Create the branch**
```bash
git checkout -b feat/monaco-script-editor
```
**Step 2: Add the two project references** (alongside the existing `Configuration` ref):
```xml
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj" />
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj" />
```
(Confirm the exact directory names of those two projects on disk first — `ls src/Core/ | grep Scripting` — and match them.)
**Step 3: Build to confirm Roslyn resolves transitively**
```bash
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
```
Expected: build succeeds. (`ScriptSandbox`, `CSharpCompilation`, `VirtualTagContext` are now referenceable.)
**Step 4: Commit**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
git commit -m "build(adminui): reference Core.Scripting for the script-analysis backend"
```
---
### Task 1: Vendor Monaco + reusable MonacoEditor.razor + minimal monaco-init.js + App.razor wiring
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2 (disjoint files)
**Files:**
- Create (copy): `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/lib/monaco/**` (from scadabridge)
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor`
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor:23-25` (add one `<script>`)
No unit tests (UI/JS — proven by the live `/run` in Task 12).
**Step 1: Vendor Monaco** (file copy, then stage the folder by path):
```bash
cp -R /Users/dohertj2/Desktop/scadabridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/lib/monaco \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/lib/monaco
```
(~15 MB / 121 files under `wwwroot/lib/monaco/vs/`. This is the air-gap-safe replacement for the CDN.)
**Step 2: Create `monaco-init.js`** — port scadabridge's file but **MINIMAL for now** (no language providers yet; those land in Task 9). Keep only the AMD loader + the editor lifecycle + value sync. Critically, set the base path to OtOpcUa's `_content`:
```javascript
(function () {
const VS_BASE = "/_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/monaco/vs";
const editors = {};
let readyPromise = null;
function ensureLoaded() {
if (readyPromise) return readyPromise;
readyPromise = new Promise(function (resolve, reject) {
const loader = document.createElement("script");
loader.src = VS_BASE + "/loader.js";
loader.onload = function () {
require.config({ paths: { vs: VS_BASE } });
require(["vs/editor/editor.main"], function () {
registerCSharpProviders(); // no-op until Task 9 fills it in
resolve();
});
};
loader.onerror = function (e) { reject(e); };
document.head.appendChild(loader);
});
return readyPromise;
}
function registerCSharpProviders() { /* Task 9: completions/hover/signature/format/inlay */ }
async function fetchDiagnostics(model) { return []; } // Task 9 wires the real fetch
async function createEditor(id, host, options, dotNetRef) {
await ensureLoaded();
if (!host) return;
const editor = monaco.editor.create(host, {
value: options.value || "", language: options.language || "csharp",
theme: "vs", minimap: { enabled: false }, scrollBeyondLastLine: false,
automaticLayout: true, fontSize: 13, lineNumbers: "on",
renderLineHighlight: "line", readOnly: !!options.readOnly,
tabSize: 4, insertSpaces: true, wordWrap: "off", fixedOverflowWidgets: true
});
let diagTimer = null;
const scheduleDiagnostics = function () {
if (diagTimer) clearTimeout(diagTimer);
diagTimer = setTimeout(async function () {
const model = editor.getModel(); if (!model) return;
const markers = await fetchDiagnostics(model);
monaco.editor.setModelMarkers(model, "otopcua", markers);
dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {});
}, 500);
};
editor.onDidChangeModelContent(function () {
dotNetRef.invokeMethodAsync("OnValueChanged", editor.getValue()).catch(function () {});
if (options.language === "csharp") scheduleDiagnostics();
});
editors[id] = { editor: editor, dotNetRef: dotNetRef };
if (options.language === "csharp") scheduleDiagnostics();
}
function setEditorOption(id, name, value) {
const e = editors[id]; if (!e) return;
if (name === "theme") { monaco.editor.setTheme(value); return; }
const u = {}; u[name] = value; e.editor.updateOptions(u);
}
function format(id) { editors[id]?.editor.getAction("editor.action.formatDocument")?.run(); }
function revealLine(id, line, col) {
const e = editors[id]; if (!e) return;
e.editor.revealLineInCenter(line); e.editor.setPosition({ lineNumber: line, column: col || 1 }); e.editor.focus();
}
function setValue(id, v) { const e = editors[id]; if (e && e.editor.getValue() !== v) e.editor.setValue(v || ""); }
function getValue(id) { const e = editors[id]; return e ? e.editor.getValue() : null; }
function setMarkers(id, m) { const e = editors[id]; const model = e && e.editor.getModel(); if (model) monaco.editor.setModelMarkers(model, "otopcua", m || []); }
function dispose(id) { const e = editors[id]; if (!e) return; try { e.editor.dispose(); } catch (x) {} delete editors[id]; }
window.MonacoBlazor = { createEditor, setValue, getValue, setMarkers, setEditorOption, format, revealLine, dispose };
// expose the editors map + helpers Task 9 will close over:
window.__otopcuaEditors = editors;
})();
```
(Task 9 reopens this file to add the providers + the real `fetchDiagnostics`. Keeping `editors` on `window.__otopcuaEditors` lets Task 9's providers find the model→editor mapping if needed; for v1 the providers don't need per-editor .NET context, so this may go unused — fine.)
**Step 3: Create `MonacoEditor.razor`** — port scadabridge's component but **strip all SCADA context** (`ScriptKind`, `DeclaredParameters`, `DeclaredParameterShapes`, `SiblingScripts`, `SelfAttributes`, `Children`, `Parent`, `GetContext`, the `ScadaContext` record). Keep: `Value`/`ValueChanged`, `Language="csharp"`, `Height`, `ReadOnly`, `ShowToolbar`, `MarkersChanged`, the toolbar (Format/Wrap/Minimap/Theme), `OnAfterRenderAsync` (create on first render + `setValue` on `Value` change), `[JSInvokable] OnValueChanged`/`OnMarkersChanged`, `RevealLineAsync`, `SafeInvokeAsync`, `DisposeAsync`. Namespace: `ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared`. Use a `DiagnosticMarker[]` parameter type for `OnMarkersChanged` (the DTO created in Task 2 — until then, type it `object[]` and tighten in Task 9, OR sequence Task 2 before this; simplest: keep `OnMarkersChanged(object[] markers)` here and let Task 9 finalize). The host div + create-options match scadabridge.
**Step 4: Wire the script in `App.razor`** — add before `<script src="_framework/blazor.web.js">`:
```html
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
```
**Step 5: Build**
```bash
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
```
Expected: succeeds.
**Step 6: Commit** (stage by path; the monaco folder is large but committed deliberately):
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/lib/monaco \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor
git commit -m "feat(adminui): vendor Monaco + reusable MonacoEditor component (no providers yet)"
```
---
### Task 2: ScriptAnalysis contracts + service analysis seam + endpoints + DI
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1 (disjoint files)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs`
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs`
- Modify: the AdminUI service-registration site (find `AddAdminUI`/`AddOtOpcUaAdminUI` extension, else the `hasAdmin` block in `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (~line 185, after `app.MapAdminUI<App>()`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptAnalysisServiceWrapperTests.cs`
**Step 1: Contracts** — port scadabridge's `ScriptAnalysisContracts.cs` but **drop** `ScriptKind` and every SCADA shape (`ScriptShape`, `ParameterShape`, `AttributeShape`, `CompositionContext`) and all the context fields on the requests. Final shapes:
```csharp
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public record DiagnoseRequest(string Code);
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn,
int EndLineNumber, int EndColumn, string Message, string Code);
public record CompletionsRequest(string CodeText, int Line, int Column);
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0);
public record HoverRequest(string CodeText, int Line, int Column);
public record HoverResponse(string? Markdown);
public record SignatureHelpRequest(string CodeText, int Line, int Column);
public record SignatureHelpResponse(string? Label, IReadOnlyList<SignatureHelpParameter>? Parameters, int ActiveParameter);
public record SignatureHelpParameter(string Label, string? Documentation);
public record FormatRequest(string Code);
public record FormatResponse(string Code);
public record InlayHintsRequest(string Code);
public record InlayHintsResponse(IReadOnlyList<InlayHint> Hints);
public record InlayHint(int Line, int Column, string Label);
```
**Step 2: Service shell + the shared analysis seam.** Create `ScriptAnalysisService` with the wrapper/compilation core all methods share, plus empty public methods (filled in Tasks 38). Inject `IScriptTagCatalog` (interface created in Task 5; for now declare the ctor param and an interface stub — OR sequence Task 5 before Task 6 and leave the ctor parameterless until Task 6; simplest: parameterless ctor now, add `IScriptTagCatalog` in Task 6).
```csharp
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using ZB.MOM.WW.OtOpcUa.Core.Scripting; // ScriptSandbox, ForbiddenTypeAnalyzer, DependencyExtractor
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions; // VirtualTagContext, ScriptGlobals (confirm namespace)
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public sealed class ScriptAnalysisService
{
private static readonly SandboxConfig Sandbox = ScriptSandbox.Build(typeof(VirtualTagContext));
private static readonly string Preamble = BuildPreamble();
private static readonly CSharpCompilationOptions CompileOptions = new(
OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release,
allowUnsafe: false, warningLevel: 4, nullableContextOptions: NullableContextOptions.Enable);
private static string BuildPreamble()
{
var usings = string.Join("\n", Sandbox.Imports.Select(i => $"using {i};"));
return usings + "\n\n" +
"namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled;\n" +
"public static class CompiledScript\n{\n" +
" public static object Run(ScriptGlobals<VirtualTagContext> globals)\n {\n" +
" var ctx = globals.ctx;\n#line 1\n";
// user source is appended after this, then " }\n}\n"
}
/// <summary>Wrap user source in OtOpcUa's evaluator shape; returns the tree, semantic model, and the prefix length (chars before user source).</summary>
private static (SyntaxTree Tree, SemanticModel Model, int Prefix) Analyze(string userSource)
{
var prefix = Preamble;
var full = prefix + (userSource ?? "") + "\n }\n}\n";
var tree = CSharpSyntaxTree.ParseText(full);
var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions);
return (tree, compilation.GetSemanticModel(tree), prefix.Length);
}
/// <summary>(line,col) in user-source coords → physical offset in the wrapped document.</summary>
private static int OffsetInWrapped(string userSource, int line, int column, int prefix)
=> prefix + Math.Clamp(PositionToOffset(userSource, line, column), 0, (userSource ?? "").Length);
private static int PositionToOffset(string code, int line, int column)
{
int offset = 0, curLine = 1, curCol = 1;
for (int i = 0; i < code.Length; i++)
{
if (curLine == line && curCol == column) return offset;
if (code[i] == '\n') { curLine++; curCol = 1; } else { curCol++; }
offset = i + 1;
}
return code.Length;
}
public DiagnoseResponse Diagnose(DiagnoseRequest req) => new(Array.Empty<DiagnosticMarker>()); // Task 3
public Task<CompletionsResponse> CompleteAsync(CompletionsRequest req) => Task.FromResult(new CompletionsResponse(Array.Empty<CompletionItem>())); // Tasks 4,6
public HoverResponse Hover(HoverRequest req) => new((string?)null); // Task 7
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req) => new(null, null, 0); // Task 7
public FormatResponse Format(FormatRequest req) => new(req.Code); // Task 8
public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty<InlayHint>()); // Task 8 (stays empty)
}
```
(Verify the exact namespace of `ScriptGlobals`/`VirtualTagContext` — the explorer reported `ZB.MOM.WW.OtOpcUa.Core.Scripting` for the types living under the `Scripting.Abstractions` project; match the actual `namespace` declarations.)
**Step 3: Endpoints** — port `ScriptAnalysisEndpoints.cs`, drop the `/run` route, swap auth:
```csharp
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/script-analysis").RequireAuthorization("FleetAdmin");
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r)));
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s) => Results.Ok(s.Hover(r)));
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r)));
group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r)));
return endpoints;
}
```
**Step 4: DI** — register `services.AddScoped<ScriptAnalysisService>();` (and in Task 5, `services.AddScoped<IScriptTagCatalog, ScriptTagCatalog>();`) in the AdminUI service-registration extension.
**Step 5: Map** — in `Program.cs` `hasAdmin` block, after `app.MapAdminUI<App>();`:
```csharp
app.MapScriptAnalysisEndpoints();
```
**Step 6: Test the wrapper seam** (the one piece of Task 2 with logic worth a test). Since `Analyze`/`OffsetInWrapped` are private, expose a tiny internal test hook OR test indirectly via Task 3. To keep Task 2 self-verifying, add an `internal` method `AnalyzeForTest(string) => Analyze(...)` guarded by `[assembly: InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.AdminUI.Tests")]`, and assert that for source `return 1;` the compilation reports **no** user-span diagnostics and that a member-access after `ctx.` resolves to `VirtualTagContext`. (If you prefer, fold this verification into Task 3's diagnostics tests and keep Task 2 to a build-only check — acceptable.)
**Step 7: Build + commit**
```bash
dotnet build ZB.MOM.WW.OtOpcUa.slnx
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis \
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs \
<the-service-registration-file> \
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptAnalysisServiceWrapperTests.cs
git commit -m "feat(adminui): script-analysis contracts, wrapper seam, endpoints + DI"
```
---
### Task 3: Diagnostics — Roslyn ForbiddenTypeAnalyzer DependencyExtractor
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5 (disjoint files)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` (`Diagnose`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs`
**Step 1: Write failing tests** (xUnit + Shouldly):
```csharp
public sealed class DiagnoseTests
{
private static readonly ScriptAnalysisService Svc = new();
private static IReadOnlyList<DiagnosticMarker> Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers;
[Fact] public void Clean_script_has_no_markers()
=> Diag("""return (double)ctx.GetTag("A").Value * 2.0;""").ShouldBeEmpty();
[Fact] public void Roslyn_error_is_reported_on_the_user_line()
{
var m = Diag("return ctx.NoSuchMember();");
m.ShouldNotBeEmpty();
m[0].Severity.ShouldBe(8);
m[0].StartLineNumber.ShouldBe(1); // #line 1 maps to user source
}
[Fact] public void Forbidden_type_is_flagged()
=> Diag("""var c = new System.Net.Http.HttpClient(); return 0;""")
.ShouldContain(m => m.Message.Contains("HttpClient") || m.Code.StartsWith("OTSCRIPT") || m.Severity == 8);
[Fact] public void Dynamic_tag_path_is_flagged()
{
var m = Diag("""var p = "A"; return ctx.GetTag(p).Value;""");
m.ShouldContain(x => x.Message.Contains("literal")); // DependencyExtractor rejection
}
[Fact] public void Preamble_diagnostics_are_suppressed()
=> Diag("var x = 1;").ShouldNotContain(m => m.Code == "CS0161"); // missing-return on Run() is hidden
}
```
Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter FullyQualifiedName~DiagnoseTests` → FAIL.
**Step 2: Implement `Diagnose`:**
```csharp
public DiagnoseResponse Diagnose(DiagnoseRequest req)
{
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
var (tree, _, prefix) = Analyze(req.Code);
var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions);
var markers = new List<DiagnosticMarker>();
// (1) Roslyn — only diagnostics physically inside the user source (suppress preamble noise like CS0161 on Run()).
foreach (var d in compilation.GetDiagnostics())
{
if (d.Severity < DiagnosticSeverity.Info || !d.Location.IsInSource) continue;
if (d.Location.SourceSpan.Start < prefix) continue;
markers.Add(ToMarker(d.Location.GetLineSpan(), Sev(d.Severity), d.GetMessage(), d.Id));
}
// (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; GetLineSpan honors #line → user coords.
foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation))
markers.Add(ToMarker(tree.GetLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN"));
// (3) DependencyExtractor — spans are offsets in the *user* source; map directly.
foreach (var r in DependencyExtractor.Extract(req.Code).Rejections)
markers.Add(ToUserMarker(req.Code, r.Span, r.Message, "OTSCRIPT_DYNPATH"));
return new DiagnoseResponse(markers);
}
private static int Sev(DiagnosticSeverity s) => s switch {
DiagnosticSeverity.Error => 8, DiagnosticSeverity.Warning => 4, DiagnosticSeverity.Info => 2, _ => 1 };
private static DiagnosticMarker ToMarker(FileLinePositionSpan span, int sev, string msg, string code) => new(
sev, span.StartLinePosition.Line + 1, span.StartLinePosition.Character + 1,
span.EndLinePosition.Line + 1, span.EndLinePosition.Character + 1, msg, code);
private static DiagnosticMarker ToUserMarker(string userSource, Microsoft.CodeAnalysis.Text.TextSpan span, string msg, string code)
{
var (sl, sc) = LineCol(userSource, span.Start);
var (el, ec) = LineCol(userSource, span.End);
return new(8, sl, sc, el, ec, msg, code);
}
private static (int, int) LineCol(string code, int offset)
{
int line = 1, col = 1;
for (int i = 0; i < offset && i < code.Length; i++)
if (code[i] == '\n') { line++; col = 1; } else col++;
return (line, col);
}
```
**Step 3: Run tests** → PASS. (If `ForbiddenTypeAnalyzer` reports nothing for `HttpClient` because the reference set already excludes `System.Net.Http`, the Roslyn pass will instead emit a CS-level "type/namespace not found" error in user-span — the test's `||` covers that. Confirm which path fires and tighten the assert.)
**Step 4: Commit**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs
git commit -m "feat(adminui): script diagnostics (Roslyn + forbidden-type + dynamic-path)"
```
---
### Task 4: Completions — scope + dot-member
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 5 (disjoint files)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` (`CompleteAsync` + `ToCompletionItem` + `TryGetDotMembers`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs`
**Step 1: Failing tests:**
```csharp
public sealed class CompletionTests
{
private static readonly ScriptAnalysisService Svc = new();
private static async Task<IReadOnlyList<CompletionItem>> Complete(string code, int line, int col)
=> (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items;
[Fact] public async Task Dot_after_ctx_offers_context_members()
{
var items = await Complete("ctx.", 1, 5); // caret right after "ctx."
var labels = items.Select(i => i.Label).ToList();
labels.ShouldContain("GetTag");
labels.ShouldContain("SetVirtualTag");
labels.ShouldContain("Now");
}
[Fact] public async Task Scope_completion_includes_ctx_local()
=> (await Complete("c", 1, 2)).Select(i => i.Label).ShouldContain("ctx");
}
```
Run filter `~CompletionTests` → FAIL.
**Step 2: Implement** — port scadabridge's `CompleteAsync` core, but build the document via `Analyze(...)` and map the offset via `OffsetInWrapped(...)`; the string-literal branch is added in Task 6 (leave a `// Task 6: tag-path literals` placeholder):
```csharp
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
{
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
var (tree, model, prefix) = Analyze(req.CodeText);
var position = OffsetInWrapped(req.CodeText, req.Line, req.Column, prefix);
var root = await tree.GetRootAsync();
var token = root.FindToken(Math.Max(0, position - 1));
// Task 6: if token is a string literal inside ctx.GetTag/SetVirtualTag → tag-path completion.
var dot = TryGetDotMembers(token, model);
if (dot != null) return new CompletionsResponse(dot);
var scoped = model.LookupSymbols(position)
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
.GroupBy(s => s.Name).Select(g => g.First())
.Select(ToCompletionItem).Take(200).ToList();
return new CompletionsResponse(scoped);
}
```
Copy `TryGetDotMembers` and `ToCompletionItem` verbatim from scadabridge (`ScriptAnalysisService.cs:885,1271`) — they are pure Roslyn, no domain coupling.
**Step 3: Run tests** → PASS.
**Step 4: Commit**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs
git commit -m "feat(adminui): scope + dot-member script completions"
```
---
### Task 5: IScriptTagCatalog (tag + virtual-tag path provider)
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 3, Task 4 (disjoint files)
**Files:**
- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs` (+ `ScriptTagCatalog`)
- Modify: AdminUI service registration (`AddScoped<IScriptTagCatalog, ScriptTagCatalog>()`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs`
**Step 0 (verify the canonical path format — do this FIRST):** Read how the runtime resolves a `ctx.GetTag("…")` literal to a configured tag (the virtual-tag engine's subscription/read-cache key construction, and how the equipment-namespace browse path is built). Confirm the exact separator and segment order a script author types — i.e. whether it is `Area/Line/Equipment/Name`, `Equipment/Name`, or `FolderPath/Name`. **Match the catalog's projected strings to that.** If the format genuinely can't be pinned down, project both the leaf `Name` and a best-effort full path and note the ambiguity in a code comment + the task report (do NOT silently guess).
**Step 1: Failing test** (in-memory EF — see `Microsoft.EntityFrameworkCore.InMemory`, already referenced by AdminUI.Tests):
```csharp
public sealed class ScriptTagCatalogTests
{
private static OtOpcUaConfigDbContext NewDb()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
return new OtOpcUaConfigDbContext(opts);
}
[Fact] public async Task Returns_configured_tag_and_virtual_tag_paths()
{
await using var db = NewDb();
// seed one Equipment + one Tag + one VirtualTag (use the confirmed path convention from Step 0)
// ...
await db.SaveChangesAsync();
var catalog = new ScriptTagCatalog(/* IDbContextFactory or db */);
var paths = await catalog.GetPathsAsync(null, default);
paths.ShouldContain(/* the expected tag path */);
paths.ShouldContain(/* the expected virtual-tag path */);
}
[Fact] public async Task Filter_prefix_narrows_results() { /* assert prefix filtering */ }
}
```
**Step 2: Implement:**
```csharp
public interface IScriptTagCatalog
{
Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct);
}
public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> dbf) : IScriptTagCatalog
{
public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
{
await using var db = await dbf.CreateDbContextAsync(ct);
// Build paths per the Step 0 convention. Pull the minimal columns, project to the
// path string, distinct, filtered by `filter` (StartsWith/Contains, case-insensitive),
// capped (e.g. Take(200)) so completion stays responsive.
// ...
return result;
}
}
```
(Use `IDbContextFactory<OtOpcUaConfigDbContext>` to match `UnsTreeService`'s pattern.)
**Step 3: Run tests** → PASS. **Step 4: Register in DI. Step 5: Commit.**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs \
<service-registration-file> \
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs
git commit -m "feat(adminui): IScriptTagCatalog for tag-path completion"
```
---
### Task 6: Tag-path string-literal completion
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (edits ScriptAnalysisService.cs; needs Tasks 4 + 5)
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` (inject `IScriptTagCatalog`, fill the Task-6 placeholder in `CompleteAsync`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs`
**Step 1: Failing test** with a fake catalog:
```csharp
private sealed class FakeCatalog : IScriptTagCatalog
{
public Task<IReadOnlyList<string>> GetPathsAsync(string? f, CancellationToken ct)
=> Task.FromResult<IReadOnlyList<string>>(new[] { "Line1/Speed", "Line1/Temp" });
}
[Fact] public async Task String_literal_in_GetTag_offers_tag_paths()
{
var svc = new ScriptAnalysisService(new FakeCatalog());
var items = (await svc.CompleteAsync(new CompletionsRequest("""ctx.GetTag("")""", 1, 12))).Items; // caret inside ""
items.Select(i => i.Label).ShouldContain("Line1/Speed");
}
```
(Adjust the column to land inside the empty string literal — count: `ctx.GetTag("` is 12 chars, so column 13 is between the quotes; verify with the test.)
**Step 2: Implement** the placeholder: when `token.IsKind(SyntaxKind.StringLiteralToken)` and its enclosing `InvocationExpressionSyntax` is a `MemberAccessExpressionSyntax` named `GetTag`/`SetVirtualTag` on `ctx`, fetch `await _catalog.GetPathsAsync(token.ValueText, ct)` and map to `CompletionItem(label: path, insertText: path, detail: "tag path", kind: "Field")`. Add the ctor `public ScriptAnalysisService(IScriptTagCatalog catalog)` and store it; update DI/tests that `new ScriptAnalysisService()` to pass a catalog (the no-arg tests in Tasks 3/4 can switch to a `FakeCatalog` or you add a parameterless ctor that defers — prefer requiring the catalog and updating those test fixtures).
**Step 3: Run the full AdminUI.Tests** → green. **Step 4: Commit.**
---
### Task 7: Hover + signature help
**Classification:** standard
**Estimated implement time:** ~4 min
**Parallelizable with:** none (edits ScriptAnalysisService.cs)
**Files:**
- Modify: `ScriptAnalysisService.cs` (`Hover`, `SignatureHelp`)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/HoverSignatureTests.cs`
**Step 1: Failing tests** — hovering `GetTag` returns markdown containing `DataValueSnapshot`/`GetTag`; signature help inside `ctx.GetTag(` returns a label mentioning `path`.
**Step 2: Implement** using the semantic model from `Analyze(...)`:
- **Hover:** `model.GetSymbolInfo(node)` / `GetTypeInfo` at the mapped offset; format `symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)` + XML doc summary if present into markdown. (Simpler than scadabridge's domain hover — no `Parameters[...]` branch.)
- **SignatureHelp:** find the enclosing `InvocationExpressionSyntax`; resolve the called method symbol via `model.GetSymbolInfo(invocation.Expression)` (handle `OverloadResolutionFailure` candidates); build `SignatureHelpResponse` from the method's parameters; `ActiveParameter` = commas-before-cursor in the argument list. Reuse scadabridge's comma-counting/active-index logic (`ScriptAnalysisService.cs:840`).
**Step 3: Tests PASS. Step 4: Commit.**
---
### Task 8: Format (+ InlayHints stub)
**Classification:** small
**Estimated implement time:** ~3 min
**Parallelizable with:** none (edits ScriptAnalysisService.cs)
**Files:**
- Modify: `ScriptAnalysisService.cs` (`Format`; leave `InlayHints` empty as scadabridge does)
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/FormatTests.cs`
**Step 1: Failing test**`Format(new FormatRequest("var x=1;return x;"))` returns code containing newlines / canonical spacing (`!= input`, parses clean).
**Step 2: Implement** — copy scadabridge's `Format` verbatim (`CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script))``root.NormalizeWhitespace(" ", "\n").ToFullString()`, try/catch returns input). `InlayHints` stays `new(Array.Empty<InlayHint>())`.
**Step 3: Test PASS. Step 4: Commit.**
---
### Task 9: Wire the 6 Monaco language providers in monaco-init.js
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 11 (disjoint files); needs Tasks 28 endpoints live
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js` (`registerCSharpProviders`, `fetchDiagnostics`)
**Step 1:** Port scadabridge's six provider registrations + `fetchDiagnostics`, but **strip the SCADA context** (`lookupContext`, `GetContext`, all `declaredParameters/siblingScripts/...` body fields). Request bodies become exactly the v1 DTO shapes:
- completions → `{ codeText: model.getValue(), line, column }`
- hover → `{ codeText, line, column }`
- signature-help → `{ codeText, line, column }`
- format → `{ code: model.getValue() }`
- inlay-hints → `{ code: model.getValue() }`
- diagnostics → `{ code: model.getValue() }`
Keep `KIND_MAP`, the snippet-range handling, the response→Monaco mapping, and `monaco.editor.setModelMarkers(model, "otopcua", markers)`. Each provider keeps the `try/catch → return empty` guard so a transient endpoint failure degrades gracefully.
**Step 2: Build (no test — JS).** **Step 3: Commit.**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js
git commit -m "feat(adminui): wire Monaco language providers to /api/script-analysis"
```
---
### Task 10: Swap ScriptEdit page to MonacoEditor
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 11 (disjoint files); needs Task 1
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor`
- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js`
**Step 1:** Replace the `<InputTextArea id="script-source" @bind-Value="_form.SourceCode" .../>` with:
```razor
<MonacoEditor @bind-Value="_form.SourceCode" Height="420px" />
```
(`@bind-Value` wires `Value` + `ValueChanged`.) Remove the `OnAfterRenderAsync` block that injected `monaco-loader.js` via `JS.InvokeVoidAsync("eval", ...)` + `Task.Delay(50)` + `otOpcUaScriptEditor.attach`, and the now-unused `IJSRuntime`/`_loaded` plumbing if nothing else uses them. Keep the SHA-256-on-save path untouched.
**Step 2:** `git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js`.
**Step 3: Build.** **Step 4: Commit.**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor
git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js
git commit -m "feat(adminui): ScriptEdit uses MonacoEditor; drop CDN loader"
```
---
### Task 11: VirtualTagModal inline script-source panel
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10 (disjoint files); needs Task 1
**Files:**
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` + `UnsTreeService.cs` (3 new methods)
- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor`
- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs`
**Step 1: Failing service tests** (in-memory EF): `GetScriptSourceAsync(scriptId)` returns `(SourceCode, RowVersion, Name)`; `CountVirtualTagsUsingScriptAsync(scriptId)` returns the number of `VirtualTags` with that `ScriptId`; `UpdateScriptSourceAsync(scriptId, source, rowVersion)` recomputes the SHA-256 `SourceHash`, succeeds on a matching RowVersion, and returns a concurrency error on a stale one.
**Step 2: Implement** the 3 service methods (mirror the existing `LoadScriptsAsync`/Update patterns in `UnsTreeService`; reuse the page's existing `HashSource`/SHA-256 helper — extract it to the service so both call sites share it). Result type: reuse the existing `(bool Ok, string? Error)`-style result used by `UpdateTagAsync`.
**Step 3:** In `VirtualTagModal.razor`, below the Script dropdown, add a collapsible **"Script source"** panel shown when a script is selected:
- On script selection (or modal open with an existing `ScriptId`), call `GetScriptSourceAsync` + `CountVirtualTagsUsingScriptAsync` and show a notice: `Editing shared script "{Name}" — used by {N} virtual tag(s). Changes affect all of them.`
- `<MonacoEditor @bind-Value="_scriptSource" Height="300px" />`
- A dedicated **Save script** button calling `UpdateScriptSourceAsync(scriptId, _scriptSource, _scriptRowVersion)`; on success refresh `_scriptRowVersion` + show a saved indicator; on concurrency error show the standard reload message. This Save is **separate** from the virtual-tag Create/Save (`SaveAsync`) — it does not touch `_form`.
**Step 4: Tests green; build.** **Step 5: Commit.**
```bash
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor \
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs
git commit -m "feat(adminui): inline script-source editor in the virtual-tag modal"
```
---
### Task 12: Live verification in docker-dev
**Classification:** verification (no code)
**Estimated implement time:** ~5 min (interactive; user signs in)
**Parallelizable with:** none (needs all prior tasks)
**Files:** none.
**Steps:**
1. Rebuild central in docker-dev:
```bash
docker compose -f docker-dev/docker-compose.yml build central-1
docker compose -f docker-dev/docker-compose.yml up -d --no-deps --force-recreate central-1 central-2
```
2. **Ask the user to sign in** to the AdminUI (the agent must NOT enter credentials).
3. Drive the browser (mcp__claude-in-chrome) and verify on `/scripts/{id}`:
- Editor renders (Monaco, line numbers, theme); typing two-way binds.
- Type `ctx.` → completion lists `GetTag`/`SetVirtualTag`/`Now`/`Logger`.
- Type a bad line (`ctx.Nope();`) → red squiggle on the right line.
- `ctx.GetTag("` → tag-path suggestions appear.
- Hover `GetTag` → type info; Format button reflows; theme toggle works.
4. Verify the VirtualTagModal: open it, pick a script → the "Script source" panel loads with the "used by N" notice; edit + Save script; reopen to confirm persistence.
5. Reset the rig to baseline (remove any test artifacts created).
Report the results (with screenshots). Done = build clean + `dotnet test ZB.MOM.WW.OtOpcUa.slnx` green + this `/run` pass.
---
### Task 13: Docs + memory + finish
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Files:**
- Create: `docs/ScriptEditor.md` (the Monaco/Roslyn editor: architecture, endpoints, how to extend completions, vendoring/air-gap, the wrapper-fidelity notes)
- Modify: `docs/README.md` (index row), `CLAUDE.md` (a short "Scripting / script editor" note pointing at `docs/ScriptEditor.md`)
- Memory: update `MEMORY.md` + a new `project_monaco_script_editor.md` (architecture + the `#line`/offset-mapping + preamble-suppression gotchas + the tag-path-format finding from Task 5 Step 0)
**Steps:** write the docs, update the index + CLAUDE.md, save memory, build clean, commit. Then run **superpowers-extended-cc:finishing-a-development-branch** to merge `feat/monaco-script-editor` → `master`.
---
## Execution order & parallelism summary
```
Task 0 (refs+branch)
├─ Task 1 (frontend: vendor + MonacoEditor + minimal init) ∥ Task 2 (backend: contracts+seam+endpoints+DI)
Task 2 ─┬─ Task 3 (diagnostics) ∥ Task 5 (IScriptTagCatalog)
├─ Task 4 (completions) ∥ Task 5
├─ Task 6 (tag-path completion) ← needs 4 + 5
├─ Task 7 (hover + signature)
└─ Task 8 (format + inlay)
Task 9 (wire JS providers) ← needs 28 endpoints ∥ Task 10 (ScriptEdit swap) ∥ Task 11 (modal panel)
Task 12 (live /run) ← needs all
Task 13 (docs + finish)
```
Note: Tasks 3,4,6,7,8 all edit `ScriptAnalysisService.cs` — serialize them (not mutually parallelizable) even though their test files are disjoint. Task 5 (catalog) and Task 1 (frontend) are the genuine cross-cutting parallel opportunities.