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.
This commit is contained in:
Joseph Doherty
2026-06-09 13:56:59 -04:00
parent 7a03d01613
commit 93aa6c2f81
2 changed files with 818 additions and 0 deletions
@@ -0,0 +1,797 @@
# 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.
@@ -0,0 +1,21 @@
{
"planPath": "docs/plans/2026-06-09-monaco-script-editor.md",
"branch": "feat/monaco-script-editor",
"tasks": [
{"id": 163, "planTask": 0, "subject": "Task 0: AdminUI project references + feature branch", "status": "pending"},
{"id": 164, "planTask": 1, "subject": "Task 1: Vendor Monaco + MonacoEditor.razor + minimal monaco-init.js + App.razor", "status": "pending", "blockedBy": [163]},
{"id": 165, "planTask": 2, "subject": "Task 2: ScriptAnalysis contracts + service seam + endpoints + DI", "status": "pending", "blockedBy": [163]},
{"id": 166, "planTask": 3, "subject": "Task 3: Diagnostics (Roslyn + ForbiddenTypeAnalyzer + DependencyExtractor)", "status": "pending", "blockedBy": [165]},
{"id": 167, "planTask": 4, "subject": "Task 4: Completions (scope + dot-member)", "status": "pending", "blockedBy": [166]},
{"id": 168, "planTask": 5, "subject": "Task 5: IScriptTagCatalog (tag + virtual-tag path provider)", "status": "pending", "blockedBy": [165]},
{"id": 169, "planTask": 6, "subject": "Task 6: Tag-path string-literal completion", "status": "pending", "blockedBy": [167, 168]},
{"id": 170, "planTask": 7, "subject": "Task 7: Hover + signature help", "status": "pending", "blockedBy": [169]},
{"id": 171, "planTask": 8, "subject": "Task 8: Format (+ InlayHints stub)", "status": "pending", "blockedBy": [170]},
{"id": 172, "planTask": 9, "subject": "Task 9: Wire 6 Monaco language providers in monaco-init.js", "status": "pending", "blockedBy": [164, 171]},
{"id": 173, "planTask": 10, "subject": "Task 10: Swap ScriptEdit page to MonacoEditor", "status": "pending", "blockedBy": [164]},
{"id": 174, "planTask": 11, "subject": "Task 11: VirtualTagModal inline script-source panel", "status": "pending", "blockedBy": [164]},
{"id": 175, "planTask": 12, "subject": "Task 12: Live verification in docker-dev", "status": "pending", "blockedBy": [172, 173, 174]},
{"id": 176, "planTask": 13, "subject": "Task 13: Docs + memory + finish branch", "status": "pending", "blockedBy": [175]}
],
"lastUpdated": "2026-06-09"
}