feat(scripts): realign Test Run with runtime API, add anonymous-object calls and instance binding
The Test Run sandbox and Monaco analysis modelled a script API that had drifted from the site runtime's ScriptGlobals, so real scripts failed to compile in Test Run. Realign both to the runtime surface (Instance/Scripts/ExternalSystem/Attributes/Children/Parent) and drop the duplicate ScriptHost stub so the two cannot diverge again. - Script calls (Scripts.CallShared, Instance.CallScript, Route.To().Call) accept an anonymous object instead of a hand-built dictionary, via a shared ScriptArgs normalizer; existing dictionary calls still compile. - Test Run can optionally bind to a deployed instance, so Instance/ Attributes/CallScript route to it cross-site; adds site-side RouteToGetAttributes/RouteToSetAttributes handlers. - Adds Test Run panels to the API method and template script editors. - Fixes the TestDatabaseQuery seed script, which queried a table that never existed. Also commits unrelated in-progress work already in the tree: the health monitoring report loop, site streaming changes, and the Admin/Design data-connection and SMTP page reorganization.
This commit is contained in:
@@ -9,8 +9,17 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
public interface ISharedScriptCatalog
|
||||
{
|
||||
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>
|
||||
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;
|
||||
@@ -24,4 +33,12 @@ public class SharedScriptCatalog : ISharedScriptCatalog
|
||||
.Select(s => ScriptShapeParser.Parse(s.Name, s.ParameterDefinitions, s.ReturnDefinition))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
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,56 @@
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Globals type seen by inbound API method scripts during analysis. Mirrors
|
||||
/// the surface the runtime exposes (see ScadaLink.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
|
||||
{
|
||||
public ScriptParameters Parameters { get; init; } = new();
|
||||
|
||||
public RouteHelper Route { get; } = new();
|
||||
|
||||
public System.Threading.CancellationToken CancellationToken { get; }
|
||||
|
||||
/// <summary>Editor mirror of ScadaLink.InboundAPI.RouteHelper.</summary>
|
||||
public class RouteHelper
|
||||
{
|
||||
public RouteTarget To(string instanceCode) => new();
|
||||
}
|
||||
|
||||
/// <summary>Editor mirror of ScadaLink.InboundAPI.RouteTarget.</summary>
|
||||
public class RouteTarget
|
||||
{
|
||||
public System.Threading.Tasks.Task<object?> Call(
|
||||
string scriptName,
|
||||
object? parameters = null,
|
||||
System.Threading.CancellationToken cancellationToken = default) =>
|
||||
System.Threading.Tasks.Task.FromResult<object?>(null);
|
||||
|
||||
public System.Threading.Tasks.Task<object?> GetAttribute(
|
||||
string attributeName,
|
||||
System.Threading.CancellationToken cancellationToken = default) =>
|
||||
System.Threading.Tasks.Task.FromResult<object?>(null);
|
||||
|
||||
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?>());
|
||||
|
||||
public System.Threading.Tasks.Task SetAttribute(
|
||||
string attributeName,
|
||||
string value,
|
||||
System.Threading.CancellationToken cancellationToken = default) =>
|
||||
System.Threading.Tasks.Task.CompletedTask;
|
||||
|
||||
public System.Threading.Tasks.Task SetAttributes(
|
||||
IReadOnlyDictionary<string, string> attributeValues,
|
||||
System.Threading.CancellationToken cancellationToken = default) =>
|
||||
System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
using System.Data.Common;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// User-facing surface for <c>ExternalSystem.Call</c> /
|
||||
/// <c>ExternalSystem.CachedCall</c> inside a Test Run. Mirrors
|
||||
/// ExternalSystemHelper in ScadaLink.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;
|
||||
|
||||
public SandboxExternalHelper(IExternalSystemClient? client, string instanceName)
|
||||
{
|
||||
_client = client;
|
||||
_instanceName = instanceName;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public class SandboxDatabaseHelper
|
||||
{
|
||||
private readonly IDatabaseGateway? _gateway;
|
||||
private readonly string _instanceName;
|
||||
|
||||
public SandboxDatabaseHelper(IDatabaseGateway? gateway, string instanceName)
|
||||
{
|
||||
_gateway = gateway;
|
||||
_instanceName = instanceName;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public class SandboxNotifyHelper
|
||||
{
|
||||
private readonly INotificationDeliveryService? _service;
|
||||
private readonly string _instanceName;
|
||||
|
||||
public SandboxNotifyHelper(INotificationDeliveryService? service, string instanceName)
|
||||
{
|
||||
_service = service;
|
||||
_instanceName = instanceName;
|
||||
}
|
||||
|
||||
public SandboxNotifyTarget To(string listName) =>
|
||||
new(listName, _service, _instanceName);
|
||||
}
|
||||
|
||||
public class SandboxNotifyTarget
|
||||
{
|
||||
private readonly string _listName;
|
||||
private readonly INotificationDeliveryService? _service;
|
||||
private readonly string _instanceName;
|
||||
|
||||
internal SandboxNotifyTarget(string listName, INotificationDeliveryService? service, string instanceName)
|
||||
{
|
||||
_listName = listName;
|
||||
_service = service;
|
||||
_instanceName = instanceName;
|
||||
}
|
||||
|
||||
public Task<NotificationResult> Send(string subject, string message, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_service == null)
|
||||
throw new ScriptSandboxException(
|
||||
$"Notify.To(\"{_listName}\").Send(...) — notification service not configured for Test Run.");
|
||||
return _service.SendAsync(_listName, subject, message, _instanceName, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.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
|
||||
{
|
||||
public ScriptParameters Parameters { get; init; } = new();
|
||||
|
||||
public CancellationToken CancellationToken { get; init; }
|
||||
|
||||
public RouteAccessor Route { get; } = new();
|
||||
|
||||
/// <summary>Mirror of ScadaLink.InboundAPI.RouteHelper.</summary>
|
||||
public class RouteAccessor
|
||||
{
|
||||
public RouteTarget To(string instanceCode) => new(instanceCode);
|
||||
}
|
||||
|
||||
/// <summary>Mirror of ScadaLink.InboundAPI.RouteTarget — every call throws.</summary>
|
||||
public class RouteTarget
|
||||
{
|
||||
private readonly string _instanceCode;
|
||||
|
||||
internal RouteTarget(string instanceCode) => _instanceCode = instanceCode;
|
||||
|
||||
public Task<object?> Call(
|
||||
string scriptName,
|
||||
object? parameters = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw Unavailable($"Call(\"{scriptName}\")");
|
||||
|
||||
public Task<object?> GetAttribute(
|
||||
string attributeName,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw Unavailable($"GetAttribute(\"{attributeName}\")");
|
||||
|
||||
public Task<IReadOnlyDictionary<string, object?>> GetAttributes(
|
||||
IEnumerable<string> attributeNames,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw Unavailable("GetAttributes(...)");
|
||||
|
||||
public Task SetAttribute(
|
||||
string attributeName,
|
||||
string value,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
throw Unavailable($"SetAttribute(\"{attributeName}\")");
|
||||
|
||||
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,67 @@
|
||||
using ScadaLink.Commons.Messages.InboundApi;
|
||||
using ScadaLink.Communication;
|
||||
|
||||
namespace ScadaLink.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;
|
||||
|
||||
public SandboxInstanceGateway(
|
||||
CommunicationService comms,
|
||||
string siteId,
|
||||
string instanceUniqueName,
|
||||
CancellationToken runToken)
|
||||
{
|
||||
_comms = comms;
|
||||
_siteId = siteId;
|
||||
_instanceUniqueName = instanceUniqueName;
|
||||
_runToken = runToken;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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}");
|
||||
}
|
||||
|
||||
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 ScadaLink.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,236 @@
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Commons.Types.Scripts;
|
||||
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Runtime globals for the Test Run sandbox. Mirrors the real site-runtime
|
||||
/// <c>ScriptGlobals</c> surface (ScadaLink.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>, <c>Notify</c>, and
|
||||
/// <c>Scripts.CallShared</c> run against central's real services and fire for
|
||||
/// real — they do not depend on a bound instance.
|
||||
/// </summary>
|
||||
public class SandboxScriptHost
|
||||
{
|
||||
public ScriptParameters Parameters { get; init; } = new();
|
||||
|
||||
public CancellationToken CancellationToken { get; init; }
|
||||
|
||||
public AlarmContext? Alarm { get; init; }
|
||||
|
||||
public ScriptScope Scope { get; init; } = ScriptScope.Root;
|
||||
|
||||
public SandboxInstanceContext Instance { get; init; } = new();
|
||||
|
||||
public SandboxExternalHelper ExternalSystem => Instance.ExternalSystem;
|
||||
public SandboxDatabaseHelper Database => Instance.Database;
|
||||
public SandboxNotifyHelper Notify => Instance.Notify;
|
||||
public SandboxScriptCallHelper Scripts => Instance.Scripts;
|
||||
|
||||
public SandboxAttributeAccessor Attributes => new(Instance, Scope.SelfPath);
|
||||
public SandboxChildrenAccessor Children => new(Instance, Scope.SelfPath);
|
||||
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
|
||||
{
|
||||
Task<object?> GetAttributeAsync(string canonicalName, CancellationToken ct);
|
||||
Task SetAttributeAsync(string canonicalName, string value, CancellationToken ct);
|
||||
Task<object?> CallScriptAsync(
|
||||
string canonicalScriptName, IReadOnlyDictionary<string, object?>? parameters, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox mirror of <c>ScadaLink.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>Notify</c>/<c>Scripts</c> run against central's real services regardless
|
||||
/// of binding.
|
||||
/// </summary>
|
||||
public class SandboxInstanceContext
|
||||
{
|
||||
private readonly ISandboxInstanceGateway? _gateway;
|
||||
|
||||
public SandboxExternalHelper ExternalSystem { get; }
|
||||
public SandboxDatabaseHelper Database { get; }
|
||||
public SandboxNotifyHelper Notify { get; }
|
||||
public SandboxScriptCallHelper Scripts { get; }
|
||||
|
||||
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(null, "<sandbox>");
|
||||
Scripts = scripts ?? new SandboxScriptCallHelper(null);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
public SandboxScriptCallHelper(
|
||||
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callShared)
|
||||
{
|
||||
_callShared = callShared;
|
||||
}
|
||||
|
||||
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>ScadaLink.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;
|
||||
|
||||
public string ScopePrefix { get; }
|
||||
|
||||
public SandboxAttributeAccessor(SandboxInstanceContext ctx, string prefix)
|
||||
{
|
||||
_ctx = ctx;
|
||||
ScopePrefix = prefix;
|
||||
}
|
||||
|
||||
public string Resolve(string key) =>
|
||||
ScopePrefix.Length == 0 ? key : ScopePrefix + "." + key;
|
||||
|
||||
public object? this[string key]
|
||||
{
|
||||
get => _ctx.GetAttribute(Resolve(key)).GetAwaiter().GetResult();
|
||||
set => _ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
|
||||
}
|
||||
|
||||
public Task<object?> GetAsync(string key) => _ctx.GetAttribute(Resolve(key));
|
||||
|
||||
public Task SetAsync(string key, object? value)
|
||||
{
|
||||
_ctx.SetAttribute(Resolve(key), value?.ToString() ?? string.Empty);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox mirror of <c>ScadaLink.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;
|
||||
|
||||
public string Path { get; }
|
||||
|
||||
public SandboxAttributeAccessor Attributes { get; }
|
||||
|
||||
public SandboxCompositionAccessor(SandboxInstanceContext ctx, string path)
|
||||
{
|
||||
_ctx = ctx;
|
||||
Path = path;
|
||||
Attributes = new SandboxAttributeAccessor(ctx, path);
|
||||
}
|
||||
|
||||
public string ResolveScript(string scriptName) =>
|
||||
Path.Length == 0 ? scriptName : Path + "." + scriptName;
|
||||
|
||||
public Task<object?> CallScript(string scriptName, object? parameters = null)
|
||||
=> _ctx.CallScript(ResolveScript(scriptName), parameters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sandbox mirror of <c>ScadaLink.SiteRuntime.Scripts.ChildrenAccessor</c> —
|
||||
/// dictionary-style access to child compositions.
|
||||
/// </summary>
|
||||
public class SandboxChildrenAccessor
|
||||
{
|
||||
private readonly SandboxInstanceContext _ctx;
|
||||
private readonly string _selfPath;
|
||||
|
||||
public SandboxChildrenAccessor(SandboxInstanceContext ctx, string selfPath)
|
||||
{
|
||||
_ctx = ctx;
|
||||
_selfPath = selfPath;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
public ScriptSandboxException(string message) : base(message) { }
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
namespace ScadaLink.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);
|
||||
CompositionContext? Parent = null,
|
||||
ScriptKind Kind = ScriptKind.Template);
|
||||
|
||||
public record DiagnoseResponse(IReadOnlyList<DiagnosticMarker> Markers);
|
||||
|
||||
@@ -31,7 +44,8 @@ public record CompletionsRequest(
|
||||
IReadOnlyList<ScriptShape>? SiblingScripts = null,
|
||||
IReadOnlyList<AttributeShape>? SelfAttributes = null,
|
||||
IReadOnlyList<CompositionContext>? Children = null,
|
||||
CompositionContext? Parent = null);
|
||||
CompositionContext? Parent = null,
|
||||
ScriptKind Kind = ScriptKind.Template);
|
||||
|
||||
public record CompletionsResponse(IReadOnlyList<CompletionItem> Items);
|
||||
|
||||
|
||||
@@ -31,6 +31,9 @@ public static class ScriptAnalysisEndpoints
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.CodeAnalysis.CSharp.Scripting;
|
||||
@@ -7,13 +10,16 @@ using Microsoft.CodeAnalysis.CSharp.Syntax;
|
||||
using Microsoft.CodeAnalysis.Scripting;
|
||||
using Microsoft.CodeAnalysis.Text;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles user scripts as Roslyn C# Scripting fragments against
|
||||
/// <see cref="ScriptHost"/> globals and surfaces diagnostics + completions
|
||||
/// in the shape Monaco's provider APIs expect.
|
||||
/// <see cref="SandboxScriptHost"/> globals (template/shared) or
|
||||
/// <see cref="InboundScriptHost"/> (inbound API) and surfaces diagnostics +
|
||||
/// completions in the shape Monaco's provider APIs expect.
|
||||
///
|
||||
/// Diagnostics are cached by code hash via IMemoryCache — Monaco debounces
|
||||
/// keystrokes at 500 ms but a typing-then-pausing flow can still re-issue
|
||||
@@ -23,9 +29,10 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
///
|
||||
/// Beyond plain C# analysis, layers SCADA-specific extensions:
|
||||
/// - In-string completion of Parameters["..."] keys (from the request's
|
||||
/// DeclaredParameters), CallShared("...") names (from
|
||||
/// <see cref="ISharedScriptCatalog"/>), and CallScript("...") names
|
||||
/// (from the request's SiblingScripts).
|
||||
/// DeclaredParameters), Scripts.CallShared("...") names (from
|
||||
/// <see cref="ISharedScriptCatalog"/>), and Instance.CallScript("...") /
|
||||
/// Children["X"].CallScript("...") / Parent.CallScript("...") names
|
||||
/// (from the request's SiblingScripts / Children / Parent).
|
||||
/// - Forbidden-API diagnostic for the documented script trust model,
|
||||
/// resolved against the SemanticModel so user identifiers that happen
|
||||
/// to share names with forbidden types (e.g. <c>var File = ...</c>)
|
||||
@@ -39,7 +46,9 @@ public class ScriptAnalysisService
|
||||
typeof(Enumerable).Assembly,
|
||||
typeof(System.Collections.Generic.Dictionary<,>).Assembly,
|
||||
typeof(System.ComponentModel.DescriptionAttribute).Assembly,
|
||||
typeof(ScriptHost).Assembly)
|
||||
typeof(Microsoft.CSharp.RuntimeBinder.CSharpArgumentInfo).Assembly,
|
||||
typeof(Commons.Types.ScriptParameters).Assembly,
|
||||
typeof(SandboxScriptHost).Assembly)
|
||||
.AddImports(
|
||||
"System",
|
||||
"System.Collections.Generic",
|
||||
@@ -61,26 +70,46 @@ public class ScriptAnalysisService
|
||||
|
||||
private readonly ISharedScriptCatalog _sharedScripts;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
public ScriptAnalysisService(ISharedScriptCatalog sharedScripts, IMemoryCache cache)
|
||||
public ScriptAnalysisService(
|
||||
ISharedScriptCatalog sharedScripts,
|
||||
IMemoryCache cache,
|
||||
IServiceProvider services)
|
||||
{
|
||||
_sharedScripts = sharedScripts;
|
||||
_cache = cache;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
/// <summary>Globals type a script of the given kind is compiled against.</summary>
|
||||
private static Type GlobalsTypeFor(ScriptKind kind) =>
|
||||
kind == ScriptKind.InboundApi ? typeof(InboundScriptHost) : typeof(SandboxScriptHost);
|
||||
|
||||
/// <summary>
|
||||
/// Re-enables the nullable annotation context for an analysis compilation.
|
||||
/// Roslyn scripting defaults to a disabled nullable context, which makes any
|
||||
/// <c>?</c> annotation in a user script raise CS8632. Annotations-only keeps
|
||||
/// <c>string?</c> legal without surfacing the nullable-flow warnings.
|
||||
/// </summary>
|
||||
private static Compilation WithNullableAnnotations(Compilation compilation) =>
|
||||
compilation is CSharpCompilation cs
|
||||
? cs.WithOptions(cs.Options.WithNullableContextOptions(NullableContextOptions.Annotations))
|
||||
: compilation;
|
||||
|
||||
public DiagnoseResponse Diagnose(DiagnoseRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Code))
|
||||
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
||||
|
||||
var cacheKey = "diag:" + HashCode(request.Code);
|
||||
var cacheKey = "diag:" + (int)request.Kind + ":" + HashCode(request.Code);
|
||||
if (_cache.TryGetValue(cacheKey, out DiagnoseResponse? cached) && cached is not null)
|
||||
return cached;
|
||||
|
||||
Script<object> script;
|
||||
try
|
||||
{
|
||||
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: typeof(ScriptHost));
|
||||
script = CSharpScript.Create(request.Code, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -91,7 +120,7 @@ public class ScriptAnalysisService
|
||||
return Cache(cacheKey, failure);
|
||||
}
|
||||
|
||||
var compilation = script.GetCompilation();
|
||||
var compilation = WithNullableAnnotations(script.GetCompilation());
|
||||
var markers = compilation
|
||||
.GetDiagnostics()
|
||||
.Where(d => d.Severity >= DiagnosticSeverity.Info && d.Location.IsInSource)
|
||||
@@ -104,8 +133,6 @@ public class ScriptAnalysisService
|
||||
var model = compilation.GetSemanticModel(tree);
|
||||
markers.AddRange(FindForbiddenApiUsages(tree, model));
|
||||
markers.AddRange(FindUnknownParameterKeys(tree, request.DeclaredParameters));
|
||||
markers.AddRange(FindArgumentCountMismatches(tree, request.SiblingScripts));
|
||||
markers.AddRange(FindArgumentTypeMismatches(tree, request.SiblingScripts));
|
||||
markers.AddRange(FindUnknownAttributeKeys(tree, request));
|
||||
markers.AddRange(FindUnknownChildren(tree, request.Children));
|
||||
}
|
||||
@@ -113,6 +140,341 @@ public class ScriptAnalysisService
|
||||
return Cache(cacheKey, new DiagnoseResponse(markers));
|
||||
}
|
||||
|
||||
private const int SandboxMaxTimeoutSeconds = 10;
|
||||
private const int SandboxDefaultTimeoutSeconds = 5;
|
||||
private const int SandboxMaxConsoleChars = 32_000;
|
||||
private const int SandboxMaxReturnJsonChars = 32_000;
|
||||
|
||||
private const int SandboxMaxCallSharedDepth = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Compiles and runs a script in the central process. The globals surface
|
||||
/// depends on <see cref="SandboxRunRequest.Kind"/>: template and shared
|
||||
/// scripts run against <see cref="SandboxScriptHost"/>, inbound API method
|
||||
/// scripts against <see cref="SandboxInboundScriptHost"/>.
|
||||
/// Pure logic + the supplied Parameters always work.
|
||||
/// For the SandboxScriptHost surface, <c>Attributes</c> still throws while
|
||||
/// <c>External</c>, <c>Database</c>, and <c>Notify</c> are wired to
|
||||
/// central's real <see cref="IExternalSystemClient"/>,
|
||||
/// <see cref="IDatabaseGateway"/>, and
|
||||
/// <see cref="INotificationDeliveryService"/> — calls fire for real and
|
||||
/// have production-equivalent side effects (HTTP, SQL, SMTP).
|
||||
/// <c>CallShared</c> compiles and executes the named shared script in the
|
||||
/// same sandbox, with a recursion limit of
|
||||
/// <see cref="SandboxMaxCallSharedDepth"/>. <c>CallScript</c> still throws
|
||||
/// because a shared script has no template siblings in this context.
|
||||
/// For the SandboxInboundScriptHost surface, every <c>Route</c> call throws
|
||||
/// because cross-site routing needs a deployed site.
|
||||
/// Console.Out / Console.Error are redirected per-call so writes from
|
||||
/// the script land in the result.
|
||||
/// </summary>
|
||||
public async Task<SandboxRunResult> RunInSandboxAsync(SandboxRunRequest request, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.Code))
|
||||
{
|
||||
return new SandboxRunResult(
|
||||
Success: false,
|
||||
ReturnValueJson: null,
|
||||
ReturnTypeName: null,
|
||||
ConsoleOutput: "",
|
||||
Error: "Script code is empty.",
|
||||
ErrorKind: SandboxErrorKind.CompileError,
|
||||
DurationMs: 0,
|
||||
Markers: Array.Empty<DiagnosticMarker>());
|
||||
}
|
||||
|
||||
var timeoutSeconds = Math.Clamp(
|
||||
request.TimeoutSeconds ?? SandboxDefaultTimeoutSeconds,
|
||||
1, SandboxMaxTimeoutSeconds);
|
||||
|
||||
var options = DefaultOptions.WithReferences(DefaultOptions.MetadataReferences.Concat(new[]
|
||||
{
|
||||
Microsoft.CodeAnalysis.MetadataReference.CreateFromFile(typeof(SandboxScriptHost).Assembly.Location)
|
||||
}));
|
||||
|
||||
var globalsType = request.Kind == ScriptKind.InboundApi
|
||||
? typeof(SandboxInboundScriptHost)
|
||||
: typeof(SandboxScriptHost);
|
||||
|
||||
Script<object> script;
|
||||
try
|
||||
{
|
||||
script = CSharpScript.Create(request.Code, options, globalsType: globalsType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new SandboxRunResult(false, null, null, "", ex.Message,
|
||||
SandboxErrorKind.CompileError, 0,
|
||||
new[] { new DiagnosticMarker(8, 1, 1, 1, 2, ex.Message, "SCRIPT_BUILD") });
|
||||
}
|
||||
|
||||
var compileDiagnostics = script.Compile(ct);
|
||||
var errorDiagnostics = compileDiagnostics
|
||||
.Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource)
|
||||
.ToList();
|
||||
if (errorDiagnostics.Count > 0)
|
||||
{
|
||||
var markers = errorDiagnostics.Select(ToMarker).ToList();
|
||||
return new SandboxRunResult(false, null, null, "",
|
||||
string.Join("\n", errorDiagnostics.Select(d => d.GetMessage())),
|
||||
SandboxErrorKind.CompileError, 0, markers);
|
||||
}
|
||||
|
||||
var parameters = ConvertJsonParameters(request.Parameters);
|
||||
|
||||
using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds));
|
||||
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, timeoutCts.Token);
|
||||
|
||||
// Optional instance binding: when the Test Run targets a deployed
|
||||
// instance, Instance.GetAttribute/SetAttribute/CallScript and the
|
||||
// Attributes/Children/Parent accessors route to it cross-site.
|
||||
ISandboxInstanceGateway? instanceGateway = null;
|
||||
var instanceLabel = "test-run";
|
||||
if (request.Kind != ScriptKind.InboundApi
|
||||
&& !string.IsNullOrWhiteSpace(request.BindInstanceUniqueName))
|
||||
{
|
||||
var bindName = request.BindInstanceUniqueName.Trim();
|
||||
var locator = _services.GetService<IInstanceLocator>();
|
||||
var comms = _services.GetService<ScadaLink.Communication.CommunicationService>();
|
||||
if (locator == null || comms == null)
|
||||
return new SandboxRunResult(false, null, null, "",
|
||||
"Instance binding is unavailable — cross-site communication is not configured on this node.",
|
||||
SandboxErrorKind.SandboxLimitation, 0, null);
|
||||
|
||||
var siteId = await locator.GetSiteIdForInstanceAsync(bindName, ct);
|
||||
if (siteId == null)
|
||||
return new SandboxRunResult(false, null, null, "",
|
||||
$"Cannot bind to instance '{bindName}' — it is not deployed or has no assigned site.",
|
||||
SandboxErrorKind.SandboxLimitation, 0, null);
|
||||
|
||||
instanceGateway = new SandboxInstanceGateway(comms, siteId, bindName, linkedCts.Token);
|
||||
instanceLabel = bindName;
|
||||
}
|
||||
|
||||
var externalClient = _services.GetService<IExternalSystemClient>();
|
||||
var databaseGateway = _services.GetService<IDatabaseGateway>();
|
||||
var notifyService = _services.GetService<INotificationDeliveryService>();
|
||||
var external = new SandboxExternalHelper(externalClient, instanceLabel);
|
||||
var database = new SandboxDatabaseHelper(databaseGateway, instanceLabel);
|
||||
var notify = new SandboxNotifyHelper(notifyService, instanceLabel);
|
||||
|
||||
var compileCache = new Dictionary<string, Script<object>>(StringComparer.Ordinal);
|
||||
var compileCacheLock = new object();
|
||||
var depth = 0;
|
||||
|
||||
Func<string, IReadOnlyDictionary<string, object?>?, CancellationToken, Task<object?>>? callSharedFunc = null;
|
||||
|
||||
// Scripts.CallShared and the Instance helpers share one context across
|
||||
// the root script and any nested shared scripts — mirroring the site
|
||||
// runtime, where a shared script runs against the caller's Instance.
|
||||
var scriptsHelper = new SandboxScriptCallHelper(
|
||||
(name, ps, nestedCt) => callSharedFunc!(name, ps, nestedCt));
|
||||
var instanceContext = new SandboxInstanceContext(
|
||||
gateway: instanceGateway,
|
||||
external: external,
|
||||
database: database,
|
||||
notify: notify,
|
||||
scripts: scriptsHelper);
|
||||
|
||||
callSharedFunc = async (name, ps, nestedCt) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(name))
|
||||
throw new ScriptSandboxException("Scripts.CallShared called with an empty script name.");
|
||||
if (depth >= SandboxMaxCallSharedDepth)
|
||||
throw new ScriptSandboxException(
|
||||
$"Scripts.CallShared(\"{name}\") exceeded the sandbox recursion limit of {SandboxMaxCallSharedDepth} nested calls.");
|
||||
|
||||
Script<object>? compiled;
|
||||
lock (compileCacheLock) compileCache.TryGetValue(name, out compiled);
|
||||
|
||||
if (compiled == null)
|
||||
{
|
||||
var src = await _sharedScripts.GetByNameAsync(name, nestedCt);
|
||||
if (src == null)
|
||||
throw new ScriptSandboxException(
|
||||
$"Scripts.CallShared(\"{name}\") — no shared script with that name is registered in central.");
|
||||
|
||||
Script<object> built;
|
||||
try
|
||||
{
|
||||
built = CSharpScript.Create(src.Code, options, globalsType: typeof(SandboxScriptHost));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new ScriptSandboxException($"Scripts.CallShared(\"{name}\") compile failed: {ex.Message}");
|
||||
}
|
||||
var nestedDiag = built.Compile(nestedCt);
|
||||
var nestedErrors = nestedDiag
|
||||
.Where(d => d.Severity == DiagnosticSeverity.Error && d.Location.IsInSource)
|
||||
.ToList();
|
||||
if (nestedErrors.Count > 0)
|
||||
throw new ScriptSandboxException(
|
||||
$"Scripts.CallShared(\"{name}\") compile failed: {string.Join("; ", nestedErrors.Select(d => d.GetMessage()))}");
|
||||
|
||||
lock (compileCacheLock)
|
||||
{
|
||||
if (!compileCache.TryGetValue(name, out compiled))
|
||||
{
|
||||
compileCache[name] = built;
|
||||
compiled = built;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var nestedHost = new SandboxScriptHost
|
||||
{
|
||||
Parameters = new Commons.Types.ScriptParameters(ps ?? new Dictionary<string, object?>()),
|
||||
CancellationToken = nestedCt,
|
||||
Instance = instanceContext,
|
||||
};
|
||||
|
||||
Interlocked.Increment(ref depth);
|
||||
try
|
||||
{
|
||||
var nestedState = await compiled!.RunAsync(nestedHost, nestedCt).ConfigureAwait(false);
|
||||
return nestedState.ReturnValue;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Interlocked.Decrement(ref depth);
|
||||
}
|
||||
};
|
||||
|
||||
// Inbound API scripts see a different globals surface (Parameters +
|
||||
// Route); template and shared scripts see the SandboxScriptHost surface
|
||||
// mirroring the site runtime's ScriptGlobals.
|
||||
object host = request.Kind == ScriptKind.InboundApi
|
||||
? new SandboxInboundScriptHost
|
||||
{
|
||||
Parameters = new Commons.Types.ScriptParameters(parameters),
|
||||
CancellationToken = linkedCts.Token,
|
||||
}
|
||||
: new SandboxScriptHost
|
||||
{
|
||||
Parameters = new Commons.Types.ScriptParameters(parameters),
|
||||
CancellationToken = linkedCts.Token,
|
||||
Instance = instanceContext,
|
||||
};
|
||||
|
||||
var originalOut = Console.Out;
|
||||
var originalError = Console.Error;
|
||||
var captured = new StringWriter();
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
Console.SetOut(captured);
|
||||
Console.SetError(captured);
|
||||
|
||||
// Run on a thread-pool thread with no SynchronizationContext: a
|
||||
// bound script's Instance.SetAttribute / Attributes[...] block
|
||||
// synchronously on cross-site I/O (the API surface is sync by
|
||||
// contract), which would deadlock against the Blazor circuit's
|
||||
// captured context if the script ran inline.
|
||||
var state = await Task.Run(
|
||||
() => script.RunAsync(host, linkedCts.Token), linkedCts.Token)
|
||||
.ConfigureAwait(false);
|
||||
stopwatch.Stop();
|
||||
var (returnJson, returnType) = SerializeReturn(state.ReturnValue);
|
||||
return new SandboxRunResult(
|
||||
Success: true,
|
||||
ReturnValueJson: returnJson,
|
||||
ReturnTypeName: returnType,
|
||||
ConsoleOutput: TruncateConsole(captured.ToString()),
|
||||
Error: null,
|
||||
ErrorKind: SandboxErrorKind.None,
|
||||
DurationMs: stopwatch.ElapsedMilliseconds,
|
||||
Markers: null);
|
||||
}
|
||||
catch (ScriptSandboxException sandboxEx)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new SandboxRunResult(false, null, null,
|
||||
TruncateConsole(captured.ToString()), sandboxEx.Message,
|
||||
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new SandboxRunResult(false, null, null,
|
||||
TruncateConsole(captured.ToString()),
|
||||
$"Script execution exceeded the {timeoutSeconds}-second sandbox timeout.",
|
||||
SandboxErrorKind.Timeout, stopwatch.ElapsedMilliseconds, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
var inner = ex is Microsoft.CodeAnalysis.Scripting.CompilationErrorException ? ex : (ex.InnerException ?? ex);
|
||||
if (inner is ScriptSandboxException sx)
|
||||
{
|
||||
return new SandboxRunResult(false, null, null,
|
||||
TruncateConsole(captured.ToString()), sx.Message,
|
||||
SandboxErrorKind.SandboxLimitation, stopwatch.ElapsedMilliseconds, null);
|
||||
}
|
||||
return new SandboxRunResult(false, null, null,
|
||||
TruncateConsole(captured.ToString()),
|
||||
$"{inner.GetType().Name}: {inner.Message}",
|
||||
SandboxErrorKind.RuntimeError, stopwatch.ElapsedMilliseconds, null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Console.SetOut(originalOut);
|
||||
Console.SetError(originalError);
|
||||
}
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> ConvertJsonParameters(
|
||||
Dictionary<string, JsonElement>? parameters)
|
||||
{
|
||||
var result = new Dictionary<string, object?>(StringComparer.Ordinal);
|
||||
if (parameters == null) return result;
|
||||
foreach (var (key, value) in parameters)
|
||||
{
|
||||
result[key] = JsonElementToObject(value);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static object? JsonElementToObject(JsonElement element)
|
||||
{
|
||||
return element.ValueKind switch
|
||||
{
|
||||
JsonValueKind.String => element.GetString(),
|
||||
JsonValueKind.Number => element.TryGetInt64(out var i) ? (object)i : element.GetDouble(),
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.Null => null,
|
||||
JsonValueKind.Undefined => null,
|
||||
JsonValueKind.Array => element.EnumerateArray().Select(JsonElementToObject).ToList(),
|
||||
JsonValueKind.Object => element.EnumerateObject()
|
||||
.ToDictionary(p => p.Name, p => JsonElementToObject(p.Value)),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static (string Json, string TypeName) SerializeReturn(object? value)
|
||||
{
|
||||
if (value == null) return ("null", "null");
|
||||
var typeName = value.GetType().Name;
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value, new JsonSerializerOptions { WriteIndented = true });
|
||||
if (json.Length > SandboxMaxReturnJsonChars)
|
||||
json = json[..SandboxMaxReturnJsonChars] + "\n… (truncated)";
|
||||
return (json, typeName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return ($"\"<unserializable: {ex.Message}>\"", typeName);
|
||||
}
|
||||
}
|
||||
|
||||
private static string TruncateConsole(string text)
|
||||
{
|
||||
if (text.Length <= SandboxMaxConsoleChars) return text;
|
||||
return text[..SandboxMaxConsoleChars] + "\n… (truncated)";
|
||||
}
|
||||
|
||||
private DiagnoseResponse Cache(string key, DiagnoseResponse value)
|
||||
{
|
||||
_cache.Set(key, value, new MemoryCacheEntryOptions
|
||||
@@ -137,7 +499,7 @@ public class ScriptAnalysisService
|
||||
Script<object> script;
|
||||
try
|
||||
{
|
||||
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: typeof(ScriptHost));
|
||||
script = CSharpScript.Create(request.CodeText, DefaultOptions, globalsType: GlobalsTypeFor(request.Kind));
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -242,54 +604,32 @@ public class ScriptAnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
// CallShared("...") / CallScript("...") / Children["X"].CallScript("...") / Parent.CallScript("...")
|
||||
// Scripts.CallShared("...") / Instance.CallScript("...") /
|
||||
// Children["X"].CallScript("...") / Parent.CallScript("...")
|
||||
if (owner is InvocationExpressionSyntax inv)
|
||||
{
|
||||
var calleeIdName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
||||
var calleeMa = inv.Expression as MemberAccessExpressionSyntax;
|
||||
var calleeName = calleeIdName ?? calleeMa?.Name.Identifier.ValueText;
|
||||
|
||||
if (calleeName == "CallShared")
|
||||
var call = ClassifyScriptCall(inv);
|
||||
switch (call.Kind)
|
||||
{
|
||||
var shapes = await _sharedScripts.GetShapesAsync();
|
||||
return shapes.Select(s => MakeCallCompletion(s, "shared script")).ToList();
|
||||
}
|
||||
|
||||
if (calleeName == "CallScript")
|
||||
{
|
||||
// Children["X"].CallScript("..." or Parent.CallScript("...
|
||||
if (calleeMa != null)
|
||||
case ScriptCallKind.Shared:
|
||||
{
|
||||
// Children["X"].CallScript
|
||||
if (calleeMa.Expression is ElementAccessExpressionSyntax childElem
|
||||
&& childElem.Expression is IdentifierNameSyntax cid
|
||||
&& cid.Identifier.ValueText == "Children"
|
||||
&& childElem.ArgumentList.Arguments.Count == 1
|
||||
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
|
||||
&& cLit.IsKind(SyntaxKind.StringLiteralExpression))
|
||||
{
|
||||
var compName = cLit.Token.ValueText;
|
||||
var comp = (request.Children ?? Array.Empty<CompositionContext>())
|
||||
.FirstOrDefault(c => c.Name == compName);
|
||||
if (comp != null)
|
||||
return comp.Scripts.Select(s => MakeCallCompletion(s, $"script on {compName}")).ToList();
|
||||
return new List<CompletionItem>();
|
||||
}
|
||||
// Parent.CallScript
|
||||
if (calleeMa.Expression is IdentifierNameSyntax pid
|
||||
&& pid.Identifier.ValueText == "Parent"
|
||||
&& request.Parent != null)
|
||||
{
|
||||
return request.Parent.Scripts
|
||||
.Select(s => MakeCallCompletion(s, "parent script"))
|
||||
.ToList();
|
||||
}
|
||||
var shapes = await _sharedScripts.GetShapesAsync();
|
||||
return shapes.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
|
||||
}
|
||||
case ScriptCallKind.Sibling:
|
||||
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
|
||||
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
|
||||
case ScriptCallKind.Parent:
|
||||
return (request.Parent?.Scripts ?? Array.Empty<ScriptShape>())
|
||||
.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList();
|
||||
case ScriptCallKind.Child:
|
||||
{
|
||||
var comp = (request.Children ?? Array.Empty<CompositionContext>())
|
||||
.FirstOrDefault(c => c.Name == call.CompositionName);
|
||||
return comp != null
|
||||
? comp.Scripts.Select(s => MakeCallCompletion(s, CallDetail(call))).ToList()
|
||||
: new List<CompletionItem>();
|
||||
}
|
||||
|
||||
// Plain CallScript("...") — siblings
|
||||
return (request.SiblingScripts ?? Array.Empty<ScriptShape>())
|
||||
.Select(s => MakeCallCompletion(s, "sibling script"))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -298,24 +638,25 @@ public class ScriptAnalysisService
|
||||
|
||||
/// <summary>
|
||||
/// Builds a Monaco snippet that fills the call after the name, e.g.
|
||||
/// <c>Greet", ${1:name}, ${2:count})</c>. The JS provider extends the
|
||||
/// completion range over the auto-closed <c>")</c> if Monaco inserted
|
||||
/// one, so the snippet replaces the rest of the call cleanly.
|
||||
/// <c>Greet", new { name = ${1:name}, count = ${2:count} })</c>. The JS
|
||||
/// provider extends the completion range over the auto-closed <c>")</c> if
|
||||
/// Monaco inserted one, so the snippet replaces the rest of the call cleanly.
|
||||
/// </summary>
|
||||
private static CompletionItem MakeCallCompletion(ScriptShape shape, string detail)
|
||||
{
|
||||
// The runtime call API takes the arguments as an anonymous object; the
|
||||
// snippet emits one member per declared parameter.
|
||||
string insertText;
|
||||
int insertRules;
|
||||
const int insertAsSnippet = 4;
|
||||
if (shape.Parameters.Count == 0)
|
||||
{
|
||||
insertText = shape.Name + "\")";
|
||||
insertRules = 4;
|
||||
}
|
||||
else
|
||||
{
|
||||
var args = string.Join(", ", shape.Parameters.Select((p, i) => $"${{{i + 1}:{p.Name}}}"));
|
||||
insertText = $"{shape.Name}\", {args})";
|
||||
insertRules = 4;
|
||||
var entries = string.Join(", ", shape.Parameters.Select((p, i) =>
|
||||
$"{p.Name} = ${{{i + 1}:{p.Name}}}"));
|
||||
insertText = $"{shape.Name}\", new {{ {entries} }})";
|
||||
}
|
||||
var paramList = string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}"));
|
||||
var returnType = shape.ReturnType ?? "void";
|
||||
@@ -324,7 +665,7 @@ public class ScriptAnalysisService
|
||||
InsertText: insertText,
|
||||
Detail: $"{detail} ({paramList}) -> {returnType}",
|
||||
Kind: "Method",
|
||||
InsertTextRules: insertRules);
|
||||
InsertTextRules: insertAsSnippet);
|
||||
}
|
||||
|
||||
public FormatResponse Format(FormatRequest request)
|
||||
@@ -348,51 +689,14 @@ public class ScriptAnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
public InlayHintsResponse InlayHints(InlayHintsRequest request)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Code))
|
||||
return new InlayHintsResponse(Array.Empty<InlayHint>());
|
||||
|
||||
var script = TryParse(request.Code);
|
||||
if (script == null) return new InlayHintsResponse(Array.Empty<InlayHint>());
|
||||
var (tree, _) = script.Value;
|
||||
|
||||
IReadOnlyList<ScriptShape>? sharedShapes = null;
|
||||
IReadOnlyList<ScriptShape> SharedShapes() =>
|
||||
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
|
||||
|
||||
var hints = new List<InlayHint>();
|
||||
foreach (var inv in tree.GetRoot().DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||
{
|
||||
var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
||||
if (callee is not ("CallShared" or "CallScript")) continue;
|
||||
if (inv.ArgumentList.Arguments.Count < 1) continue;
|
||||
|
||||
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
|
||||
if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
|
||||
var scriptName = nameArg.Token.ValueText;
|
||||
if (string.IsNullOrEmpty(scriptName)) continue;
|
||||
|
||||
ScriptShape? shape = callee == "CallShared"
|
||||
? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
|
||||
: request.SiblingScripts?.FirstOrDefault(s => s.Name == scriptName);
|
||||
if (shape == null) continue;
|
||||
|
||||
for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++)
|
||||
{
|
||||
var arg = inv.ArgumentList.Arguments[i];
|
||||
var p = shape.Parameters[i - 1];
|
||||
var pos = arg.Span.Start;
|
||||
var lineSpan = tree.GetLineSpan(new TextSpan(pos, 0)).Span;
|
||||
hints.Add(new InlayHint(
|
||||
Line: lineSpan.Start.Line + 1,
|
||||
Column: lineSpan.Start.Character + 1,
|
||||
Label: $"{p.Name}:"));
|
||||
}
|
||||
}
|
||||
|
||||
return new InlayHintsResponse(hints);
|
||||
}
|
||||
/// <summary>
|
||||
/// Parameter-name inlay hints are obsolete under the runtime call API:
|
||||
/// Scripts.CallShared / Instance.CallScript pass arguments as an explicit
|
||||
/// <c>IReadOnlyDictionary</c> literal (<c>{ ["p"] = … }</c>), which is
|
||||
/// already self-labelling — there are no positional arguments to annotate.
|
||||
/// </summary>
|
||||
public InlayHintsResponse InlayHints(InlayHintsRequest request) =>
|
||||
new(Array.Empty<InlayHint>());
|
||||
|
||||
public HoverResponse Hover(HoverRequest request)
|
||||
{
|
||||
@@ -429,19 +733,15 @@ public class ScriptAnalysisService
|
||||
|
||||
if (owner is not InvocationExpressionSyntax inv) return new HoverResponse(null);
|
||||
|
||||
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
||||
var call = ClassifyScriptCall(inv);
|
||||
if (call.Kind == ScriptCallKind.None) return new HoverResponse(null);
|
||||
var rawName = token.ValueText;
|
||||
if (string.IsNullOrEmpty(rawName)) return new HoverResponse(null);
|
||||
|
||||
ScriptShape? shape = null;
|
||||
if (calleeName == "CallShared")
|
||||
shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
|
||||
.FirstOrDefault(s => s.Name == rawName);
|
||||
else if (calleeName == "CallScript" && request.SiblingScripts != null)
|
||||
shape = request.SiblingScripts.FirstOrDefault(s => s.Name == rawName);
|
||||
|
||||
var shape = ResolveCalledShape(
|
||||
call, rawName, request.SiblingScripts, request.Children, request.Parent);
|
||||
if (shape == null) return new HoverResponse(null);
|
||||
return new HoverResponse(FormatHover(shape, calleeName!));
|
||||
return new HoverResponse(FormatHover(shape, call));
|
||||
}
|
||||
|
||||
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest request)
|
||||
@@ -471,24 +771,20 @@ public class ScriptAnalysisService
|
||||
}
|
||||
if (inv == null) return empty;
|
||||
|
||||
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
||||
if (calleeName is not ("CallShared" or "CallScript")) return empty;
|
||||
var call = ClassifyScriptCall(inv);
|
||||
if (call.Kind == ScriptCallKind.None) return empty;
|
||||
|
||||
// First argument is the name literal; pull it out.
|
||||
if (inv.ArgumentList.Arguments.Count < 1) return empty;
|
||||
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
|
||||
var scriptName = nameArg?.Token.ValueText ?? "";
|
||||
|
||||
ScriptShape? shape = null;
|
||||
if (calleeName == "CallShared")
|
||||
shape = _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
|
||||
.FirstOrDefault(s => s.Name == scriptName);
|
||||
else if (request.SiblingScripts != null)
|
||||
shape = request.SiblingScripts.FirstOrDefault(s => s.Name == scriptName);
|
||||
var shape = ResolveCalledShape(
|
||||
call, scriptName, request.SiblingScripts, request.Children, request.Parent);
|
||||
if (shape == null) return empty;
|
||||
|
||||
var paramLabels = shape.Parameters.Select(p => $"{p.Name}: {p.Type}").ToList();
|
||||
var label = $"{calleeName}(\"{shape.Name}\"" +
|
||||
var label = $"{CallLabel(call)}(\"{shape.Name}\"" +
|
||||
(paramLabels.Count > 0 ? ", " + string.Join(", ", paramLabels) : "") + ")";
|
||||
|
||||
// ActiveParameter: count commas in ArgumentList before the cursor; subtract 1 because
|
||||
@@ -514,7 +810,7 @@ public class ScriptAnalysisService
|
||||
if (string.IsNullOrEmpty(code)) return null;
|
||||
try
|
||||
{
|
||||
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(ScriptHost));
|
||||
var s = CSharpScript.Create(code, DefaultOptions, globalsType: typeof(SandboxScriptHost));
|
||||
var compilation = s.GetCompilation();
|
||||
var tree = compilation.SyntaxTrees.FirstOrDefault();
|
||||
return tree == null ? null : (tree, compilation);
|
||||
@@ -525,14 +821,13 @@ public class ScriptAnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
private static string FormatHover(ScriptShape shape, string callee)
|
||||
private static string FormatHover(ScriptShape shape, ScriptCallInfo call)
|
||||
{
|
||||
var ps = shape.Parameters.Count == 0
|
||||
? "(no parameters)"
|
||||
: string.Join(", ", shape.Parameters.Select(p => $"{p.Name}: {p.Type}{(p.Required ? "" : "?")}"));
|
||||
var rt = shape.ReturnType ?? "void";
|
||||
var kind = callee == "CallShared" ? "shared script" : "sibling script";
|
||||
return $"**{kind}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
|
||||
return $"**{CallDetail(call)}** `{shape.Name}`\n\n```\n{shape.Name}({ps}): {rt}\n```";
|
||||
}
|
||||
|
||||
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
|
||||
@@ -583,52 +878,85 @@ public class ScriptAnalysisService
|
||||
}
|
||||
}
|
||||
|
||||
private IEnumerable<DiagnosticMarker> FindArgumentCountMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
|
||||
private enum ScriptCallKind { None, Shared, Sibling, Child, Parent }
|
||||
|
||||
/// <summary>A classified script-call invocation: which kind, and (for a child) the composition name.</summary>
|
||||
private readonly record struct ScriptCallInfo(ScriptCallKind Kind, string? CompositionName);
|
||||
|
||||
/// <summary>
|
||||
/// Classifies an invocation against the runtime call surface:
|
||||
/// <c>Scripts.CallShared(...)</c>, <c>Instance.CallScript(...)</c>,
|
||||
/// <c>Children["X"].CallScript(...)</c>, and <c>Parent.CallScript(...)</c>.
|
||||
/// The first argument of each is the called script's name literal.
|
||||
/// </summary>
|
||||
private static ScriptCallInfo ClassifyScriptCall(InvocationExpressionSyntax inv)
|
||||
{
|
||||
var root = tree.GetRoot();
|
||||
if (inv.Expression is not MemberAccessExpressionSyntax ma)
|
||||
return new ScriptCallInfo(ScriptCallKind.None, null);
|
||||
|
||||
IReadOnlyList<ScriptShape>? sharedShapes = null;
|
||||
IReadOnlyList<ScriptShape> SharedShapes() =>
|
||||
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
|
||||
var method = ma.Name.Identifier.ValueText;
|
||||
|
||||
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||
if (method == "CallShared"
|
||||
&& ma.Expression is IdentifierNameSyntax sid && sid.Identifier.ValueText == "Scripts")
|
||||
return new ScriptCallInfo(ScriptCallKind.Shared, null);
|
||||
|
||||
if (method == "CallScript")
|
||||
{
|
||||
var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
||||
if (callee is not ("CallShared" or "CallScript")) continue;
|
||||
if (inv.ArgumentList.Arguments.Count < 1) continue;
|
||||
|
||||
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
|
||||
if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
|
||||
var scriptName = nameArg.Token.ValueText;
|
||||
if (string.IsNullOrEmpty(scriptName)) continue;
|
||||
|
||||
ScriptShape? shape = callee == "CallShared"
|
||||
? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
|
||||
: siblings?.FirstOrDefault(s => s.Name == scriptName);
|
||||
if (shape == null) continue;
|
||||
|
||||
var passedCount = inv.ArgumentList.Arguments.Count - 1; // exclude name
|
||||
var expectedRequired = shape.Parameters.Count(p => p.Required);
|
||||
var expectedTotal = shape.Parameters.Count;
|
||||
|
||||
if (passedCount < expectedRequired || passedCount > expectedTotal)
|
||||
if (ma.Expression is IdentifierNameSyntax iid)
|
||||
{
|
||||
var span = inv.GetLocation().GetLineSpan().Span;
|
||||
var expected = expectedRequired == expectedTotal
|
||||
? expectedTotal.ToString()
|
||||
: $"{expectedRequired}–{expectedTotal}";
|
||||
yield return new DiagnosticMarker(
|
||||
Severity: 8,
|
||||
StartLineNumber: span.Start.Line + 1,
|
||||
StartColumn: span.Start.Character + 1,
|
||||
EndLineNumber: span.End.Line + 1,
|
||||
EndColumn: span.End.Character + 1,
|
||||
Message: $"{callee}('{scriptName}') expects {expected} argument(s) but got {passedCount}.",
|
||||
Code: "SCADA004");
|
||||
if (iid.Identifier.ValueText == "Instance")
|
||||
return new ScriptCallInfo(ScriptCallKind.Sibling, null);
|
||||
if (iid.Identifier.ValueText == "Parent")
|
||||
return new ScriptCallInfo(ScriptCallKind.Parent, null);
|
||||
}
|
||||
if (ma.Expression is ElementAccessExpressionSyntax childElem
|
||||
&& childElem.Expression is IdentifierNameSyntax cid && cid.Identifier.ValueText == "Children"
|
||||
&& childElem.ArgumentList.Arguments.Count == 1
|
||||
&& childElem.ArgumentList.Arguments[0].Expression is LiteralExpressionSyntax cLit
|
||||
&& cLit.IsKind(SyntaxKind.StringLiteralExpression))
|
||||
return new ScriptCallInfo(ScriptCallKind.Child, cLit.Token.ValueText);
|
||||
}
|
||||
|
||||
return new ScriptCallInfo(ScriptCallKind.None, null);
|
||||
}
|
||||
|
||||
/// <summary>Human-readable call expression, e.g. <c>Scripts.CallShared</c>.</summary>
|
||||
private static string CallLabel(ScriptCallInfo call) => call.Kind switch
|
||||
{
|
||||
ScriptCallKind.Shared => "Scripts.CallShared",
|
||||
ScriptCallKind.Sibling => "Instance.CallScript",
|
||||
ScriptCallKind.Parent => "Parent.CallScript",
|
||||
ScriptCallKind.Child => $"Children[\"{call.CompositionName}\"].CallScript",
|
||||
_ => "call"
|
||||
};
|
||||
|
||||
/// <summary>Short description of what the call targets, for completions/hover.</summary>
|
||||
private static string CallDetail(ScriptCallInfo call) => call.Kind switch
|
||||
{
|
||||
ScriptCallKind.Shared => "shared script",
|
||||
ScriptCallKind.Sibling => "sibling script",
|
||||
ScriptCallKind.Parent => "parent script",
|
||||
ScriptCallKind.Child => $"script on {call.CompositionName}",
|
||||
_ => "script"
|
||||
};
|
||||
|
||||
/// <summary>Resolves the called script's shape from the metadata in scope for its kind.</summary>
|
||||
private ScriptShape? ResolveCalledShape(
|
||||
ScriptCallInfo call,
|
||||
string scriptName,
|
||||
IReadOnlyList<ScriptShape>? siblings,
|
||||
IReadOnlyList<CompositionContext>? children,
|
||||
CompositionContext? parent) => call.Kind switch
|
||||
{
|
||||
ScriptCallKind.Shared => _sharedScripts.GetShapesAsync().GetAwaiter().GetResult()
|
||||
.FirstOrDefault(s => s.Name == scriptName),
|
||||
ScriptCallKind.Sibling => siblings?.FirstOrDefault(s => s.Name == scriptName),
|
||||
ScriptCallKind.Parent => parent?.Scripts.FirstOrDefault(s => s.Name == scriptName),
|
||||
ScriptCallKind.Child => children?.FirstOrDefault(c => c.Name == call.CompositionName)
|
||||
?.Scripts.FirstOrDefault(s => s.Name == scriptName),
|
||||
_ => null
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// SCADA006 — flag <c>Attributes["typo"]</c>,
|
||||
/// <c>Children["X"].Attributes["typo"]</c>, and
|
||||
@@ -758,112 +1086,6 @@ public class ScriptAnalysisService
|
||||
return new(AttributeContextKind.None, null);
|
||||
}
|
||||
|
||||
private IEnumerable<DiagnosticMarker> FindArgumentTypeMismatches(SyntaxTree tree, IReadOnlyList<ScriptShape>? siblings)
|
||||
{
|
||||
var root = tree.GetRoot();
|
||||
|
||||
IReadOnlyList<ScriptShape>? sharedShapes = null;
|
||||
IReadOnlyList<ScriptShape> SharedShapes() =>
|
||||
sharedShapes ??= _sharedScripts.GetShapesAsync().GetAwaiter().GetResult();
|
||||
|
||||
foreach (var inv in root.DescendantNodes().OfType<InvocationExpressionSyntax>())
|
||||
{
|
||||
var callee = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText;
|
||||
if (callee is not ("CallShared" or "CallScript")) continue;
|
||||
if (inv.ArgumentList.Arguments.Count < 1) continue;
|
||||
var nameArg = inv.ArgumentList.Arguments[0].Expression as LiteralExpressionSyntax;
|
||||
if (nameArg == null || !nameArg.IsKind(SyntaxKind.StringLiteralExpression)) continue;
|
||||
var scriptName = nameArg.Token.ValueText;
|
||||
if (string.IsNullOrEmpty(scriptName)) continue;
|
||||
|
||||
ScriptShape? shape = callee == "CallShared"
|
||||
? SharedShapes().FirstOrDefault(s => s.Name == scriptName)
|
||||
: siblings?.FirstOrDefault(s => s.Name == scriptName);
|
||||
if (shape == null) continue;
|
||||
|
||||
for (var i = 1; i < inv.ArgumentList.Arguments.Count && i - 1 < shape.Parameters.Count; i++)
|
||||
{
|
||||
var arg = inv.ArgumentList.Arguments[i].Expression;
|
||||
var p = shape.Parameters[i - 1];
|
||||
var literalType = LiteralTypeOf(arg);
|
||||
if (literalType == null) continue; // Not a literal we can check.
|
||||
if (TypeAccepts(p.Type, literalType.Value)) continue;
|
||||
var span = arg.GetLocation().GetLineSpan().Span;
|
||||
yield return new DiagnosticMarker(
|
||||
Severity: 8,
|
||||
StartLineNumber: span.Start.Line + 1,
|
||||
StartColumn: span.Start.Character + 1,
|
||||
EndLineNumber: span.End.Line + 1,
|
||||
EndColumn: span.End.Character + 1,
|
||||
Message: $"Argument {i} of {callee}('{scriptName}') expects {p.Type} but got {literalType}.",
|
||||
Code: "SCADA005");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum LiteralKind { String, Integer, Float, Boolean, Null }
|
||||
|
||||
private static LiteralKind? LiteralTypeOf(ExpressionSyntax expr)
|
||||
{
|
||||
if (expr is LiteralExpressionSyntax lit)
|
||||
{
|
||||
if (lit.IsKind(SyntaxKind.StringLiteralExpression)) return LiteralKind.String;
|
||||
if (lit.IsKind(SyntaxKind.TrueLiteralExpression) || lit.IsKind(SyntaxKind.FalseLiteralExpression))
|
||||
return LiteralKind.Boolean;
|
||||
if (lit.IsKind(SyntaxKind.NullLiteralExpression)) return LiteralKind.Null;
|
||||
if (lit.IsKind(SyntaxKind.NumericLiteralExpression))
|
||||
{
|
||||
var text = lit.Token.Text;
|
||||
return text.Contains('.') || text.EndsWith("f", StringComparison.OrdinalIgnoreCase)
|
||||
|| text.EndsWith("d", StringComparison.OrdinalIgnoreCase)
|
||||
? LiteralKind.Float
|
||||
: LiteralKind.Integer;
|
||||
}
|
||||
}
|
||||
if (expr is InterpolatedStringExpressionSyntax) return LiteralKind.String;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a literal of <paramref name="literal"/> is acceptable for a
|
||||
/// parameter declared as <paramref name="declared"/>. Object/List always
|
||||
/// accept (we don't introspect collection literals); Null is acceptable
|
||||
/// for any non-value type.
|
||||
/// </summary>
|
||||
private static bool TypeAccepts(string declared, LiteralKind literal)
|
||||
{
|
||||
var d = NormalizeDeclaredType(declared);
|
||||
if (literal == LiteralKind.Null) return d is "Object" or "List" or "String";
|
||||
return d switch
|
||||
{
|
||||
"Boolean" => literal == LiteralKind.Boolean,
|
||||
"Integer" => literal == LiteralKind.Integer,
|
||||
"Float" => literal is LiteralKind.Float or LiteralKind.Integer,
|
||||
"String" => literal == LiteralKind.String,
|
||||
"Object" or "List" => true,
|
||||
_ => true // unknown SCADA type — assume compatible
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes legacy / .NET type names from stored ParameterDefinitions
|
||||
/// JSON to the canonical Inbound API set. Mirrors the frontend
|
||||
/// ParameterListEditor's normalization so SCADA005 doesn't false-negative
|
||||
/// on data still in the legacy shape.
|
||||
/// </summary>
|
||||
private static string NormalizeDeclaredType(string declared) =>
|
||||
declared.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" or "bool" => "Boolean",
|
||||
"integer" or "int" or "int32" or "int64" or "int16" or "byte"
|
||||
or "sbyte" or "uint32" or "uint64" or "uint16" => "Integer",
|
||||
"float" or "double" or "single" or "decimal" => "Float",
|
||||
"string" or "datetime" => "String",
|
||||
"object" => "Object",
|
||||
"list" => "List",
|
||||
_ => declared
|
||||
};
|
||||
|
||||
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree, SemanticModel model)
|
||||
{
|
||||
var root = tree.GetRoot();
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
namespace ScadaLink.CentralUI.ScriptAnalysis;
|
||||
|
||||
/// <summary>
|
||||
/// Globals type seen by user scripts during analysis. Mirrors the surface
|
||||
/// the runtime exposes (see ScadaLink.SiteRuntime.Scripts.ScriptGlobals).
|
||||
/// The methods and indexers here are never invoked — Roslyn only reads
|
||||
/// their signatures to know what's in scope while compiling for diagnostics
|
||||
/// and completions.
|
||||
/// </summary>
|
||||
public class ScriptHost
|
||||
{
|
||||
public IReadOnlyDictionary<string, object?> Parameters { get; init; } =
|
||||
new Dictionary<string, object?>();
|
||||
|
||||
/// <summary>Invokes another shared script by name and returns its result.</summary>
|
||||
public object? CallShared(string name, params object?[] args) => null;
|
||||
|
||||
/// <summary>Invokes another script on the same template and returns its result.</summary>
|
||||
public object? CallScript(string name, params object?[] args) => null;
|
||||
|
||||
// Scope-aware accessors. SCADA-specific completion + diagnostics live in
|
||||
// ScriptAnalysisService; these stubs exist so the bare Roslyn pass doesn't
|
||||
// produce CS0103 errors on Attributes / Children / Parent.
|
||||
|
||||
public AttributeBag Attributes { get; } = new();
|
||||
public ChildrenBag Children { get; } = new();
|
||||
public CompositionBag? Parent { get; } = new();
|
||||
|
||||
public class AttributeBag
|
||||
{
|
||||
public object? this[string name]
|
||||
{
|
||||
get => null;
|
||||
set { /* no-op for analyzer */ }
|
||||
}
|
||||
public System.Threading.Tasks.Task<object?> GetAsync(string name) =>
|
||||
System.Threading.Tasks.Task.FromResult<object?>(null);
|
||||
public System.Threading.Tasks.Task SetAsync(string name, object? value) =>
|
||||
System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
public class CompositionBag
|
||||
{
|
||||
public AttributeBag Attributes { get; } = new();
|
||||
public System.Threading.Tasks.Task<object?> CallScript(string name, params object?[] args) =>
|
||||
System.Threading.Tasks.Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
public class ChildrenBag
|
||||
{
|
||||
public CompositionBag this[string compositionName] => new();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user