Files
lmxopcua/docs/plans/2026-06-09-monaco-script-editor.md
Joseph Doherty 7d989fb529
v2-ci / unit-tests (push) Has been cancelled
docs(security): script authoring and deploy are Administrator OR Designer (lmxopcua#779)
The docs said script authoring was Admin-gated (VirtualTags.md "Known
resource limits") and that a Designer "cannot publish" (security.md roles
table). The code admits Designer to both: Scripts, ScriptEdit, EquipmentPage
(virtual tags, scripted alarms), /api/script-analysis/* and Deployments all
carry AdminUiPolicies.ConfigEditor = RequireRole("Administrator",
"Designer"). That is a recorded decision, not drift: AdminUI-001 (98b27fc1)
loosened the analysis backend from FleetAdmin to Administrator,Designer to
match the page gate, and R2-05 converged the same pages on ConfigEditor
"with the same semantics". So the docs follow the code:

- VirtualTags.md: the primary control is ConfigEditor (Administrator or
  Designer), which also gates deploy.
- security.md roles table: Designer = the whole ConfigEditor surface incl.
  scripts and deploy; Administrator = Designer + the FleetAdmin-only surface
  (role grants, API keys, certificate actions, manual failover) — the old
  "publish, cluster/node CRUD" were ConfigEditor all along.
- 2026-06-09 Monaco plan: its FleetAdmin gate is marked superseded.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
2026-11-09 17:44:35 -05:00

46 KiB
Raw Permalink Blame History

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

Superseded (AdminUI-001, 98b27fc1; recorded again in lmxopcua#779): the shipped gate is the ConfigEditor policy — Administrator or Designer — on the script pages, /api/script-analysis/* and /deployments alike. Designer was being 403'd by the analysis backend while the page admitted it, so the backend was loosened to match the page, not the reverse.

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 19–40)

Step 1: Create the branch

git checkout -b feat/monaco-script-editor

Step 2: Add the two project references (alongside the existing Configuration ref):

<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

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

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

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:

(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">:

<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>

Step 5: Build

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

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:

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 3–8). 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).

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:

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>();:

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

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

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:

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

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:

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

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

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

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:

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.

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:

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 2–8 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.

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:

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

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.

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:
    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 2–8 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.