From 93aa6c2f81c8aebe5e5100e4247adb77334a4a5e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 9 Jun 2026 13:56:59 -0400 Subject: [PATCH] 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. --- docs/plans/2026-06-09-monaco-script-editor.md | 797 ++++++++++++++++++ ...6-06-09-monaco-script-editor.md.tasks.json | 21 + 2 files changed, 818 insertions(+) create mode 100644 docs/plans/2026-06-09-monaco-script-editor.md create mode 100644 docs/plans/2026-06-09-monaco-script-editor.md.tasks.json diff --git a/docs/plans/2026-06-09-monaco-script-editor.md b/docs/plans/2026-06-09-monaco-script-editor.md new file mode 100644 index 00000000..821ca1af --- /dev/null +++ b/docs/plans/2026-06-09-monaco-script-editor.md @@ -0,0 +1,797 @@ +# Monaco Script Editor (Roslyn-backed IntelliSense) Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task. + +**Goal:** Replace OtOpcUa's CDN-Monaco-over-textarea script editor with a reusable, Roslyn-backed Monaco editor offering full IntelliSense (completions, hover, signature help, live diagnostics, formatting, tag-path completion), re-seated on OtOpcUa's *real* script compile context so the editor accepts/rejects exactly what publish enforces. + +**Architecture:** A new `ScriptAnalysis/` backend inside the AdminUI exposes `/api/script-analysis/*` minimal-API endpoints backed by `ScriptAnalysisService`, which compiles the user's source inside OtOpcUa's genuine evaluator wrapper (`public static object Run(ScriptGlobals 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 References, IReadOnlyList Imports)` — `src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptSandbox.cs:46` +- `ForbiddenTypeAnalyzer.Analyze(Compilation) → IReadOnlyList` — `ForbiddenTypeAnalyzer.cs:175,309` +- `DependencyExtractor.Extract(string) → DependencyExtractionResult(IReadOnlySet Reads, IReadOnlySet Writes, IReadOnlyList 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 ctx }` — `src/Core/Scripting.Abstractions/` +- `DataValueSnapshot(object? Value, uint StatusCode, DateTime? SourceTimestampUtc, DateTime ServerTimestampUtc)` — `Core.Abstractions/DataValueSnapshot.cs:17` +- `OtOpcUaConfigDbContext` with `DbSet Tags`, `DbSet VirtualTags`, `DbSet Equipment`, `DbSet +``` + +**Step 5: Build** +```bash +dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj +``` +Expected: succeeds. + +**Step 6: Commit** (stage by path; the monaco folder is large but committed deliberately): +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/lib/monaco \ + src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js \ + src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor \ + src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor +git commit -m "feat(adminui): vendor Monaco + reusable MonacoEditor component (no providers yet)" +``` + +--- + +### Task 2: ScriptAnalysis contracts + service analysis seam + endpoints + DI + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 1 (disjoint files) + +**Files:** +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs` +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs` +- Modify: the AdminUI service-registration site (find `AddAdminUI`/`AddOtOpcUaAdminUI` extension, else the `hasAdmin` block in `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs`) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs` (~line 185, after `app.MapAdminUI()`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptAnalysisServiceWrapperTests.cs` + +**Step 1: Contracts** — port scadabridge's `ScriptAnalysisContracts.cs` but **drop** `ScriptKind` and every SCADA shape (`ScriptShape`, `ParameterShape`, `AttributeShape`, `CompositionContext`) and all the context fields on the requests. Final shapes: +```csharp +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +public record DiagnoseRequest(string Code); +public record DiagnoseResponse(IReadOnlyList 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 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? 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 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). +```csharp +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; // ScriptSandbox, ForbiddenTypeAnalyzer, DependencyExtractor +using ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions; // VirtualTagContext, ScriptGlobals (confirm namespace) + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis; + +public sealed class ScriptAnalysisService +{ + private static readonly SandboxConfig Sandbox = ScriptSandbox.Build(typeof(VirtualTagContext)); + private static readonly string Preamble = BuildPreamble(); + private static readonly CSharpCompilationOptions CompileOptions = new( + OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release, + allowUnsafe: false, warningLevel: 4, nullableContextOptions: NullableContextOptions.Enable); + + private static string BuildPreamble() + { + var usings = string.Join("\n", Sandbox.Imports.Select(i => $"using {i};")); + return usings + "\n\n" + + "namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled;\n" + + "public static class CompiledScript\n{\n" + + " public static object Run(ScriptGlobals globals)\n {\n" + + " var ctx = globals.ctx;\n#line 1\n"; + // user source is appended after this, then " }\n}\n" + } + + /// Wrap user source in OtOpcUa's evaluator shape; returns the tree, semantic model, and the prefix length (chars before user source). + 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); + } + + /// (line,col) in user-source coords → physical offset in the wrapped document. + 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()); // Task 3 + public Task CompleteAsync(CompletionsRequest req) => Task.FromResult(new CompletionsResponse(Array.Empty())); // 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()); // Task 8 (stays empty) +} +``` +(Verify the exact namespace of `ScriptGlobals`/`VirtualTagContext` — the explorer reported `ZB.MOM.WW.OtOpcUa.Core.Scripting` for the types living under the `Scripting.Abstractions` project; match the actual `namespace` declarations.) + +**Step 3: Endpoints** — port `ScriptAnalysisEndpoints.cs`, drop the `/run` route, swap auth: +```csharp +public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints) +{ + var group = endpoints.MapGroup("/api/script-analysis").RequireAuthorization("FleetAdmin"); + group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r))); + group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r))); + group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s) => Results.Ok(s.Hover(r))); + group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r))); + group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r))); + group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r))); + return endpoints; +} +``` + +**Step 4: DI** — register `services.AddScoped();` (and in Task 5, `services.AddScoped();`) in the AdminUI service-registration extension. + +**Step 5: Map** — in `Program.cs` `hasAdmin` block, after `app.MapAdminUI();`: +```csharp +app.MapScriptAnalysisEndpoints(); +``` + +**Step 6: Test the wrapper seam** (the one piece of Task 2 with logic worth a test). Since `Analyze`/`OffsetInWrapped` are private, expose a tiny internal test hook OR test indirectly via Task 3. To keep Task 2 self-verifying, add an `internal` method `AnalyzeForTest(string) => Analyze(...)` guarded by `[assembly: InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.AdminUI.Tests")]`, and assert that for source `return 1;` the compilation reports **no** user-span diagnostics and that a member-access after `ctx.` resolves to `VirtualTagContext`. (If you prefer, fold this verification into Task 3's diagnostics tests and keep Task 2 to a build-only check — acceptable.) + +**Step 7: Build + commit** +```bash +dotnet build ZB.MOM.WW.OtOpcUa.slnx +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis \ + src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs \ + \ + tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptAnalysisServiceWrapperTests.cs +git commit -m "feat(adminui): script-analysis contracts, wrapper seam, endpoints + DI" +``` + +--- + +### Task 3: Diagnostics — Roslyn ∪ ForbiddenTypeAnalyzer ∪ DependencyExtractor + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 5 (disjoint files) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` (`Diagnose`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs` + +**Step 1: Write failing tests** (xUnit + Shouldly): +```csharp +public sealed class DiagnoseTests +{ + private static readonly ScriptAnalysisService Svc = new(); + private static IReadOnlyList Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers; + + [Fact] public void Clean_script_has_no_markers() + => Diag("""return (double)ctx.GetTag("A").Value * 2.0;""").ShouldBeEmpty(); + + [Fact] public void Roslyn_error_is_reported_on_the_user_line() + { + var m = Diag("return ctx.NoSuchMember();"); + m.ShouldNotBeEmpty(); + m[0].Severity.ShouldBe(8); + m[0].StartLineNumber.ShouldBe(1); // #line 1 maps to user source + } + + [Fact] public void Forbidden_type_is_flagged() + => Diag("""var c = new System.Net.Http.HttpClient(); return 0;""") + .ShouldContain(m => m.Message.Contains("HttpClient") || m.Code.StartsWith("OTSCRIPT") || m.Severity == 8); + + [Fact] public void Dynamic_tag_path_is_flagged() + { + var m = Diag("""var p = "A"; return ctx.GetTag(p).Value;"""); + m.ShouldContain(x => x.Message.Contains("literal")); // DependencyExtractor rejection + } + + [Fact] public void Preamble_diagnostics_are_suppressed() + => Diag("var x = 1;").ShouldNotContain(m => m.Code == "CS0161"); // missing-return on Run() is hidden +} +``` +Run: `dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter FullyQualifiedName~DiagnoseTests` → FAIL. + +**Step 2: Implement `Diagnose`:** +```csharp +public DiagnoseResponse Diagnose(DiagnoseRequest req) +{ + if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty()); + var (tree, _, prefix) = Analyze(req.Code); + var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions); + var markers = new List(); + + // (1) Roslyn — only diagnostics physically inside the user source (suppress preamble noise like CS0161 on Run()). + foreach (var d in compilation.GetDiagnostics()) + { + if (d.Severity < DiagnosticSeverity.Info || !d.Location.IsInSource) continue; + if (d.Location.SourceSpan.Start < prefix) continue; + markers.Add(ToMarker(d.Location.GetLineSpan(), Sev(d.Severity), d.GetMessage(), d.Id)); + } + // (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; GetLineSpan honors #line → user coords. + foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation)) + markers.Add(ToMarker(tree.GetLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN")); + // (3) DependencyExtractor — spans are offsets in the *user* source; map directly. + foreach (var r in DependencyExtractor.Extract(req.Code).Rejections) + markers.Add(ToUserMarker(req.Code, r.Span, r.Message, "OTSCRIPT_DYNPATH")); + + return new DiagnoseResponse(markers); +} + +private static int Sev(DiagnosticSeverity s) => s switch { + DiagnosticSeverity.Error => 8, DiagnosticSeverity.Warning => 4, DiagnosticSeverity.Info => 2, _ => 1 }; + +private static DiagnosticMarker ToMarker(FileLinePositionSpan span, int sev, string msg, string code) => new( + sev, span.StartLinePosition.Line + 1, span.StartLinePosition.Character + 1, + span.EndLinePosition.Line + 1, span.EndLinePosition.Character + 1, msg, code); + +private static DiagnosticMarker ToUserMarker(string userSource, Microsoft.CodeAnalysis.Text.TextSpan span, string msg, string code) +{ + var (sl, sc) = LineCol(userSource, span.Start); + var (el, ec) = LineCol(userSource, span.End); + return new(8, sl, sc, el, ec, msg, code); +} +private static (int, int) LineCol(string code, int offset) +{ + int line = 1, col = 1; + for (int i = 0; i < offset && i < code.Length; i++) + if (code[i] == '\n') { line++; col = 1; } else col++; + return (line, col); +} +``` + +**Step 3: Run tests** → PASS. (If `ForbiddenTypeAnalyzer` reports nothing for `HttpClient` because the reference set already excludes `System.Net.Http`, the Roslyn pass will instead emit a CS-level "type/namespace not found" error in user-span — the test's `||` covers that. Confirm which path fires and tighten the assert.) + +**Step 4: Commit** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs +git commit -m "feat(adminui): script diagnostics (Roslyn + forbidden-type + dynamic-path)" +``` + +--- + +### Task 4: Completions — scope + dot-member + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 5 (disjoint files) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` (`CompleteAsync` + `ToCompletionItem` + `TryGetDotMembers`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs` + +**Step 1: Failing tests:** +```csharp +public sealed class CompletionTests +{ + private static readonly ScriptAnalysisService Svc = new(); + private static async Task> Complete(string code, int line, int col) + => (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items; + + [Fact] public async Task Dot_after_ctx_offers_context_members() + { + var items = await Complete("ctx.", 1, 5); // caret right after "ctx." + var labels = items.Select(i => i.Label).ToList(); + labels.ShouldContain("GetTag"); + labels.ShouldContain("SetVirtualTag"); + labels.ShouldContain("Now"); + } + + [Fact] public async Task Scope_completion_includes_ctx_local() + => (await Complete("c", 1, 2)).Select(i => i.Label).ShouldContain("ctx"); +} +``` +Run filter `~CompletionTests` → FAIL. + +**Step 2: Implement** — port scadabridge's `CompleteAsync` core, but build the document via `Analyze(...)` and map the offset via `OffsetInWrapped(...)`; the string-literal branch is added in Task 6 (leave a `// Task 6: tag-path literals` placeholder): +```csharp +public async Task CompleteAsync(CompletionsRequest req) +{ + if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty()); + var (tree, model, prefix) = Analyze(req.CodeText); + var position = OffsetInWrapped(req.CodeText, req.Line, req.Column, prefix); + var root = await tree.GetRootAsync(); + var token = root.FindToken(Math.Max(0, position - 1)); + + // Task 6: if token is a string literal inside ctx.GetTag/SetVirtualTag → tag-path completion. + + var dot = TryGetDotMembers(token, model); + if (dot != null) return new CompletionsResponse(dot); + + var scoped = model.LookupSymbols(position) + .Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name)) + .GroupBy(s => s.Name).Select(g => g.First()) + .Select(ToCompletionItem).Take(200).ToList(); + return new CompletionsResponse(scoped); +} +``` +Copy `TryGetDotMembers` and `ToCompletionItem` verbatim from scadabridge (`ScriptAnalysisService.cs:885,1271`) — they are pure Roslyn, no domain coupling. + +**Step 3: Run tests** → PASS. + +**Step 4: Commit** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \ + tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs +git commit -m "feat(adminui): scope + dot-member script completions" +``` + +--- + +### Task 5: IScriptTagCatalog (tag + virtual-tag path provider) + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 3, Task 4 (disjoint files) + +**Files:** +- Create: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs` (+ `ScriptTagCatalog`) +- Modify: AdminUI service registration (`AddScoped()`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs` + +**Step 0 (verify the canonical path format — do this FIRST):** Read how the runtime resolves a `ctx.GetTag("…")` literal to a configured tag (the virtual-tag engine's subscription/read-cache key construction, and how the equipment-namespace browse path is built). Confirm the exact separator and segment order a script author types — i.e. whether it is `Area/Line/Equipment/Name`, `Equipment/Name`, or `FolderPath/Name`. **Match the catalog's projected strings to that.** If the format genuinely can't be pinned down, project both the leaf `Name` and a best-effort full path and note the ambiguity in a code comment + the task report (do NOT silently guess). + +**Step 1: Failing test** (in-memory EF — see `Microsoft.EntityFrameworkCore.InMemory`, already referenced by AdminUI.Tests): +```csharp +public sealed class ScriptTagCatalogTests +{ + private static OtOpcUaConfigDbContext NewDb() + { + var opts = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + return new OtOpcUaConfigDbContext(opts); + } + + [Fact] public async Task Returns_configured_tag_and_virtual_tag_paths() + { + await using var db = NewDb(); + // seed one Equipment + one Tag + one VirtualTag (use the confirmed path convention from Step 0) + // ... + await db.SaveChangesAsync(); + var catalog = new ScriptTagCatalog(/* IDbContextFactory or db */); + var paths = await catalog.GetPathsAsync(null, default); + paths.ShouldContain(/* the expected tag path */); + paths.ShouldContain(/* the expected virtual-tag path */); + } + + [Fact] public async Task Filter_prefix_narrows_results() { /* assert prefix filtering */ } +} +``` + +**Step 2: Implement:** +```csharp +public interface IScriptTagCatalog +{ + Task> GetPathsAsync(string? filter, CancellationToken ct); +} + +public sealed class ScriptTagCatalog(IDbContextFactory dbf) : IScriptTagCatalog +{ + public async Task> 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` to match `UnsTreeService`'s pattern.) + +**Step 3: Run tests** → PASS. **Step 4: Register in DI. Step 5: Commit.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs \ + \ + tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs +git commit -m "feat(adminui): IScriptTagCatalog for tag-path completion" +``` + +--- + +### Task 6: Tag-path string-literal completion + +**Classification:** standard +**Estimated implement time:** ~4 min +**Parallelizable with:** none (edits ScriptAnalysisService.cs; needs Tasks 4 + 5) + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs` (inject `IScriptTagCatalog`, fill the Task-6 placeholder in `CompleteAsync`) +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/TagPathCompletionTests.cs` + +**Step 1: Failing test** with a fake catalog: +```csharp +private sealed class FakeCatalog : IScriptTagCatalog +{ + public Task> GetPathsAsync(string? f, CancellationToken ct) + => Task.FromResult>(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())`. + +**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.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js +git commit -m "feat(adminui): wire Monaco language providers to /api/script-analysis" +``` + +--- + +### Task 10: Swap ScriptEdit page to MonacoEditor + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** Task 11 (disjoint files); needs Task 1 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor` +- Delete: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js` + +**Step 1:** Replace the `` with: +```razor + +``` +(`@bind-Value` wires `Value` + `ValueChanged`.) Remove the `OnAfterRenderAsync` block that injected `monaco-loader.js` via `JS.InvokeVoidAsync("eval", ...)` + `Task.Delay(50)` + `otOpcUaScriptEditor.attach`, and the now-unused `IJSRuntime`/`_loaded` plumbing if nothing else uses them. Keep the SHA-256-on-save path untouched. + +**Step 2:** `git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js`. + +**Step 3: Build.** **Step 4: Commit.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor +git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js +git commit -m "feat(adminui): ScriptEdit uses MonacoEditor; drop CDN loader" +``` + +--- + +### Task 11: VirtualTagModal inline script-source panel + +**Classification:** standard +**Estimated implement time:** ~5 min +**Parallelizable with:** Task 10 (disjoint files); needs Task 1 + +**Files:** +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs` + `UnsTreeService.cs` (3 new methods) +- Modify: `src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor` +- Test: `tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs` + +**Step 1: Failing service tests** (in-memory EF): `GetScriptSourceAsync(scriptId)` returns `(SourceCode, RowVersion, Name)`; `CountVirtualTagsUsingScriptAsync(scriptId)` returns the number of `VirtualTags` with that `ScriptId`; `UpdateScriptSourceAsync(scriptId, source, rowVersion)` recomputes the SHA-256 `SourceHash`, succeeds on a matching RowVersion, and returns a concurrency error on a stale one. + +**Step 2: Implement** the 3 service methods (mirror the existing `LoadScriptsAsync`/Update patterns in `UnsTreeService`; reuse the page's existing `HashSource`/SHA-256 helper — extract it to the service so both call sites share it). Result type: reuse the existing `(bool Ok, string? Error)`-style result used by `UpdateTagAsync`. + +**Step 3:** In `VirtualTagModal.razor`, below the Script dropdown, add a collapsible **"Script source"** panel shown when a script is selected: +- On script selection (or modal open with an existing `ScriptId`), call `GetScriptSourceAsync` + `CountVirtualTagsUsingScriptAsync` and show a notice: `Editing shared script "{Name}" — used by {N} virtual tag(s). Changes affect all of them.` +- `` +- A dedicated **Save script** button calling `UpdateScriptSourceAsync(scriptId, _scriptSource, _scriptRowVersion)`; on success refresh `_scriptRowVersion` + show a saved indicator; on concurrency error show the standard reload message. This Save is **separate** from the virtual-tag Create/Save (`SaveAsync`) — it does not touch `_form`. + +**Step 4: Tests green; build.** **Step 5: Commit.** +```bash +git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs \ + src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor \ + tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs +git commit -m "feat(adminui): inline script-source editor in the virtual-tag modal" +``` + +--- + +### Task 12: Live verification in docker-dev + +**Classification:** verification (no code) +**Estimated implement time:** ~5 min (interactive; user signs in) +**Parallelizable with:** none (needs all prior tasks) + +**Files:** none. + +**Steps:** +1. Rebuild central in docker-dev: + ```bash + docker compose -f docker-dev/docker-compose.yml build central-1 + docker compose -f docker-dev/docker-compose.yml up -d --no-deps --force-recreate central-1 central-2 + ``` +2. **Ask the user to sign in** to the AdminUI (the agent must NOT enter credentials). +3. Drive the browser (mcp__claude-in-chrome) and verify on `/scripts/{id}`: + - Editor renders (Monaco, line numbers, theme); typing two-way binds. + - Type `ctx.` → completion lists `GetTag`/`SetVirtualTag`/`Now`/`Logger`. + - Type a bad line (`ctx.Nope();`) → red squiggle on the right line. + - `ctx.GetTag("` → tag-path suggestions appear. + - Hover `GetTag` → type info; Format button reflows; theme toggle works. +4. Verify the VirtualTagModal: open it, pick a script → the "Script source" panel loads with the "used by N" notice; edit + Save script; reopen to confirm persistence. +5. Reset the rig to baseline (remove any test artifacts created). + +Report the results (with screenshots). Done = build clean + `dotnet test ZB.MOM.WW.OtOpcUa.slnx` green + this `/run` pass. + +--- + +### Task 13: Docs + memory + finish + +**Classification:** small +**Estimated implement time:** ~4 min +**Parallelizable with:** none + +**Files:** +- Create: `docs/ScriptEditor.md` (the Monaco/Roslyn editor: architecture, endpoints, how to extend completions, vendoring/air-gap, the wrapper-fidelity notes) +- Modify: `docs/README.md` (index row), `CLAUDE.md` (a short "Scripting / script editor" note pointing at `docs/ScriptEditor.md`) +- Memory: update `MEMORY.md` + a new `project_monaco_script_editor.md` (architecture + the `#line`/offset-mapping + preamble-suppression gotchas + the tag-path-format finding from Task 5 Step 0) + +**Steps:** write the docs, update the index + CLAUDE.md, save memory, build clean, commit. Then run **superpowers-extended-cc:finishing-a-development-branch** to merge `feat/monaco-script-editor` → `master`. + +--- + +## Execution order & parallelism summary + +``` +Task 0 (refs+branch) + ├─ Task 1 (frontend: vendor + MonacoEditor + minimal init) ∥ Task 2 (backend: contracts+seam+endpoints+DI) +Task 2 ─┬─ Task 3 (diagnostics) ∥ Task 5 (IScriptTagCatalog) + ├─ Task 4 (completions) ∥ Task 5 + ├─ Task 6 (tag-path completion) ← needs 4 + 5 + ├─ Task 7 (hover + signature) + └─ Task 8 (format + inlay) +Task 9 (wire JS providers) ← needs 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. diff --git a/docs/plans/2026-06-09-monaco-script-editor.md.tasks.json b/docs/plans/2026-06-09-monaco-script-editor.md.tasks.json new file mode 100644 index 00000000..6259e675 --- /dev/null +++ b/docs/plans/2026-06-09-monaco-script-editor.md.tasks.json @@ -0,0 +1,21 @@ +{ + "planPath": "docs/plans/2026-06-09-monaco-script-editor.md", + "branch": "feat/monaco-script-editor", + "tasks": [ + {"id": 163, "planTask": 0, "subject": "Task 0: AdminUI project references + feature branch", "status": "pending"}, + {"id": 164, "planTask": 1, "subject": "Task 1: Vendor Monaco + MonacoEditor.razor + minimal monaco-init.js + App.razor", "status": "pending", "blockedBy": [163]}, + {"id": 165, "planTask": 2, "subject": "Task 2: ScriptAnalysis contracts + service seam + endpoints + DI", "status": "pending", "blockedBy": [163]}, + {"id": 166, "planTask": 3, "subject": "Task 3: Diagnostics (Roslyn + ForbiddenTypeAnalyzer + DependencyExtractor)", "status": "pending", "blockedBy": [165]}, + {"id": 167, "planTask": 4, "subject": "Task 4: Completions (scope + dot-member)", "status": "pending", "blockedBy": [166]}, + {"id": 168, "planTask": 5, "subject": "Task 5: IScriptTagCatalog (tag + virtual-tag path provider)", "status": "pending", "blockedBy": [165]}, + {"id": 169, "planTask": 6, "subject": "Task 6: Tag-path string-literal completion", "status": "pending", "blockedBy": [167, 168]}, + {"id": 170, "planTask": 7, "subject": "Task 7: Hover + signature help", "status": "pending", "blockedBy": [169]}, + {"id": 171, "planTask": 8, "subject": "Task 8: Format (+ InlayHints stub)", "status": "pending", "blockedBy": [170]}, + {"id": 172, "planTask": 9, "subject": "Task 9: Wire 6 Monaco language providers in monaco-init.js", "status": "pending", "blockedBy": [164, 171]}, + {"id": 173, "planTask": 10, "subject": "Task 10: Swap ScriptEdit page to MonacoEditor", "status": "pending", "blockedBy": [164]}, + {"id": 174, "planTask": 11, "subject": "Task 11: VirtualTagModal inline script-source panel", "status": "pending", "blockedBy": [164]}, + {"id": 175, "planTask": 12, "subject": "Task 12: Live verification in docker-dev", "status": "pending", "blockedBy": [172, 173, 174]}, + {"id": 176, "planTask": 13, "subject": "Task 13: Docs + memory + finish branch", "status": "pending", "blockedBy": [175]} + ], + "lastUpdated": "2026-06-09" +}