feat(ui/scripts): SCADA-specific Monaco extensions

Wave 3 of the Monaco/Roslyn integration. Adds the four extensions
agreed in the design Q&A:

  1. Parameters["..."] keys — when the cursor is inside a string
     literal that's the index of a Parameters[] element-access,
     completions return the parameter names declared in the form's
     ParameterListEditor.
  2. CallShared("...") names — when the cursor is inside a string
     literal argument to a CallShared(...) invocation, completions
     return the names of all shared scripts (resolved server-side
     via SharedScriptService).
  3. CallScript("...") names — same shape, but uses sibling-script
     names passed from the form (TemplateEdit's _scripts list).
  4. Forbidden-API diagnostic — squiggles uses of the documented
     script trust model bans: System.IO / Diagnostics / Reflection /
     Net / Threading.Thread namespaces, plus the named types File,
     Directory, Process, Thread, Socket, etc. New diagnostic codes
     SCADA001 (using directive) and SCADA002 (type identifier).

ScriptAnalysisService gains a SharedScriptService dependency
(scoped, hence the analyzer is now scoped too); CompletionsRequest
carries DeclaredParameters and SiblingScripts; Complete is now async.

MonacoEditor.razor exposes DeclaredParameters / SiblingScripts
parameters plus a [JSInvokable] GetContext() so the JS side asks
for the latest form state on every completion request. The
provider in monaco-init.js looks up the owning editor from the
internal editors map and forwards the context.

ScriptParameterNames helper parses the ParameterListEditor JSON
into a name list — used by SharedScriptForm, ApiMethodForm, and
TemplateEdit's Add-Script form to populate the Monaco context.

Smoke-verified via direct fetch + Monaco trigger:
  - var x = Parameters["  →  popup: "name" (declared parameter)
  - var y = CallShared("  →  popup: GetWeather, Greet
  - using System.IO;      →  SCADA001 squiggle
  - Process.Start(...)    →  SCADA002 squiggle
  - File.ReadAllText(...) →  SCADA002 squiggle

Also fixed: ScriptAnalysisService scoped (was singleton, broke DI
because SharedScriptService is scoped); JS normalizes Pascal-case
context keys from Blazor's record serialization to camel-case for
the request body.
This commit is contained in:
Joseph Doherty
2026-05-12 04:56:56 -04:00
parent cf9548e9ed
commit 225817eac9
10 changed files with 250 additions and 14 deletions
@@ -3,6 +3,7 @@ using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Scripting;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Scripting;
using ScadaLink.TemplateEngine;
namespace ScadaLink.CentralUI.ScriptAnalysis;
@@ -11,6 +12,12 @@ namespace ScadaLink.CentralUI.ScriptAnalysis;
/// <see cref="ScriptHost"/> globals and surfaces diagnostics + completions
/// in the shape Monaco's provider APIs expect. Lightweight — no caching;
/// each request rebuilds the script. Acceptable for human-paced edits.
///
/// Beyond plain C# analysis, layers SCADA-specific extensions:
/// - In-string completion of Parameters["..."] keys (from the request's
/// DeclaredParameters), CallShared("...") names (from SharedScriptService),
/// and CallScript("...") names (from the request's SiblingScripts).
/// - Forbidden-API diagnostic for the documented script trust model.
/// </summary>
public class ScriptAnalysisService
{
@@ -28,6 +35,34 @@ public class ScriptAnalysisService
"System.Text",
"System.Threading.Tasks");
// Namespaces and types banned by the script trust model.
// Tasks live under System.Threading.Tasks and remain allowed.
private static readonly string[] ForbiddenNamespacePrefixes =
{
"System.IO",
"System.Diagnostics",
"System.Reflection",
"System.Net",
"System.Threading.Thread",
"System.Threading.Tasks.Sources",
};
private static readonly HashSet<string> ForbiddenTypeNames = new(StringComparer.Ordinal)
{
"File", "Directory", "Path", "StreamReader", "StreamWriter", "FileStream",
"Process", "ProcessStartInfo",
"Assembly", "Type", "MethodInfo", "PropertyInfo", "FieldInfo",
"Socket", "TcpClient", "UdpClient", "TcpListener",
"Thread", "ThreadPool", "Mutex", "Semaphore",
};
private readonly SharedScriptService _sharedScripts;
public ScriptAnalysisService(SharedScriptService sharedScripts)
{
_sharedScripts = sharedScripts;
}
public DiagnoseResponse Diagnose(DiagnoseRequest request)
{
if (string.IsNullOrEmpty(request.Code))
@@ -53,10 +88,16 @@ public class ScriptAnalysisService
.Select(ToMarker)
.ToList();
var tree = compilation.SyntaxTrees.FirstOrDefault();
if (tree != null)
{
markers.AddRange(FindForbiddenApiUsages(tree));
}
return new DiagnoseResponse(markers);
}
public CompletionsResponse Complete(CompletionsRequest request)
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest request)
{
if (string.IsNullOrEmpty(request.CodeText))
return new CompletionsResponse(Array.Empty<CompletionItem>());
@@ -82,7 +123,13 @@ public class ScriptAnalysisService
var root = tree.GetRoot();
var token = root.FindToken(Math.Max(0, position - 1));
// Dot completion: look up members of the type on the left of the dot.
// SCADA-specific string-literal completions take priority over plain C#
// because they're the actually useful suggestions inside those literals.
var stringMatches = await TryStringLiteralCompletions(token, request);
if (stringMatches != null)
return new CompletionsResponse(stringMatches);
// Dot completion: members of the type on the left of the dot.
var dotMembers = TryGetDotMembers(token, semanticModel);
if (dotMembers != null)
return new CompletionsResponse(dotMembers);
@@ -99,10 +146,62 @@ public class ScriptAnalysisService
return new CompletionsResponse(scoped);
}
private async Task<List<CompletionItem>?> TryStringLiteralCompletions(
SyntaxToken token, CompletionsRequest request)
{
// The token at the cursor must be (or be adjacent to) a string literal.
var literal = token.IsKind(SyntaxKind.StringLiteralToken)
? token
: token.GetPreviousToken().IsKind(SyntaxKind.StringLiteralToken)
? token.GetPreviousToken()
: default;
if (literal == default) return null;
// Token tree shape: StringLiteralToken → LiteralExpression → Argument →
// (ArgumentList | BracketedArgumentList) → invocation or element-access.
var argument = literal.Parent?.Parent as ArgumentSyntax;
var argumentList = argument?.Parent;
var owner = argumentList?.Parent;
// Parameters["..."]
if (owner is ElementAccessExpressionSyntax elem
&& elem.Expression is IdentifierNameSyntax id
&& id.Identifier.ValueText == "Parameters")
{
return (request.DeclaredParameters ?? Array.Empty<string>())
.Distinct()
.Select(n => new CompletionItem(n, n, "declared parameter", "Variable"))
.ToList();
}
// CallShared("...") / CallScript("...")
if (owner is InvocationExpressionSyntax inv)
{
var calleeName = (inv.Expression as IdentifierNameSyntax)?.Identifier.ValueText
?? (inv.Expression as MemberAccessExpressionSyntax)?.Name.Identifier.ValueText;
if (calleeName == "CallShared")
{
var scripts = await _sharedScripts.GetAllSharedScriptsAsync();
return scripts
.Select(s => new CompletionItem(s.Name, s.Name, "shared script", "Method"))
.ToList();
}
if (calleeName == "CallScript")
{
return (request.SiblingScripts ?? Array.Empty<string>())
.Distinct()
.Select(n => new CompletionItem(n, n, "sibling script", "Method"))
.ToList();
}
}
return null;
}
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
{
// The cursor may be positioned right after a '.'; resolve the
// member-access node and look up the left-hand side's type members.
var memberAccess = token.Parent as MemberAccessExpressionSyntax
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
if (memberAccess == null) return null;
@@ -121,6 +220,57 @@ public class ScriptAnalysisService
.ToList();
}
private static IEnumerable<DiagnosticMarker> FindForbiddenApiUsages(SyntaxTree tree)
{
var root = tree.GetRoot();
// Banned using directives.
foreach (var u in root.DescendantNodes().OfType<UsingDirectiveSyntax>())
{
var name = u.Name?.ToString() ?? "";
if (ForbiddenNamespacePrefixes.Any(p => name == p || name.StartsWith(p + ".")))
{
var span = u.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: $"Forbidden namespace '{name}' is not allowed in scripts (script trust model).",
Code: "SCADA001");
}
}
// Banned type identifiers (e.g., new Process(), File.ReadAllText, etc.).
// Note: this is a name-based heuristic — false positives are possible for
// user identifiers that happen to share names with forbidden types.
foreach (var ident in root.DescendantNodes().OfType<IdentifierNameSyntax>())
{
var name = ident.Identifier.ValueText;
if (ForbiddenTypeNames.Contains(name))
{
// Filter: only flag when used as a type or as a member-access target.
var parent = ident.Parent;
var isTypeOrAccess =
parent is MemberAccessExpressionSyntax m && m.Expression == ident ||
parent is QualifiedNameSyntax ||
parent is ObjectCreationExpressionSyntax;
if (!isTypeOrAccess) continue;
var span = ident.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: $"Type '{name}' is forbidden in scripts (script trust model).",
Code: "SCADA002");
}
}
}
private static CompletionItem ToCompletionItem(ISymbol symbol)
{
var kind = symbol.Kind switch