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

46 KiB
Raw 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> ScriptsConfiguration/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: diagnosticstree.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

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

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

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


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.