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