refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,53 @@
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Indirection so ScriptAnalysisService can be unit-tested without standing
/// up SharedScriptService and its EF Core repository chain.
/// </summary>
public interface ISharedScriptCatalog
{
/// <summary>Returns the parameter and return shapes for all registered shared scripts.</summary>
Task<IReadOnlyList<ScriptShape>> GetShapesAsync();
/// <summary>
/// Returns the source code and metadata for a named shared script, or
/// null if no shared script with that name exists. Used by Test Run to
/// compile and execute nested CallShared invocations.
/// </summary>
/// <param name="name">Name of the shared script to retrieve.</param>
/// <param name="cancellationToken">Cancellation token for the async lookup.</param>
Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default);
}
public record SharedScriptSource(string Name, string Code, string? ParameterDefinitions, string? ReturnDefinition);
public class SharedScriptCatalog : ISharedScriptCatalog
{
private readonly SharedScriptService _service;
/// <summary>
/// Initializes a new <see cref="SharedScriptCatalog"/> backed by the given service.
/// </summary>
/// <param name="service">Service providing access to shared script definitions.</param>
public SharedScriptCatalog(SharedScriptService service) => _service = service;
/// <inheritdoc />
public async Task<IReadOnlyList<ScriptShape>> GetShapesAsync()
{
var scripts = await _service.GetAllSharedScriptsAsync();
return scripts
.Select(s => ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition))
.ToList();
}
/// <inheritdoc />
public async Task<SharedScriptSource?> GetByNameAsync(string name, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(name)) return null;
var scripts = await _service.GetAllSharedScriptsAsync(cancellationToken);
var s = scripts.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.Ordinal));
return s == null ? null : new SharedScriptSource(s.Name, s.Code, s.ParameterDefinitions, s.ReturnDefinition);
}
}
@@ -0,0 +1,96 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Globals type seen by inbound API method scripts during analysis. Mirrors
/// the surface the runtime exposes (see ZB.MOM.WW.ScadaBridge.InboundAPI.InboundScriptContext
/// and RouteHelper). The methods here are never invoked — Roslyn only reads
/// their signatures to type-check API method scripts and offer completions.
/// </summary>
public class InboundScriptHost
{
/// <summary>
/// The request parameters passed to the inbound API method.
/// </summary>
public ScriptParameters Parameters { get; init; } = new();
/// <summary>
/// The route helper for accessing target instances.
/// </summary>
public RouteHelper Route { get; } = new();
/// <summary>
/// The cancellation token for the operation.
/// </summary>
public System.Threading.CancellationToken CancellationToken { get; }
/// <summary>Editor mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.RouteHelper.</summary>
public class RouteHelper
{
/// <summary>
/// Targets a specific instance for method invocation.
/// </summary>
/// <param name="instanceCode">The instance code to target.</param>
public RouteTarget To(string instanceCode) => new();
}
/// <summary>Editor mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.RouteTarget.</summary>
public class RouteTarget
{
/// <summary>
/// Calls a script on the target instance.
/// </summary>
/// <param name="scriptName">The name of the script to call.</param>
/// <param name="parameters">Optional parameters to pass to the script.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public System.Threading.Tasks.Task<object?> Call(
string scriptName,
object? parameters = null,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<object?>(null);
/// <summary>
/// Gets an attribute value from the target instance.
/// </summary>
/// <param name="attributeName">The name of the attribute to retrieve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public System.Threading.Tasks.Task<object?> GetAttribute(
string attributeName,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<object?>(null);
/// <summary>
/// Gets multiple attribute values from the target instance.
/// </summary>
/// <param name="attributeNames">The names of the attributes to retrieve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public System.Threading.Tasks.Task<IReadOnlyDictionary<string, object?>> GetAttributes(
IEnumerable<string> attributeNames,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.FromResult<IReadOnlyDictionary<string, object?>>(
new Dictionary<string, object?>());
/// <summary>
/// Sets a single attribute value on the target instance.
/// </summary>
/// <param name="attributeName">The name of the attribute to set.</param>
/// <param name="value">The value to set.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public System.Threading.Tasks.Task SetAttribute(
string attributeName,
string value,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.CompletedTask;
/// <summary>
/// Sets multiple attribute values on the target instance.
/// </summary>
/// <param name="attributeValues">Dictionary of attribute names to values.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public System.Threading.Tasks.Task SetAttributes(
IReadOnlyDictionary<string, string> attributeValues,
System.Threading.CancellationToken cancellationToken = default) =>
System.Threading.Tasks.Task.CompletedTask;
}
}
@@ -0,0 +1,181 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Translates JSON Schema documents stored in
/// <c>TemplateScript.ParameterDefinitions</c> / <c>ReturnDefinition</c> into
/// the flat <see cref="ParameterShape"/> / type-name vocabulary used by the
/// rest of the script-analysis pipeline (completions, inlay hints, signature
/// help, hover).
///
/// Lenient: malformed JSON yields an empty result, never an exception.
///
/// Also accepts the legacy pre-migration flat shape
/// (<c>[{name,type,required,itemType?}]</c> for parameters,
/// <c>{type,itemType?}</c> for return) so partially migrated rows don't crash
/// the editor.
/// </summary>
public static class JsonSchemaShapeParser
{
/// <summary>Parses a JSON Schema or legacy flat-array parameters definition and returns the resulting parameter shapes.</summary>
/// <param name="json">The JSON string to parse; <c>null</c> or whitespace returns an empty list.</param>
public static IReadOnlyList<ParameterShape> ParseParameters(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Array.Empty<ParameterShape>();
try
{
using var doc = JsonDocument.Parse(json);
return doc.RootElement.ValueKind switch
{
JsonValueKind.Array => ParseLegacyParameterArray(doc.RootElement),
JsonValueKind.Object => ParseJsonSchemaObject(doc.RootElement),
_ => Array.Empty<ParameterShape>(),
};
}
catch
{
return Array.Empty<ParameterShape>();
}
}
/// <summary>Parses a JSON Schema or legacy return-type definition and returns the normalised type name, or <c>null</c> if absent or unrecognised.</summary>
/// <param name="json">The JSON string to parse; <c>null</c> or whitespace returns <c>null</c>.</param>
public static string? ParseReturnType(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Object) return null;
return ParseReturnSchema(doc.RootElement);
}
catch
{
return null;
}
}
// ---- JSON Schema branch -------------------------------------------------
private static IReadOnlyList<ParameterShape> ParseJsonSchemaObject(JsonElement root)
{
if (!root.TryGetProperty("properties", out var props) || props.ValueKind != JsonValueKind.Object)
return Array.Empty<ParameterShape>();
var requiredSet = new HashSet<string>(StringComparer.Ordinal);
if (root.TryGetProperty("required", out var req) && req.ValueKind == JsonValueKind.Array)
{
foreach (var item in req.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String)
{
var s = item.GetString();
if (!string.IsNullOrEmpty(s)) requiredSet.Add(s);
}
}
}
var result = new List<ParameterShape>();
foreach (var prop in props.EnumerateObject())
{
var name = prop.Name;
if (string.IsNullOrEmpty(name)) continue;
var type = MapJsonSchemaType(prop.Value);
result.Add(new ParameterShape(name, type, requiredSet.Contains(name)));
}
return result;
}
private static string? ParseReturnSchema(JsonElement schema)
{
if (!schema.TryGetProperty("type", out var typeEl)) return null;
if (typeEl.ValueKind != JsonValueKind.String) return null;
var type = typeEl.GetString();
if (string.IsNullOrEmpty(type)) return null;
// Legacy form: `{type:"List", itemType:"Integer"}` (post-migration this
// should be `{type:"array", items:{type:"integer"}}`, handled below).
if (type.Equals("List", StringComparison.OrdinalIgnoreCase))
{
if (schema.TryGetProperty("itemType", out var it) && it.ValueKind == JsonValueKind.String)
return $"List<{NormalizeLegacyType(it.GetString())}>";
return "List<Object>";
}
if (type.Equals("array", StringComparison.OrdinalIgnoreCase))
{
if (schema.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object)
{
var inner = MapJsonSchemaType(items);
return $"List<{inner}>";
}
return "List<Object>";
}
return MapJsonSchemaTypeName(type);
}
private static string MapJsonSchemaType(JsonElement schema)
{
if (schema.ValueKind != JsonValueKind.Object) return "Object";
if (!schema.TryGetProperty("type", out var typeEl) || typeEl.ValueKind != JsonValueKind.String)
return "Object";
var type = typeEl.GetString() ?? "";
if (type.Equals("array", StringComparison.OrdinalIgnoreCase))
{
if (schema.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Object)
return $"List<{MapJsonSchemaType(items)}>";
return "List<Object>";
}
return MapJsonSchemaTypeName(type);
}
private static string MapJsonSchemaTypeName(string type) => type.ToLowerInvariant() switch
{
"boolean" => "Boolean",
"integer" => "Integer",
"number" => "Float",
"string" => "String",
"object" => "Object",
"array" => "List",
// Legacy aliases (in case a row's been edited by hand pre-migration):
"bool" => "Boolean",
"int" or "int32" or "int64" => "Integer",
"float" or "double" or "decimal" => "Float",
_ => type,
};
// ---- Legacy flat-array branch ------------------------------------------
private static IReadOnlyList<ParameterShape> ParseLegacyParameterArray(JsonElement root)
{
var result = new List<ParameterShape>();
foreach (var el in root.EnumerateArray())
{
if (el.ValueKind != JsonValueKind.Object) continue;
var name = el.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
if (string.IsNullOrEmpty(name)) continue;
var rawType = el.TryGetProperty("type", out var t) ? t.GetString() ?? "String" : "String";
var required = !el.TryGetProperty("required", out var rq) || rq.ValueKind != JsonValueKind.False;
result.Add(new ParameterShape(name, NormalizeLegacyType(rawType), required));
}
return result;
}
private static string NormalizeLegacyType(string? raw)
{
if (string.IsNullOrEmpty(raw)) return "String";
return raw.ToLowerInvariant() switch
{
"boolean" or "bool" => "Boolean",
"integer" or "int" or "int32" or "int64" => "Integer",
"float" or "double" or "decimal" or "number" => "Float",
"string" or "datetime" => "String",
"object" => "Object",
"list" or "array" => "List",
_ => raw,
};
}
}
@@ -0,0 +1,149 @@
using System.Text;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Per-call console capture for the Test Run sandbox.
/// <para>
/// Sandbox scripts use <c>System.Console.WriteLine</c> for ad-hoc output. The
/// sandbox needs to capture that output per execution. <c>Console.Out</c> is,
/// however, <b>process-global</b>: redirecting it with <c>Console.SetOut</c> for
/// the duration of one run corrupts any other run executing concurrently —
/// outputs interleave, and whichever run finishes first restores
/// <c>Console.Out</c> while the others are still writing (CentralUI-003).
/// </para>
/// <para>
/// This writer is installed into <c>Console.Out</c>/<c>Console.Error</c>
/// <b>exactly once</b> (see <see cref="Install"/>) and never removed. Each
/// concurrent run pushes its own buffer onto an <see cref="AsyncLocal{T}"/>
/// scope via <see cref="BeginCapture"/>; writes on that run's logical call-tree
/// land in that run's buffer only. Writes made on threads with no active
/// capture scope (i.e. genuine host-process console output) fall through to the
/// original writer. No process-global mutation happens per run.
/// </para>
/// </summary>
internal sealed class SandboxConsoleCapture : TextWriter
{
private static readonly object InstallLock = new();
private static SandboxConsoleCapture? _outInstance;
private static SandboxConsoleCapture? _errorInstance;
private readonly TextWriter _fallback;
private readonly AsyncLocal<StringWriter?> _current = new();
private SandboxConsoleCapture(TextWriter fallback) => _fallback = fallback;
/// <inheritdoc />
public override Encoding Encoding => _fallback.Encoding;
/// <summary>
/// Installs the routing writers into <see cref="Console.Out"/> and
/// <see cref="Console.Error"/> once for the process. Idempotent and
/// thread-safe. Subsequent calls return the already-installed instances.
/// </summary>
public static (SandboxConsoleCapture Out, SandboxConsoleCapture Error) Install()
{
if (_outInstance != null && _errorInstance != null)
return (_outInstance, _errorInstance);
lock (InstallLock)
{
if (_outInstance == null)
{
_outInstance = new SandboxConsoleCapture(Console.Out);
Console.SetOut(_outInstance);
}
if (_errorInstance == null)
{
_errorInstance = new SandboxConsoleCapture(Console.Error);
Console.SetError(_errorInstance);
}
}
return (_outInstance, _errorInstance);
}
/// <summary>
/// Begins a capture scope on the current logical (async) call-tree. All
/// console writes from this point until the returned scope is disposed are
/// routed into <paramref name="buffer"/> instead of the original writer.
/// The scope is restored on dispose, so nesting and concurrent scopes on
/// other call-trees are unaffected.
/// </summary>
/// <param name="buffer">The writer that receives console output for this scope.</param>
public CaptureScope BeginCapture(StringWriter buffer)
{
var previous = _current.Value;
_current.Value = buffer;
return new CaptureScope(this, previous);
}
// CentralUI-030: intra-script concurrency hardening. A sandboxed script
// can fan out work with `Task.WhenAll` / `Task.Run`; `AsyncLocal` flows
// the capture `StringWriter` into every child task, so two tasks can
// race the *same* buffer. `StringWriter` is not thread-safe — concurrent
// `Write`/`WriteLine` calls can corrupt the underlying `StringBuilder`
// and either throw or interleave at the character level. We lock on the
// captured writer itself so writes from one capture scope serialise;
// fall-through to the original `_fallback` (host-process console) is
// unlocked because the BCL's process-wide `Console.Out` is already
// synchronised by its TextWriter wrapper.
/// <inheritdoc />
public override void Write(char value) => WriteSynchronized(t => t.Write(value));
/// <inheritdoc />
public override void Write(string? value) => WriteSynchronized(t => t.Write(value));
/// <inheritdoc />
public override void Write(char[] buffer, int index, int count) =>
WriteSynchronized(t => t.Write(buffer, index, count));
/// <inheritdoc />
public override void WriteLine() => WriteSynchronized(t => t.WriteLine());
/// <inheritdoc />
public override void WriteLine(string? value) => WriteSynchronized(t => t.WriteLine(value));
/// <summary>
/// Routes a single write through the currently-active capture buffer
/// under a lock on that buffer, or to the unwrapped fallback writer when
/// no capture scope is active. The lock target is the `StringWriter`
/// instance itself — different capture scopes have different writers,
/// so two unrelated scopes never block each other.
/// </summary>
private void WriteSynchronized(Action<TextWriter> write)
{
var captured = _current.Value;
if (captured is null)
{
write(_fallback);
return;
}
lock (captured)
{
write(captured);
}
}
internal readonly struct CaptureScope : IDisposable
{
private readonly SandboxConsoleCapture _owner;
private readonly StringWriter? _previous;
/// <summary>
/// Initializes a capture scope that restores the previous writer on dispose.
/// </summary>
/// <param name="owner">The owning <see cref="SandboxConsoleCapture"/> instance.</param>
/// <param name="previous">The writer that was active before this scope was opened.</param>
internal CaptureScope(SandboxConsoleCapture owner, StringWriter? previous)
{
_owner = owner;
_previous = previous;
}
/// <summary>Restores the previous capture scope.</summary>
public void Dispose() => _owner._current.Value = _previous;
}
}
@@ -0,0 +1,161 @@
using System.Data.Common;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// User-facing surface for <c>ExternalSystem.Call</c> /
/// <c>ExternalSystem.CachedCall</c> inside a Test Run. Mirrors
/// ExternalSystemHelper in ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRuntimeContext
/// so the same user code compiles against both. When constructed with a null
/// client (the editor's metadata-only analysis pass) every call throws
/// <see cref="ScriptSandboxException"/>; with a real client wired in (a Test
/// Run) calls hit the live HTTP path.
/// </summary>
public class SandboxExternalHelper
{
private readonly IExternalSystemClient? _client;
private readonly string _instanceName;
/// <summary>Initializes a new instance of the SandboxExternalHelper class.</summary>
/// <param name="client">Optional external system client for test runs.</param>
/// <param name="instanceName">The instance name context.</param>
public SandboxExternalHelper(IExternalSystemClient? client, string instanceName)
{
_client = client;
_instanceName = instanceName;
}
/// <summary>Invokes a synchronous external system call.</summary>
/// <param name="systemName">The external system name.</param>
/// <param name="methodName">The method name to invoke.</param>
/// <param name="parameters">Optional method parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<ExternalCallResult> Call(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
if (_client == null)
throw new ScriptSandboxException(
$"External.Call(\"{systemName}\", \"{methodName}\") — external system client not configured for Test Run.");
return _client.CallAsync(systemName, methodName, parameters, cancellationToken);
}
/// <summary>Invokes a cached external system call.</summary>
/// <param name="systemName">The external system name.</param>
/// <param name="methodName">The method name to invoke.</param>
/// <param name="parameters">Optional method parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<ExternalCallResult> CachedCall(
string systemName,
string methodName,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
if (_client == null)
throw new ScriptSandboxException(
$"External.CachedCall(\"{systemName}\", \"{methodName}\") — external system client not configured for Test Run.");
return _client.CachedCallAsync(systemName, methodName, parameters, _instanceName, cancellationToken);
}
}
/// <summary>Sandbox database helper for script analysis.</summary>
public class SandboxDatabaseHelper
{
private readonly IDatabaseGateway? _gateway;
private readonly string _instanceName;
/// <summary>Initializes a new instance of the SandboxDatabaseHelper class.</summary>
/// <param name="gateway">Optional database gateway for test runs.</param>
/// <param name="instanceName">The instance name context.</param>
public SandboxDatabaseHelper(IDatabaseGateway? gateway, string instanceName)
{
_gateway = gateway;
_instanceName = instanceName;
}
/// <summary>Gets a database connection by name.</summary>
/// <param name="name">The database connection name.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<DbConnection> Connection(string name, CancellationToken cancellationToken = default)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"Database.Connection(\"{name}\") — database gateway not configured for Test Run.");
return _gateway.GetConnectionAsync(name, cancellationToken);
}
/// <summary>Executes a cached database write operation.</summary>
/// <param name="name">The database connection name.</param>
/// <param name="sql">The SQL statement to execute.</param>
/// <param name="parameters">Optional SQL parameters.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task CachedWrite(
string name,
string sql,
IReadOnlyDictionary<string, object?>? parameters = null,
CancellationToken cancellationToken = default)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"Database.CachedWrite(\"{name}\") — database gateway not configured for Test Run.");
return _gateway.CachedWriteAsync(name, sql, parameters, _instanceName, cancellationToken);
}
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.NotifyHelper</c> — the
/// <c>Notify</c> global. Signature-faithful to production so the same user code
/// (<c>Notify.To(...).Send(...)</c> / <c>Notify.Status(...)</c>) compiles
/// identically against both surfaces.
///
/// In the Notification Outbox design production no longer delivers notification
/// email inline — <c>Notify.Send</c> enqueues into the site Store-and-Forward
/// Engine and returns a <c>NotificationId</c>. The sandbox has no S&amp;F engine
/// and no central, so it is a pure no-op fake: <c>Send</c> returns a generated
/// fake id and <c>Status</c> returns a placeholder <see cref="NotificationDeliveryStatus"/>.
/// Nothing is delivered.
/// </summary>
public class SandboxNotifyHelper
{
/// <summary>Selects the notification list to send to.</summary>
/// <param name="listName">The notification list name.</param>
public SandboxNotifyTarget To(string listName) =>
new();
/// <summary>
/// Queries the delivery status of a previously-sent notification. The
/// sandbox never delivers, so this always reports the placeholder
/// <c>Unknown</c> status — it exists for signature fidelity with
/// <c>NotifyHelper.Status</c>.
/// </summary>
/// <param name="notificationId">The notification ID to check status for.</param>
public Task<NotificationDeliveryStatus> Status(string notificationId) =>
Task.FromResult(new NotificationDeliveryStatus("Unknown", 0, null, null));
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.NotifyTarget</c> — the
/// target of <c>Notify.To("listName")</c>.
/// </summary>
public class SandboxNotifyTarget
{
/// <summary>Initializes a new instance of the SandboxNotifyTarget class.</summary>
internal SandboxNotifyTarget()
{
}
/// <summary>
/// Mirrors <c>NotifyTarget.Send</c> — returns a <c>NotificationId</c>. In
/// the sandbox nothing is enqueued or delivered; a fake id is returned so
/// the call type-checks identically to production.
/// </summary>
/// <param name="subject">The notification subject.</param>
/// <param name="message">The notification message.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<string> Send(string subject, string message, CancellationToken cancellationToken = default) =>
Task.FromResult(Guid.NewGuid().ToString("N"));
}
@@ -0,0 +1,105 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Runtime globals for an inbound API method Test Run. Mirrors
/// <see cref="InboundScriptHost"/>'s public surface so the same user code that
/// compiles for diagnostics also compiles against this type — but every
/// <c>Route</c> accessor throws <see cref="ScriptSandboxException"/> instead of
/// reaching a deployed site. Cross-site routing needs the cluster transport and
/// a live instance, neither of which exists in a central Test Run; pure logic
/// and <c>Parameters</c> still work, matching how <see cref="SandboxScriptHost"/>
/// throws on <c>Attributes</c> for shared scripts.
/// </summary>
public class SandboxInboundScriptHost
{
/// <summary>Gets or initializes the script input parameters.</summary>
public ScriptParameters Parameters { get; init; } = new();
/// <summary>Gets or initializes the cancellation token for the test run.</summary>
public CancellationToken CancellationToken { get; init; }
/// <summary>Gets the route accessor; every call throws <see cref="ScriptSandboxException"/> in a test run.</summary>
public RouteAccessor Route { get; } = new();
/// <summary>Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.RouteHelper.</summary>
public class RouteAccessor
{
/// <summary>
/// Creates a sandbox route target that throws on every operation.
/// </summary>
/// <param name="instanceCode">The instance code (used only in the exception message).</param>
public RouteTarget To(string instanceCode) => new(instanceCode);
}
/// <summary>Mirror of ZB.MOM.WW.ScadaBridge.InboundAPI.RouteTarget — every call throws.</summary>
public class RouteTarget
{
private readonly string _instanceCode;
/// <summary>
/// Initializes the sandbox route target for the given instance code.
/// </summary>
/// <param name="instanceCode">The instance code referenced by the routing expression (used in the exception message).</param>
internal RouteTarget(string instanceCode) => _instanceCode = instanceCode;
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="scriptName">Script name (included in the exception message).</param>
/// <param name="parameters">Unused parameters.</param>
/// <param name="cancellationToken">Unused token.</param>
public Task<object?> Call(
string scriptName,
object? parameters = null,
CancellationToken cancellationToken = default) =>
throw Unavailable($"Call(\"{scriptName}\")");
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="attributeName">Attribute name (included in the exception message).</param>
/// <param name="cancellationToken">Unused token.</param>
public Task<object?> GetAttribute(
string attributeName,
CancellationToken cancellationToken = default) =>
throw Unavailable($"GetAttribute(\"{attributeName}\")");
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="attributeNames">Attribute names (unused).</param>
/// <param name="cancellationToken">Unused token.</param>
public Task<IReadOnlyDictionary<string, object?>> GetAttributes(
IEnumerable<string> attributeNames,
CancellationToken cancellationToken = default) =>
throw Unavailable("GetAttributes(...)");
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="attributeName">Attribute name (included in the exception message).</param>
/// <param name="value">Unused value.</param>
/// <param name="cancellationToken">Unused token.</param>
public Task SetAttribute(
string attributeName,
string value,
CancellationToken cancellationToken = default) =>
throw Unavailable($"SetAttribute(\"{attributeName}\")");
/// <summary>
/// Always throws <see cref="ScriptSandboxException"/>; cross-site routing is unavailable in a Test Run.
/// </summary>
/// <param name="attributeValues">Unused attribute values.</param>
/// <param name="cancellationToken">Unused token.</param>
public Task SetAttributes(
IReadOnlyDictionary<string, string> attributeValues,
CancellationToken cancellationToken = default) =>
throw Unavailable("SetAttributes(...)");
private ScriptSandboxException Unavailable(string operation) =>
new($"Route.To(\"{_instanceCode}\").{operation} is not available in Test Run — " +
"cross-site routing needs a deployed site reachable over the cluster transport.");
}
}
@@ -0,0 +1,77 @@
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Communication;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Backs the Test Run sandbox <c>Instance</c> when the run is bound to a real
/// deployed instance. Routes attribute reads/writes and sibling-script calls to
/// the instance cross-site via <see cref="CommunicationService"/> — the same
/// transport the inbound API's <c>Route.To()</c> uses. All calls run under the
/// Test Run's cancellation token, so the sandbox timeout still applies.
/// </summary>
public sealed class SandboxInstanceGateway : ISandboxInstanceGateway
{
private readonly CommunicationService _comms;
private readonly string _siteId;
private readonly string _instanceUniqueName;
private readonly CancellationToken _runToken;
/// <summary>
/// Initializes a new <see cref="SandboxInstanceGateway"/> bound to a specific deployed instance.
/// </summary>
/// <param name="comms">Communication service used to route requests to the site.</param>
/// <param name="siteId">String identifier of the site hosting the bound instance.</param>
/// <param name="instanceUniqueName">Unique name of the instance to route calls to.</param>
/// <param name="runToken">Cancellation token for the test run; applied to all cross-site calls.</param>
public SandboxInstanceGateway(
CommunicationService comms,
string siteId,
string instanceUniqueName,
CancellationToken runToken)
{
_comms = comms;
_siteId = siteId;
_instanceUniqueName = instanceUniqueName;
_runToken = runToken;
}
/// <inheritdoc />
public async Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct)
{
var request = new RouteToGetAttributesRequest(
Guid.NewGuid().ToString(), _instanceUniqueName,
new[] { canonicalName }, DateTimeOffset.UtcNow);
var response = await _comms.RouteToGetAttributesAsync(_siteId, request, _runToken);
if (!response.Success)
throw new ScriptSandboxException(
$"GetAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
return response.Values.TryGetValue(canonicalName, out var value) ? value : null;
}
/// <inheritdoc />
public async Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct)
{
var request = new RouteToSetAttributesRequest(
Guid.NewGuid().ToString(), _instanceUniqueName,
new Dictionary<string, string> { [canonicalName] = value }, DateTimeOffset.UtcNow);
var response = await _comms.RouteToSetAttributesAsync(_siteId, request, _runToken);
if (!response.Success)
throw new ScriptSandboxException(
$"SetAttribute(\"{canonicalName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
}
/// <inheritdoc />
public async Task<object?> CallScriptAsync(
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct)
{
var request = new RouteToCallRequest(
Guid.NewGuid().ToString(), _instanceUniqueName,
canonicalScriptName, parameters, DateTimeOffset.UtcNow);
var response = await _comms.RouteToCallAsync(_siteId, request, _runToken);
if (!response.Success)
throw new ScriptSandboxException(
$"CallScript(\"{canonicalScriptName}\") on bound instance '{_instanceUniqueName}' failed: {response.ErrorMessage}");
return response.ReturnValue;
}
}
@@ -0,0 +1,45 @@
using System.Text.Json;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Request from the UI to execute a script in the central sandbox.
/// Parameters arrive as JSON values and are converted to .NET primitives
/// before being placed in the Parameters dictionary supplied to the script.
/// <see cref="Kind"/> selects which globals surface the script is compiled
/// and run against — template/shared scripts see <see cref="SandboxScriptHost"/>,
/// inbound API method scripts see <see cref="SandboxInboundScriptHost"/>.
/// <see cref="BindInstanceUniqueName"/>, when set, binds the run to a deployed
/// instance so <c>Instance</c>/<c>Attributes</c> access routes to it cross-site
/// instead of throwing. Ignored for inbound API scripts.
/// </summary>
public record SandboxRunRequest(
string Code,
Dictionary<string, JsonElement>? Parameters,
int? TimeoutSeconds,
ScriptKind Kind = ScriptKind.Template,
string? BindInstanceUniqueName = null);
public enum SandboxErrorKind
{
None,
CompileError,
SandboxLimitation,
RuntimeError,
Timeout
}
/// <summary>
/// Result of a Test Run. <see cref="Markers"/> carries Roslyn diagnostics
/// when <see cref="ErrorKind"/> is CompileError so the UI can display them
/// the same way it does for the editor's live problems panel.
/// </summary>
public record SandboxRunResult(
bool Success,
string? ReturnValueJson,
string? ReturnTypeName,
string ConsoleOutput,
string? Error,
SandboxErrorKind ErrorKind,
long DurationMs,
IReadOnlyList<DiagnosticMarker>? Markers);
@@ -0,0 +1,413 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Scripts;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Runtime globals for the Test Run sandbox. Mirrors the real site-runtime
/// <c>ScriptGlobals</c> surface (ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts) member-for-member
/// so the same user code that runs at a site also compiles and runs here.
///
/// Instance-context members — <c>Instance.GetAttribute/SetAttribute/CallScript</c>,
/// <c>Attributes</c>, <c>Children</c>, <c>Parent</c> — need a live deployed
/// instance. With no instance bound they throw <see cref="ScriptSandboxException"/>;
/// with one bound (see <see cref="SandboxInstanceContext"/>) they route to it.
///
/// <c>ExternalSystem</c>, <c>Database</c>, and <c>Scripts.CallShared</c> run
/// against central's real services and fire for real; <c>Notify</c> is a
/// signature-faithful no-op fake. None of them depend on a bound instance.
/// </summary>
public class SandboxScriptHost
{
/// <summary>
/// Script parameters passed to the sandbox.
/// </summary>
public ScriptParameters Parameters { get; init; } = new();
/// <summary>
/// Cancellation token for the sandbox execution.
/// </summary>
public CancellationToken CancellationToken { get; init; }
/// <summary>
/// Alarm context for the sandbox.
/// </summary>
public AlarmContext? Alarm { get; init; }
/// <summary>
/// Script scope defining the execution context.
/// </summary>
public ScriptScope Scope { get; init; } = ScriptScope.Root;
/// <summary>
/// Instance context providing access to deployed instance data.
/// </summary>
public SandboxInstanceContext Instance { get; init; } = new();
/// <summary>
/// Helper for external system calls.
/// </summary>
public SandboxExternalHelper ExternalSystem => Instance.ExternalSystem;
/// <summary>
/// Helper for database operations.
/// </summary>
public SandboxDatabaseHelper Database => Instance.Database;
/// <summary>
/// Helper for sending notifications.
/// </summary>
public SandboxNotifyHelper Notify => Instance.Notify;
/// <summary>
/// Helper for calling scripts.
/// </summary>
public SandboxScriptCallHelper Scripts => Instance.Scripts;
/// <summary>
/// Accessor for attributes scoped to the current instance.
/// </summary>
public SandboxAttributeAccessor Attributes => new(Instance, Scope.SelfPath);
/// <summary>
/// Accessor for child compositions.
/// </summary>
public SandboxChildrenAccessor Children => new(Instance, Scope.SelfPath);
/// <summary>
/// Accessor for the parent composition, or null if at root.
/// </summary>
public SandboxCompositionAccessor? Parent =>
Scope.ParentPath == null ? null : new SandboxCompositionAccessor(Instance, Scope.ParentPath);
}
/// <summary>
/// Backs the sandbox <c>Instance</c> when a Test Run is bound to a real
/// deployed instance. Null when unbound. The implementation routes to the
/// instance cross-site over the cluster transport.
/// </summary>
public interface ISandboxInstanceGateway
{
/// <summary>
/// Gets the value of an attribute with the specified canonical name.
/// </summary>
/// <param name="canonicalName">The canonical name of the attribute.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The attribute value, or null if not found.</returns>
Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct);
/// <summary>
/// Sets the value of an attribute with the specified canonical name.
/// </summary>
/// <param name="canonicalName">The canonical name of the attribute.</param>
/// <param name="value">The value to set.</param>
/// <param name="ct">Cancellation token.</param>
Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct);
/// <summary>
/// Calls a script with the specified canonical name.
/// </summary>
/// <param name="canonicalScriptName">The canonical name of the script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The script result, or null if none.</returns>
Task<object?> CallScriptAsync(
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct);
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ScriptRuntimeContext</c> —
/// the <c>Instance</c> global. Attribute and sibling-script access needs a real
/// deployed instance: with no gateway wired it throws; with one (a bound
/// instance) it routes cross-site. <c>ExternalSystem</c>/<c>Database</c>/
/// <c>Scripts</c> run against central's real services regardless of binding;
/// <c>Notify</c> is a signature-faithful no-op fake.
/// </summary>
public class SandboxInstanceContext
{
private readonly ISandboxInstanceGateway? _gateway;
/// <summary>
/// Helper for external system calls.
/// </summary>
public SandboxExternalHelper ExternalSystem { get; }
/// <summary>
/// Helper for database operations.
/// </summary>
public SandboxDatabaseHelper Database { get; }
/// <summary>
/// Helper for sending notifications.
/// </summary>
public SandboxNotifyHelper Notify { get; }
/// <summary>
/// Helper for calling scripts.
/// </summary>
public SandboxScriptCallHelper Scripts { get; }
/// <summary>
/// Initializes a new instance of the SandboxInstanceContext.
/// </summary>
/// <param name="gateway">Gateway for accessing deployed instance data, or null if unbound.</param>
/// <param name="external">External system helper, or null to create a default.</param>
/// <param name="database">Database helper, or null to create a default.</param>
/// <param name="notify">Notification helper, or null to create a default.</param>
/// <param name="scripts">Script call helper, or null to create a default.</param>
public SandboxInstanceContext(
ISandboxInstanceGateway? gateway = null,
SandboxExternalHelper? external = null,
SandboxDatabaseHelper? database = null,
SandboxNotifyHelper? notify = null,
SandboxScriptCallHelper? scripts = null)
{
_gateway = gateway;
ExternalSystem = external ?? new SandboxExternalHelper(null, "<sandbox>");
Database = database ?? new SandboxDatabaseHelper(null, "<sandbox>");
Notify = notify ?? new SandboxNotifyHelper();
Scripts = scripts ?? new SandboxScriptCallHelper(null);
}
/// <summary>
/// Gets the value of an attribute.
/// </summary>
/// <param name="attributeName">The name of the attribute.</param>
/// <returns>The attribute value, or null if not found.</returns>
public Task<object?> GetAttribute(string attributeName)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"GetAttribute(\"{attributeName}\") needs a deployed instance — " +
"bind one in Test Run to read live attribute values.");
return _gateway.GetAttributeAsync(attributeName, CancellationToken.None);
}
/// <summary>
/// Sets the value of an attribute.
/// </summary>
/// <param name="attributeName">The name of the attribute.</param>
/// <param name="value">The value to set.</param>
public void SetAttribute(string attributeName, string value)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"SetAttribute(\"{attributeName}\") needs a deployed instance — " +
"bind one in Test Run to write attribute values.");
_gateway.SetAttributeAsync(attributeName, value, CancellationToken.None).GetAwaiter().GetResult();
}
/// <summary>
/// Calls a sibling script.
/// </summary>
/// <param name="scriptName">The name of the script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <returns>The script result, or null if none.</returns>
public Task<object?> CallScript(string scriptName, object? parameters = null)
{
if (_gateway == null)
throw new ScriptSandboxException(
$"CallScript(\"{scriptName}\") needs a deployed instance — " +
"bind one in Test Run to call sibling scripts.");
return _gateway.CallScriptAsync(scriptName, ScriptArgs.Normalize(parameters), CancellationToken.None);
}
}
/// <summary>
/// Sandbox mirror of <c>ScriptRuntimeContext.ScriptCallHelper</c> —
/// <c>Scripts.CallShared(...)</c>. Compiles and runs the named shared script in
/// the same sandbox via the wired delegate.
/// </summary>
public class SandboxScriptCallHelper
{
private readonly Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? _callShared;
/// <summary>
/// Initializes a new instance of the SandboxScriptCallHelper.
/// </summary>
/// <param name="callShared">Delegate for calling shared scripts, or null if not available.</param>
public SandboxScriptCallHelper(
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callShared)
{
_callShared = callShared;
}
/// <summary>
/// Calls a shared script.
/// </summary>
/// <param name="scriptName">The name of the shared script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The script result, or null if none.</returns>
public Task<object?> CallShared(
string scriptName,
object? parameters = null,
CancellationToken cancellationToken = default)
{
if (_callShared == null)
throw new ScriptSandboxException(
$"Scripts.CallShared(\"{scriptName}\") — shared-script catalog not configured for Test Run.");
return _callShared(scriptName, ScriptArgs.Normalize(parameters), cancellationToken);
}
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.AttributeAccessor</c> —
/// scope-aware <c>Attributes["X"]</c> access anchored at a canonical-name prefix.
/// </summary>
public class SandboxAttributeAccessor
{
private readonly SandboxInstanceContext _ctx;
/// <summary>
/// The scope prefix for attribute resolution.
/// </summary>
public string ScopePrefix { get; }
/// <summary>
/// Initializes a new instance of the SandboxAttributeAccessor.
/// </summary>
/// <param name="ctx">The sandbox instance context.</param>
/// <param name="prefix">The scope prefix for attribute names.</param>
public SandboxAttributeAccessor(SandboxInstanceContext ctx, string prefix)
{
_ctx = ctx;
ScopePrefix = prefix;
}
/// <summary>
/// Resolves a key to its fully qualified name within the current scope.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <returns>The fully qualified attribute name.</returns>
public string Resolve(string key) =>
ScopePrefix.Length == 0 ? key : ScopePrefix + "." + key;
/// <summary>
/// Gets or sets an attribute value by key.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <returns>The attribute value, or null if not found.</returns>
public object? this[string key]
{
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult();
set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
}
/// <summary>
/// Gets an attribute value asynchronously.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <returns>The attribute value, or null if not found.</returns>
public Task<object?> GetAsync(string key) => _ctx.GetAttribute(Resolve(key));
/// <summary>
/// Sets an attribute value asynchronously.
/// </summary>
/// <param name="key">The attribute key.</param>
/// <param name="value">The value to set, or null.</param>
/// <returns>A task representing the operation.</returns>
public Task SetAsync(string key, object? value)
{
_ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
return Task.CompletedTask;
}
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.CompositionAccessor</c> —
/// a view of one composition: its attributes plus an invokable <c>CallScript</c>.
/// </summary>
public class SandboxCompositionAccessor
{
private readonly SandboxInstanceContext _ctx;
/// <summary>
/// The path to the composition within the instance hierarchy.
/// </summary>
public string Path { get; }
/// <summary>
/// Accessor for attributes within the composition.
/// </summary>
public SandboxAttributeAccessor Attributes { get; }
/// <summary>
/// Initializes a new instance of the SandboxCompositionAccessor.
/// </summary>
/// <param name="ctx">The sandbox instance context.</param>
/// <param name="path">The path to the composition within the instance hierarchy.</param>
public SandboxCompositionAccessor(SandboxInstanceContext ctx, string path)
{
_ctx = ctx;
Path = path;
Attributes = new SandboxAttributeAccessor(ctx, path);
}
/// <summary>
/// Resolves a script name to its fully qualified name within the composition.
/// </summary>
/// <param name="scriptName">The script name.</param>
/// <returns>The fully qualified script name.</returns>
public string ResolveScript(string scriptName) =>
Path.Length == 0 ? scriptName : Path + "." + scriptName;
/// <summary>
/// Calls a script within the composition.
/// </summary>
/// <param name="scriptName">The name of the script.</param>
/// <param name="parameters">Script parameters, or null if none.</param>
/// <returns>The script result, or null if none.</returns>
public Task<object?> CallScript(string scriptName, object? parameters = null)
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
}
/// <summary>
/// Sandbox mirror of <c>ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts.ChildrenAccessor</c> —
/// dictionary-style access to child compositions.
/// </summary>
public class SandboxChildrenAccessor
{
private readonly SandboxInstanceContext _ctx;
private readonly string _selfPath;
/// <summary>
/// Initializes a new instance of the SandboxChildrenAccessor.
/// </summary>
/// <param name="ctx">The sandbox instance context.</param>
/// <param name="selfPath">The path to the parent composition.</param>
public SandboxChildrenAccessor(SandboxInstanceContext ctx, string selfPath)
{
_ctx = ctx;
_selfPath = selfPath;
}
/// <summary>
/// Gets a child composition by name.
/// </summary>
/// <param name="compositionName">The name of the child composition.</param>
/// <returns>An accessor for the child composition.</returns>
public SandboxCompositionAccessor this[string compositionName]
{
get
{
var path = _selfPath.Length == 0
? compositionName
: _selfPath + "." + compositionName;
return new SandboxCompositionAccessor(_ctx, path);
}
}
}
/// <summary>
/// Distinct exception so the Test Run pipeline can label sandbox-only
/// limitations differently from genuine runtime errors in user code.
/// </summary>
public class ScriptSandboxException : Exception
{
/// <summary>
/// Initializes a new instance of the ScriptSandboxException.
/// </summary>
/// <param name="message">The exception message.</param>
public ScriptSandboxException(string message) : base(message) { }
}
@@ -0,0 +1,127 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Which runtime globals surface a script is analyzed against. Template and
/// shared scripts see <see cref="SandboxScriptHost"/> (mirroring the site
/// runtime's ScriptGlobals); inbound API method scripts see
/// <see cref="InboundScriptHost"/> (with <c>Route</c> and <c>Parameters</c>).
/// </summary>
public enum ScriptKind
{
Template,
InboundApi
}
public record DiagnoseRequest(
string Code,
IReadOnlyList<string>? DeclaredParameters = null,
IReadOnlyList<ScriptShape>? SiblingScripts = null,
IReadOnlyList<AttributeShape>? SelfAttributes = null,
IReadOnlyList<CompositionContext>? Children = null,
CompositionContext? Parent = null,
ScriptKind Kind = ScriptKind.Template);
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
/// <summary>
/// Shape Monaco's setModelMarkers expects (with severity mapped to Monaco's
/// MarkerSeverity enum: 1=Hint, 2=Info, 4=Warning, 8=Error).
/// </summary>
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,
IReadOnlyList<string>? DeclaredParameters = null,
IReadOnlyList<ScriptShape>? SiblingScripts = null,
IReadOnlyList<AttributeShape>? SelfAttributes = null,
IReadOnlyList<CompositionContext>? Children = null,
CompositionContext? Parent = null,
ScriptKind Kind = ScriptKind.Template);
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
public record CompletionItem(
string Label,
string InsertText,
string Detail,
string Kind,
/// <summary>Monaco CompletionItemInsertTextRule. 4 = InsertAsSnippet.</summary>
int InsertTextRules = 0);
public record HoverRequest(
string CodeText,
int Line,
int Column,
IReadOnlyList<ScriptShape>? SiblingScripts = null,
IReadOnlyList<ParameterShape>? DeclaredParameters = null,
IReadOnlyList<AttributeShape>? SelfAttributes = null,
IReadOnlyList<CompositionContext>? Children = null,
CompositionContext? Parent = null);
public record HoverResponse(string? Markdown);
public record SignatureHelpRequest(
string CodeText,
int Line,
int Column,
IReadOnlyList<ScriptShape>? SiblingScripts = null,
IReadOnlyList<CompositionContext>? Children = null,
CompositionContext? Parent = null);
public record SignatureHelpResponse(
string? Label,
IReadOnlyList<SignatureHelpParameter>? Parameters,
int ActiveParameter);
public record SignatureHelpParameter(string Label, string? Documentation);
/// <summary>
/// Shape metadata for a script. Captured from the form's ParameterListEditor
/// and ReturnTypeEditor (for siblings) or from SharedScriptCatalog (for shared
/// scripts). Used by hover, signature-help, snippet expansion, and the
/// argument-count diagnostic.
/// </summary>
public record ScriptShape(
string Name,
IReadOnlyList<ParameterShape> Parameters,
string? ReturnType);
public record ParameterShape(string Name, string Type, bool Required);
/// <summary>
/// Attribute declared on a template: name + canonical SCADA type (Boolean,
/// Integer, Float, String, Object, List).
/// </summary>
public record AttributeShape(string Name, string Type);
/// <summary>
/// One end of a composition relationship — either a child (referenced by
/// composition instance name) or the parent (referenced by template name).
/// The shape carries the attributes and scripts at that scope so the editor
/// can complete <c>Children["X"].Attributes["Y"]</c> and
/// <c>Children["X"].CallScript("Z")</c> with the right metadata.
/// </summary>
public record CompositionContext(
string Name,
IReadOnlyList<AttributeShape> Attributes,
IReadOnlyList<ScriptShape> Scripts);
public record FormatRequest(string Code);
public record FormatResponse(string Code);
public record InlayHintsRequest(
string Code,
IReadOnlyList<ScriptShape>? SiblingScripts = null);
public record InlayHintsResponse(IReadOnlyList<InlayHint> Hints);
public record InlayHint(int Line, int Column, string Label);
@@ -0,0 +1,45 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Minimal-API endpoint group for Roslyn-backed script analysis (diagnostics, completions, hover, etc.).
/// </summary>
public static class ScriptAnalysisEndpoints
{
/// <summary>Registers all script analysis endpoints under <c>/api/script-analysis</c>.</summary>
/// <param name="endpoints">The endpoint route builder to register against.</param>
/// <returns>The same <paramref name="endpoints"/> instance for chaining.</returns>
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{
var group = endpoints.MapGroup("/api/script-analysis")
.RequireAuthorization(AuthorizationPolicies.RequireDesign);
group.MapPost("/diagnostics", (DiagnoseRequest req, ScriptAnalysisService svc) =>
Results.Ok(svc.Diagnose(req)));
group.MapPost("/completions", async (CompletionsRequest req, ScriptAnalysisService svc) =>
Results.Ok(await svc.CompleteAsync(req)));
group.MapPost("/hover", async (HoverRequest req, ScriptAnalysisService svc) =>
Results.Ok(await svc.Hover(req)));
group.MapPost("/signature-help", async (SignatureHelpRequest req, ScriptAnalysisService svc) =>
Results.Ok(await svc.SignatureHelp(req)));
group.MapPost("/format", (FormatRequest req, ScriptAnalysisService svc) =>
Results.Ok(svc.Format(req)));
group.MapPost("/inlay-hints", (InlayHintsRequest req, ScriptAnalysisService svc) =>
Results.Ok(svc.InlayHints(req)));
group.MapPost("/run", async (SandboxRunRequest req, ScriptAnalysisService svc, HttpContext http) =>
Results.Ok(await svc.RunInSandboxAsync(req, http.RequestAborted)));
return endpoints;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
namespace ZB.MOM.WW.ScadaBridge.CentralUI.ScriptAnalysis;
/// <summary>
/// Parses the parameter-definitions and return-definition JSON Schema written
/// by SchemaBuilder into a <see cref="ScriptShape"/>. Delegates to
/// <see cref="JsonSchemaShapeParser"/>, which also handles legacy flat-shape
/// rows during the transition window.
/// </summary>
public static class ScriptShapeParser
{
/// <summary>Parses the parameter and return-type JSON schemas for a script and returns a <see cref="ScriptShape"/> describing its signature.</summary>
/// <param name="name">The canonical script name.</param>
/// <param name="parametersJson">The JSON Schema or legacy flat-array parameters definition, or <c>null</c> for parameterless scripts.</param>
/// <param name="returnJson">The JSON Schema or legacy return-type definition, or <c>null</c> for void scripts.</param>
public static ScriptShape Parse(string name, string? parametersJson, string? returnJson)
{
var parameters = JsonSchemaShapeParser.ParseParameters(parametersJson);
var returnType = JsonSchemaShapeParser.ParseReturnType(returnJson);
return new ScriptShape(name, parameters, returnType);
}
}