feat(adminui): script-analysis contracts, wrapper seam, endpoints + DI

This commit is contained in:
Joseph Doherty
2026-06-09 14:11:43 -04:00
parent 9afb2d230e
commit b54a6ad29f
5 changed files with 127 additions and 0 deletions
@@ -47,6 +47,9 @@ public static class EndpointRouteBuilderExtensions
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
// Roslyn-backed Monaco script-editor analysis (diagnostics/completions/hover/...).
services.AddScoped<ScriptAnalysis.ScriptAnalysisService>();
return services;
}
}
@@ -0,0 +1,24 @@
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);
@@ -0,0 +1,21 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public static class ScriptAnalysisEndpoints
{
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;
}
}
@@ -0,0 +1,77 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <summary>
/// Roslyn-backed analysis for the Monaco virtual-tag script editor. Re-seats the
/// scadabridge analysis surface on OtOpcUa's REAL evaluator wrapper: the user's
/// source is wrapped in the same synthesized <c>CompiledScript.Run(globals)</c> shape
/// that <see cref="ScriptEvaluator{TContext, TResult}"/> compiles, so diagnostics,
/// completions, hover, and signature help all see the exact symbol surface a published
/// script gets. Built against the <see cref="ScriptSandbox"/> allow-list for the
/// <see cref="VirtualTagContext"/> context.
/// </summary>
/// <remarks>
/// Public methods are placeholders returning empty results — later tasks fill in each
/// capability via TDD. The shared <see cref="Analyze"/> seam + the offset helpers are
/// the load-bearing part every later capability reuses; keep them even while unused.
/// </remarks>
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);
// Mirrors ScriptEvaluator's wrapper EXACTLY (usings from the sandbox + the
// Run(ScriptGlobals<VirtualTagContext>) shape), except the analysis return type is
// `object` so a shared script stays valid regardless of any virtual tag's DataType.
private static string BuildPreamble()
{
var usings = string.Join("\n", Sandbox.Imports.Select(i => $"using {i};"));
var globalsType = $"global::{typeof(ScriptGlobals<>).Namespace}.ScriptGlobals<global::{typeof(VirtualTagContext).FullName}>";
return usings + "\n\n"
+ "namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Compiled;\n"
+ "public static class CompiledScript\n{\n"
+ $" public static object Run({globalsType} globals)\n {{\n"
+ " var ctx = globals.ctx;\n#line 1\n";
}
// userSource is appended after Preamble, then the method/class are closed.
private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) 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, compilation.GetSemanticModel(tree), prefix.Length);
}
// editor (line,col) in USER-source coords -> physical offset in the wrapped document
private static int OffsetInWrapped(string userSource, int line, int column, int preambleLength)
=> preambleLength + 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)
}
@@ -5,6 +5,7 @@ using ZB.MOM.WW.OtOpcUa.AdminUI;
using ZB.MOM.WW.OtOpcUa.AdminUI.Clients;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components;
using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
using ZB.MOM.WW.OtOpcUa.Cluster;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.ControlPlane;
@@ -183,6 +184,7 @@ if (hasAdmin)
app.UseAntiforgery();
app.MapOtOpcUaAuth();
app.MapAdminUI<App>();
app.MapScriptAnalysisEndpoints();
app.MapOtOpcUaHubs();
// Headless deploy trigger for CI/scripts (API-key gated; disabled until Security:DeployApiKey set).
app.MapOtOpcUaDeployApi(app.Configuration);