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.
46 KiB
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.razorsrc/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/js/monaco-init.jssrc/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:46ForbiddenTypeAnalyzer.Analyze(Compilation) → IReadOnlyList<ForbiddenTypeRejection(TextSpan Span, string TypeName, string Namespace, string Message)>—ForbiddenTypeAnalyzer.cs:175,309DependencyExtractor.Extract(string) → DependencyExtractionResult(IReadOnlySet<string> Reads, IReadOnlySet<string> Writes, IReadOnlyList<DependencyRejection(TextSpan Span, string Message)> Rejections){ bool IsValid }—DependencyExtractor.cs:42,147,156VirtualTagContext : ScriptContext(GetTag(string)→DataValueSnapshot,SetVirtualTag(string,object?),Now,Logger, staticScriptContext.Deadband(double,double,double)) andScriptGlobals<TContext>{ TContext ctx }—src/Core/Scripting.Abstractions/DataValueSnapshot(object? Value, uint StatusCode, DateTime? SourceTimestampUtc, DateTime ServerTimestampUtc)—Core.Abstractions/DataValueSnapshot.cs:17OtOpcUaConfigDbContextwithDbSet<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(aScriptis shared across virtual tags of differingDataType; per-tag return coercion stays a runtime concern). - The wrapper preamble (usings +
Runsignature +var ctx = globals.ctx;) precedes a#line 1directive, 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 offsetprefixLength + 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 theRunmethod when the user hasn't typedreturnyet) doesn't show as a phantom marker. (Missing-returnenforcement stays a runtime/publish concern for v1.) - We use
CSharpCompilation(notCSharpScript), soMicrosoft.CodeAnalysis.CSharp.Scriptingis not needed; Roslyn core (Microsoft.CodeAnalysis.CSharp, incl.NormalizeWhitespace) arrives transitively through theCore.Scriptingproject reference. AddMicrosoft.CodeAnalysis.CSharp.Workspacesonly if a build error names a missing type.
Task 0: AdminUI project references + feature branch
Classification: small Estimated implement time: ~3 min Parallelizable with: none (prerequisite for backend tasks)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj(ItemGroup of ProjectReferences, ~lines 19–40)
Step 1: Create the branch
git checkout -b feat/monaco-script-editor
Step 2: Add the two project references (alongside the existing Configuration ref):
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting\ZB.MOM.WW.OtOpcUa.Core.Scripting.csproj" />
<ProjectReference Include="..\..\Core\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj" />
(Confirm the exact directory names of those two projects on disk first — ls src/Core/ | grep Scripting — and match them.)
Step 3: Build to confirm Roslyn resolves transitively
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
Expected: build succeeds. (ScriptSandbox, CSharpCompilation, VirtualTagContext are now referenceable.)
Step 4: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
git commit -m "build(adminui): reference Core.Scripting for the script-analysis backend"
Task 1: Vendor Monaco + reusable MonacoEditor.razor + minimal monaco-init.js + App.razor wiring
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 2 (disjoint files)
Files:
- Create (copy):
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/lib/monaco/**(from scadabridge) - Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js - Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor:23-25(add one<script>)
No unit tests (UI/JS — proven by the live /run in Task 12).
Step 1: Vendor Monaco (file copy, then stage the folder by path):
cp -R /Users/dohertj2/Desktop/scadabridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/lib/monaco \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/lib/monaco
(~15 MB / 121 files under wwwroot/lib/monaco/vs/. This is the air-gap-safe replacement for the CDN.)
Step 2: Create monaco-init.js — port scadabridge's file but MINIMAL for now (no language providers yet; those land in Task 9). Keep only the AMD loader + the editor lifecycle + value sync. Critically, set the base path to OtOpcUa's _content:
(function () {
const VS_BASE = "/_content/ZB.MOM.WW.OtOpcUa.AdminUI/lib/monaco/vs";
const editors = {};
let readyPromise = null;
function ensureLoaded() {
if (readyPromise) return readyPromise;
readyPromise = new Promise(function (resolve, reject) {
const loader = document.createElement("script");
loader.src = VS_BASE + "/loader.js";
loader.onload = function () {
require.config({ paths: { vs: VS_BASE } });
require(["vs/editor/editor.main"], function () {
registerCSharpProviders(); // no-op until Task 9 fills it in
resolve();
});
};
loader.onerror = function (e) { reject(e); };
document.head.appendChild(loader);
});
return readyPromise;
}
function registerCSharpProviders() { /* Task 9: completions/hover/signature/format/inlay */ }
async function fetchDiagnostics(model) { return []; } // Task 9 wires the real fetch
async function createEditor(id, host, options, dotNetRef) {
await ensureLoaded();
if (!host) return;
const editor = monaco.editor.create(host, {
value: options.value || "", language: options.language || "csharp",
theme: "vs", minimap: { enabled: false }, scrollBeyondLastLine: false,
automaticLayout: true, fontSize: 13, lineNumbers: "on",
renderLineHighlight: "line", readOnly: !!options.readOnly,
tabSize: 4, insertSpaces: true, wordWrap: "off", fixedOverflowWidgets: true
});
let diagTimer = null;
const scheduleDiagnostics = function () {
if (diagTimer) clearTimeout(diagTimer);
diagTimer = setTimeout(async function () {
const model = editor.getModel(); if (!model) return;
const markers = await fetchDiagnostics(model);
monaco.editor.setModelMarkers(model, "otopcua", markers);
dotNetRef.invokeMethodAsync("OnMarkersChanged", markers).catch(function () {});
}, 500);
};
editor.onDidChangeModelContent(function () {
dotNetRef.invokeMethodAsync("OnValueChanged", editor.getValue()).catch(function () {});
if (options.language === "csharp") scheduleDiagnostics();
});
editors[id] = { editor: editor, dotNetRef: dotNetRef };
if (options.language === "csharp") scheduleDiagnostics();
}
function setEditorOption(id, name, value) {
const e = editors[id]; if (!e) return;
if (name === "theme") { monaco.editor.setTheme(value); return; }
const u = {}; u[name] = value; e.editor.updateOptions(u);
}
function format(id) { editors[id]?.editor.getAction("editor.action.formatDocument")?.run(); }
function revealLine(id, line, col) {
const e = editors[id]; if (!e) return;
e.editor.revealLineInCenter(line); e.editor.setPosition({ lineNumber: line, column: col || 1 }); e.editor.focus();
}
function setValue(id, v) { const e = editors[id]; if (e && e.editor.getValue() !== v) e.editor.setValue(v || ""); }
function getValue(id) { const e = editors[id]; return e ? e.editor.getValue() : null; }
function setMarkers(id, m) { const e = editors[id]; const model = e && e.editor.getModel(); if (model) monaco.editor.setModelMarkers(model, "otopcua", m || []); }
function dispose(id) { const e = editors[id]; if (!e) return; try { e.editor.dispose(); } catch (x) {} delete editors[id]; }
window.MonacoBlazor = { createEditor, setValue, getValue, setMarkers, setEditorOption, format, revealLine, dispose };
// expose the editors map + helpers Task 9 will close over:
window.__otopcuaEditors = editors;
})();
(Task 9 reopens this file to add the providers + the real fetchDiagnostics. Keeping editors on window.__otopcuaEditors lets Task 9's providers find the model→editor mapping if needed; for v1 the providers don't need per-editor .NET context, so this may go unused — fine.)
Step 3: Create MonacoEditor.razor — port scadabridge's component but strip all SCADA context (ScriptKind, DeclaredParameters, DeclaredParameterShapes, SiblingScripts, SelfAttributes, Children, Parent, GetContext, the ScadaContext record). Keep: Value/ValueChanged, Language="csharp", Height, ReadOnly, ShowToolbar, MarkersChanged, the toolbar (Format/Wrap/Minimap/Theme), OnAfterRenderAsync (create on first render + setValue on Value change), [JSInvokable] OnValueChanged/OnMarkersChanged, RevealLineAsync, SafeInvokeAsync, DisposeAsync. Namespace: ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared. Use a DiagnosticMarker[] parameter type for OnMarkersChanged (the DTO created in Task 2 — until then, type it object[] and tighten in Task 9, OR sequence Task 2 before this; simplest: keep OnMarkersChanged(object[] markers) here and let Task 9 finalize). The host div + create-options match scadabridge.
Step 4: Wire the script in App.razor — add before <script src="_framework/blazor.web.js">:
<script src="_content/ZB.MOM.WW.OtOpcUa.AdminUI/js/monaco-init.js"></script>
Step 5: Build
dotnet build src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ZB.MOM.WW.OtOpcUa.AdminUI.csproj
Expected: succeeds.
Step 6: Commit (stage by path; the monaco folder is large but committed deliberately):
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/lib/monaco \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/MonacoEditor.razor \
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/App.razor
git commit -m "feat(adminui): vendor Monaco + reusable MonacoEditor component (no providers yet)"
Task 2: ScriptAnalysis contracts + service analysis seam + endpoints + DI
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 1 (disjoint files)
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisContracts.cs - Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs - Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs - Modify: the AdminUI service-registration site (find
AddAdminUI/AddOtOpcUaAdminUIextension, else thehasAdminblock insrc/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs(~line 185, afterapp.MapAdminUI<App>()) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptAnalysisServiceWrapperTests.cs
Step 1: Contracts — port scadabridge's ScriptAnalysisContracts.cs but drop ScriptKind and every SCADA shape (ScriptShape, ParameterShape, AttributeShape, CompositionContext) and all the context fields on the requests. Final shapes:
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public record DiagnoseRequest(string Code);
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
public record DiagnosticMarker(int Severity, int StartLineNumber, int StartColumn,
int EndLineNumber, int EndColumn, string Message, string Code);
public record CompletionsRequest(string CodeText, int Line, int Column);
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
public record CompletionItem(string Label, string InsertText, string Detail, string Kind, int InsertTextRules = 0);
public record HoverRequest(string CodeText, int Line, int Column);
public record HoverResponse(string? Markdown);
public record SignatureHelpRequest(string CodeText, int Line, int Column);
public record SignatureHelpResponse(string? Label, IReadOnlyList<SignatureHelpParameter>? Parameters, int ActiveParameter);
public record SignatureHelpParameter(string Label, string? Documentation);
public record FormatRequest(string Code);
public record FormatResponse(string Code);
public record InlayHintsRequest(string Code);
public record InlayHintsResponse(IReadOnlyList<InlayHint> Hints);
public record InlayHint(int Line, int Column, string Label);
Step 2: Service shell + the shared analysis seam. Create ScriptAnalysisService with the wrapper/compilation core all methods share, plus empty public methods (filled in Tasks 3–8). Inject IScriptTagCatalog (interface created in Task 5; for now declare the ctor param and an interface stub — OR sequence Task 5 before Task 6 and leave the ctor parameterless until Task 6; simplest: parameterless ctor now, add IScriptTagCatalog in Task 6).
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using ZB.MOM.WW.OtOpcUa.Core.Scripting; // ScriptSandbox, ForbiddenTypeAnalyzer, DependencyExtractor
using ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions; // VirtualTagContext, ScriptGlobals (confirm namespace)
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public sealed class ScriptAnalysisService
{
private static readonly SandboxConfig Sandbox = ScriptSandbox.Build(typeof(VirtualTagContext));
private static readonly string Preamble = BuildPreamble();
private static readonly CSharpCompilationOptions CompileOptions = new(
OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release,
allowUnsafe: false, warningLevel: 4, nullableContextOptions: NullableContextOptions.Enable);
private static string BuildPreamble()
{
var usings = string.Join("\n", Sandbox.Imports.Select(i => $"using {i};"));
return usings + "\n\n" +
"namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled;\n" +
"public static class CompiledScript\n{\n" +
" public static object Run(ScriptGlobals<VirtualTagContext> globals)\n {\n" +
" var ctx = globals.ctx;\n#line 1\n";
// user source is appended after this, then " }\n}\n"
}
/// <summary>Wrap user source in OtOpcUa's evaluator shape; returns the tree, semantic model, and the prefix length (chars before user source).</summary>
private static (SyntaxTree Tree, SemanticModel Model, int Prefix) Analyze(string userSource)
{
var prefix = Preamble;
var full = prefix + (userSource ?? "") + "\n }\n}\n";
var tree = CSharpSyntaxTree.ParseText(full);
var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions);
return (tree, compilation.GetSemanticModel(tree), prefix.Length);
}
/// <summary>(line,col) in user-source coords → physical offset in the wrapped document.</summary>
private static int OffsetInWrapped(string userSource, int line, int column, int prefix)
=> prefix + Math.Clamp(PositionToOffset(userSource, line, column), 0, (userSource ?? "").Length);
private static int PositionToOffset(string code, int line, int column)
{
int offset = 0, curLine = 1, curCol = 1;
for (int i = 0; i < code.Length; i++)
{
if (curLine == line && curCol == column) return offset;
if (code[i] == '\n') { curLine++; curCol = 1; } else { curCol++; }
offset = i + 1;
}
return code.Length;
}
public DiagnoseResponse Diagnose(DiagnoseRequest req) => new(Array.Empty<DiagnosticMarker>()); // Task 3
public Task<CompletionsResponse> CompleteAsync(CompletionsRequest req) => Task.FromResult(new CompletionsResponse(Array.Empty<CompletionItem>())); // Tasks 4,6
public HoverResponse Hover(HoverRequest req) => new((string?)null); // Task 7
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req) => new(null, null, 0); // Task 7
public FormatResponse Format(FormatRequest req) => new(req.Code); // Task 8
public InlayHintsResponse InlayHints(InlayHintsRequest req) => new(Array.Empty<InlayHint>()); // Task 8 (stays empty)
}
(Verify the exact namespace of ScriptGlobals/VirtualTagContext — the explorer reported ZB.MOM.WW.OtOpcUa.Core.Scripting for the types living under the Scripting.Abstractions project; match the actual namespace declarations.)
Step 3: Endpoints — port ScriptAnalysisEndpoints.cs, drop the /run route, swap auth:
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/script-analysis").RequireAuthorization("FleetAdmin");
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(s.Diagnose(r)));
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s) => Results.Ok(s.Hover(r)));
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r)));
group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r)));
return endpoints;
}
Step 4: DI — register services.AddScoped<ScriptAnalysisService>(); (and in Task 5, services.AddScoped<IScriptTagCatalog, ScriptTagCatalog>();) in the AdminUI service-registration extension.
Step 5: Map — in Program.cs hasAdmin block, after app.MapAdminUI<App>();:
app.MapScriptAnalysisEndpoints();
Step 6: Test the wrapper seam (the one piece of Task 2 with logic worth a test). Since Analyze/OffsetInWrapped are private, expose a tiny internal test hook OR test indirectly via Task 3. To keep Task 2 self-verifying, add an internal method AnalyzeForTest(string) => Analyze(...) guarded by [assembly: InternalsVisibleTo("ZB.MOM.WW.OtOpcUa.AdminUI.Tests")], and assert that for source return 1; the compilation reports no user-span diagnostics and that a member-access after ctx. resolves to VirtualTagContext. (If you prefer, fold this verification into Task 3's diagnostics tests and keep Task 2 to a build-only check — acceptable.)
Step 7: Build + commit
dotnet build ZB.MOM.WW.OtOpcUa.slnx
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis \
src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs \
<the-service-registration-file> \
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptAnalysisServiceWrapperTests.cs
git commit -m "feat(adminui): script-analysis contracts, wrapper seam, endpoints + DI"
Task 3: Diagnostics — Roslyn ∪ ForbiddenTypeAnalyzer ∪ DependencyExtractor
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 5 (disjoint files)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs(Diagnose) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs
Step 1: Write failing tests (xUnit + Shouldly):
public sealed class DiagnoseTests
{
private static readonly ScriptAnalysisService Svc = new();
private static IReadOnlyList<DiagnosticMarker> Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers;
[Fact] public void Clean_script_has_no_markers()
=> Diag("""return (double)ctx.GetTag("A").Value * 2.0;""").ShouldBeEmpty();
[Fact] public void Roslyn_error_is_reported_on_the_user_line()
{
var m = Diag("return ctx.NoSuchMember();");
m.ShouldNotBeEmpty();
m[0].Severity.ShouldBe(8);
m[0].StartLineNumber.ShouldBe(1); // #line 1 maps to user source
}
[Fact] public void Forbidden_type_is_flagged()
=> Diag("""var c = new System.Net.Http.HttpClient(); return 0;""")
.ShouldContain(m => m.Message.Contains("HttpClient") || m.Code.StartsWith("OTSCRIPT") || m.Severity == 8);
[Fact] public void Dynamic_tag_path_is_flagged()
{
var m = Diag("""var p = "A"; return ctx.GetTag(p).Value;""");
m.ShouldContain(x => x.Message.Contains("literal")); // DependencyExtractor rejection
}
[Fact] public void Preamble_diagnostics_are_suppressed()
=> Diag("var x = 1;").ShouldNotContain(m => m.Code == "CS0161"); // missing-return on Run() is hidden
}
Run: dotnet test tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests --filter FullyQualifiedName~DiagnoseTests → FAIL.
Step 2: Implement Diagnose:
public DiagnoseResponse Diagnose(DiagnoseRequest req)
{
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
var (tree, _, prefix) = Analyze(req.Code);
var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions);
var markers = new List<DiagnosticMarker>();
// (1) Roslyn — only diagnostics physically inside the user source (suppress preamble noise like CS0161 on Run()).
foreach (var d in compilation.GetDiagnostics())
{
if (d.Severity < DiagnosticSeverity.Info || !d.Location.IsInSource) continue;
if (d.Location.SourceSpan.Start < prefix) continue;
markers.Add(ToMarker(d.Location.GetLineSpan(), Sev(d.Severity), d.GetMessage(), d.Id));
}
// (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; GetLineSpan honors #line → user coords.
foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation))
markers.Add(ToMarker(tree.GetLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN"));
// (3) DependencyExtractor — spans are offsets in the *user* source; map directly.
foreach (var r in DependencyExtractor.Extract(req.Code).Rejections)
markers.Add(ToUserMarker(req.Code, r.Span, r.Message, "OTSCRIPT_DYNPATH"));
return new DiagnoseResponse(markers);
}
private static int Sev(DiagnosticSeverity s) => s switch {
DiagnosticSeverity.Error => 8, DiagnosticSeverity.Warning => 4, DiagnosticSeverity.Info => 2, _ => 1 };
private static DiagnosticMarker ToMarker(FileLinePositionSpan span, int sev, string msg, string code) => new(
sev, span.StartLinePosition.Line + 1, span.StartLinePosition.Character + 1,
span.EndLinePosition.Line + 1, span.EndLinePosition.Character + 1, msg, code);
private static DiagnosticMarker ToUserMarker(string userSource, Microsoft.CodeAnalysis.Text.TextSpan span, string msg, string code)
{
var (sl, sc) = LineCol(userSource, span.Start);
var (el, ec) = LineCol(userSource, span.End);
return new(8, sl, sc, el, ec, msg, code);
}
private static (int, int) LineCol(string code, int offset)
{
int line = 1, col = 1;
for (int i = 0; i < offset && i < code.Length; i++)
if (code[i] == '\n') { line++; col = 1; } else col++;
return (line, col);
}
Step 3: Run tests → PASS. (If ForbiddenTypeAnalyzer reports nothing for HttpClient because the reference set already excludes System.Net.Http, the Roslyn pass will instead emit a CS-level "type/namespace not found" error in user-span — the test's || covers that. Confirm which path fires and tighten the assert.)
Step 4: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs
git commit -m "feat(adminui): script diagnostics (Roslyn + forbidden-type + dynamic-path)"
Task 4: Completions — scope + dot-member
Classification: standard Estimated implement time: ~4 min Parallelizable with: Task 5 (disjoint files)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs(CompleteAsync+ToCompletionItem+TryGetDotMembers) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs
Step 1: Failing tests:
public sealed class CompletionTests
{
private static readonly ScriptAnalysisService Svc = new();
private static async Task<IReadOnlyList<CompletionItem>> Complete(string code, int line, int col)
=> (await Svc.CompleteAsync(new CompletionsRequest(code, line, col))).Items;
[Fact] public async Task Dot_after_ctx_offers_context_members()
{
var items = await Complete("ctx.", 1, 5); // caret right after "ctx."
var labels = items.Select(i => i.Label).ToList();
labels.ShouldContain("GetTag");
labels.ShouldContain("SetVirtualTag");
labels.ShouldContain("Now");
}
[Fact] public async Task Scope_completion_includes_ctx_local()
=> (await Complete("c", 1, 2)).Select(i => i.Label).ShouldContain("ctx");
}
Run filter ~CompletionTests → FAIL.
Step 2: Implement — port scadabridge's CompleteAsync core, but build the document via Analyze(...) and map the offset via OffsetInWrapped(...); the string-literal branch is added in Task 6 (leave a // Task 6: tag-path literals placeholder):
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
{
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
var (tree, model, prefix) = Analyze(req.CodeText);
var position = OffsetInWrapped(req.CodeText, req.Line, req.Column, prefix);
var root = await tree.GetRootAsync();
var token = root.FindToken(Math.Max(0, position - 1));
// Task 6: if token is a string literal inside ctx.GetTag/SetVirtualTag → tag-path completion.
var dot = TryGetDotMembers(token, model);
if (dot != null) return new CompletionsResponse(dot);
var scoped = model.LookupSymbols(position)
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
.GroupBy(s => s.Name).Select(g => g.First())
.Select(ToCompletionItem).Take(200).ToList();
return new CompletionsResponse(scoped);
}
Copy TryGetDotMembers and ToCompletionItem verbatim from scadabridge (ScriptAnalysisService.cs:885,1271) — they are pure Roslyn, no domain coupling.
Step 3: Run tests → PASS.
Step 4: Commit
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs \
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs
git commit -m "feat(adminui): scope + dot-member script completions"
Task 5: IScriptTagCatalog (tag + virtual-tag path provider)
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 3, Task 4 (disjoint files)
Files:
- Create:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs(+ScriptTagCatalog) - Modify: AdminUI service registration (
AddScoped<IScriptTagCatalog, ScriptTagCatalog>()) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs
Step 0 (verify the canonical path format — do this FIRST): Read how the runtime resolves a ctx.GetTag("…") literal to a configured tag (the virtual-tag engine's subscription/read-cache key construction, and how the equipment-namespace browse path is built). Confirm the exact separator and segment order a script author types — i.e. whether it is Area/Line/Equipment/Name, Equipment/Name, or FolderPath/Name. Match the catalog's projected strings to that. If the format genuinely can't be pinned down, project both the leaf Name and a best-effort full path and note the ambiguity in a code comment + the task report (do NOT silently guess).
Step 1: Failing test (in-memory EF — see Microsoft.EntityFrameworkCore.InMemory, already referenced by AdminUI.Tests):
public sealed class ScriptTagCatalogTests
{
private static OtOpcUaConfigDbContext NewDb()
{
var opts = new DbContextOptionsBuilder<OtOpcUaConfigDbContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
return new OtOpcUaConfigDbContext(opts);
}
[Fact] public async Task Returns_configured_tag_and_virtual_tag_paths()
{
await using var db = NewDb();
// seed one Equipment + one Tag + one VirtualTag (use the confirmed path convention from Step 0)
// ...
await db.SaveChangesAsync();
var catalog = new ScriptTagCatalog(/* IDbContextFactory or db */);
var paths = await catalog.GetPathsAsync(null, default);
paths.ShouldContain(/* the expected tag path */);
paths.ShouldContain(/* the expected virtual-tag path */);
}
[Fact] public async Task Filter_prefix_narrows_results() { /* assert prefix filtering */ }
}
Step 2: Implement:
public interface IScriptTagCatalog
{
Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct);
}
public sealed class ScriptTagCatalog(IDbContextFactory<OtOpcUaConfigDbContext> dbf) : IScriptTagCatalog
{
public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
{
await using var db = await dbf.CreateDbContextAsync(ct);
// Build paths per the Step 0 convention. Pull the minimal columns, project to the
// path string, distinct, filtered by `filter` (StartsWith/Contains, case-insensitive),
// capped (e.g. Take(200)) so completion stays responsive.
// ...
return result;
}
}
(Use IDbContextFactory<OtOpcUaConfigDbContext> to match UnsTreeService's pattern.)
Step 3: Run tests → PASS. Step 4: Register in DI. Step 5: Commit.
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs \
<service-registration-file> \
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogTests.cs
git commit -m "feat(adminui): IScriptTagCatalog for tag-path completion"
Task 6: Tag-path string-literal completion
Classification: standard Estimated implement time: ~4 min Parallelizable with: none (edits ScriptAnalysisService.cs; needs Tasks 4 + 5)
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs(injectIScriptTagCatalog, fill the Task-6 placeholder inCompleteAsync) - 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)/GetTypeInfoat the mapped offset; formatsymbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat)+ XML doc summary if present into markdown. (Simpler than scadabridge's domain hover — noParameters[...]branch.) - SignatureHelp: find the enclosing
InvocationExpressionSyntax; resolve the called method symbol viamodel.GetSymbolInfo(invocation.Expression)(handleOverloadResolutionFailurecandidates); buildSignatureHelpResponsefrom 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; leaveInlayHintsempty as scadabridge does) - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/FormatTests.cs
Step 1: Failing test — Format(new FormatRequest("var x=1;return x;")) returns code containing newlines / canonical spacing (!= input, parses clean).
Step 2: Implement — copy scadabridge's Format verbatim (CSharpSyntaxTree.ParseText(code, new CSharpParseOptions(LanguageVersion.Latest, kind: SourceCodeKind.Script)) → root.NormalizeWhitespace(" ", "\n").ToFullString(), try/catch returns input). InlayHints stays new(Array.Empty<InlayHint>()).
Step 3: Test PASS. Step 4: Commit.
Task 9: Wire the 6 Monaco language providers in monaco-init.js
Classification: small Estimated implement time: ~5 min Parallelizable with: Task 10, Task 11 (disjoint files); needs Tasks 2–8 endpoints live
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js(registerCSharpProviders,fetchDiagnostics)
Step 1: Port scadabridge's six provider registrations + fetchDiagnostics, but strip the SCADA context (lookupContext, GetContext, all declaredParameters/siblingScripts/... body fields). Request bodies become exactly the v1 DTO shapes:
- completions →
{ codeText: model.getValue(), line, column } - hover →
{ codeText, line, column } - signature-help →
{ codeText, line, column } - format →
{ code: model.getValue() } - inlay-hints →
{ code: model.getValue() } - diagnostics →
{ code: model.getValue() }
Keep KIND_MAP, the snippet-range handling, the response→Monaco mapping, and monaco.editor.setModelMarkers(model, "otopcua", markers). Each provider keeps the try/catch → return empty guard so a transient endpoint failure degrades gracefully.
Step 2: Build (no test — JS). Step 3: Commit.
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js
git commit -m "feat(adminui): wire Monaco language providers to /api/script-analysis"
Task 10: Swap ScriptEdit page to MonacoEditor
Classification: small Estimated implement time: ~4 min Parallelizable with: Task 11 (disjoint files); needs Task 1
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor - Delete:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js
Step 1: Replace the <InputTextArea id="script-source" @bind-Value="_form.SourceCode" .../> with:
<MonacoEditor @bind-Value="_form.SourceCode" Height="420px" />
(@bind-Value wires Value + ValueChanged.) Remove the OnAfterRenderAsync block that injected monaco-loader.js via JS.InvokeVoidAsync("eval", ...) + Task.Delay(50) + otOpcUaScriptEditor.attach, and the now-unused IJSRuntime/_loaded plumbing if nothing else uses them. Keep the SHA-256-on-save path untouched.
Step 2: git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js.
Step 3: Build. Step 4: Commit.
git add src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/ScriptEdit.razor
git rm src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-loader.js
git commit -m "feat(adminui): ScriptEdit uses MonacoEditor; drop CDN loader"
Task 11: VirtualTagModal inline script-source panel
Classification: standard Estimated implement time: ~5 min Parallelizable with: Task 10 (disjoint files); needs Task 1
Files:
- Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs+UnsTreeService.cs(3 new methods) - Modify:
src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/VirtualTagModal.razor - Test:
tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/ScriptSourceServiceTests.cs
Step 1: Failing service tests (in-memory EF): GetScriptSourceAsync(scriptId) returns (SourceCode, RowVersion, Name); CountVirtualTagsUsingScriptAsync(scriptId) returns the number of VirtualTags with that ScriptId; UpdateScriptSourceAsync(scriptId, source, rowVersion) recomputes the SHA-256 SourceHash, succeeds on a matching RowVersion, and returns a concurrency error on a stale one.
Step 2: Implement the 3 service methods (mirror the existing LoadScriptsAsync/Update patterns in UnsTreeService; reuse the page's existing HashSource/SHA-256 helper — extract it to the service so both call sites share it). Result type: reuse the existing (bool Ok, string? Error)-style result used by UpdateTagAsync.
Step 3: In VirtualTagModal.razor, below the Script dropdown, add a collapsible "Script source" panel shown when a script is selected:
- On script selection (or modal open with an existing
ScriptId), callGetScriptSourceAsync+CountVirtualTagsUsingScriptAsyncand 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:
- 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 - Ask the user to sign in to the AdminUI (the agent must NOT enter credentials).
- 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 listsGetTag/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.
- 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.
- 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 atdocs/ScriptEditor.md) - Memory: update
MEMORY.md+ a newproject_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.