fix(adminui): script analysis reuses one compilation, honours cancellation, indexes the catalog (D2)

R04:P2 - every analysis derives from one process-wide bound base compilation via
ReplaceSyntaxTree (the scoped service keeps its current compilation); every entry point
takes the request token, the endpoints bind RequestAborted and answer 499 on abort, and
the Monaco providers abort their fetch when Monaco cancels the request.

R04:P3 - the catalog snapshot carries a sorted, de-duplicated string[] (binary-search
prefix completion) and an Ordinal path->info dictionary, built once per snapshot.

R04:S1 - Diagnose emits an OTSCRIPT_SETVTAG_INERT warning for each ctx.SetVirtualTag
call. It is a warning, so editor-accepts == publish-accepts still holds.

Refs #745

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 17:31:54 -04:00
parent 82380d2bee
commit 8e3bbf91fb
10 changed files with 612 additions and 97 deletions
@@ -18,10 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <see cref="VirtualTagContext"/> context.
/// </summary>
/// <remarks>
/// All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format
/// are wired and backed by the real Roslyn document/analysis pipeline. Only InlayHints is
/// intentionally a no-op (empty list) — the endpoint exists for future use but the feature is
/// not implemented.
/// <para>All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format
/// are wired and backed by the real Roslyn document/analysis pipeline.</para>
/// <para><b>Cost model.</b> Every analysis derives from one process-wide <see cref="BaseCompilation"/>
/// via <see cref="CSharpCompilation.ReplaceSyntaxTree"/>, so the sandbox reference set is bound once rather
/// than per keystroke, and every entry point takes the request's <see cref="CancellationToken"/>
/// (<c>HttpContext.RequestAborted</c>) so an abandoned request stops instead of running to completion.</para>
/// </remarks>
public sealed class ScriptAnalysisService
{
@@ -56,16 +58,44 @@ public sealed class ScriptAnalysisService
+ " var ctx = globals.ctx;\n#line 1\n";
}
// userSource is appended after Preamble, then the method/class are closed.
private static (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(string userSource)
private static SyntaxTree WrapAndParse(string userSource, CancellationToken ct)
=> CSharpSyntaxTree.ParseText(Preamble + (userSource ?? "") + "\n }\n}\n", cancellationToken: ct);
/// <summary>
/// The process-wide compilation every analysis derives from. Its sandbox reference set is bound ONCE
/// (forced here by resolving <c>System.Object</c>); <see cref="CSharpCompilation.ReplaceSyntaxTree"/> keeps
/// that binding, so a request only re-binds the user's tree instead of the whole reference set. It is
/// immutable, so sharing it across circuits and requests is thread-safe.
/// </summary>
internal static readonly CSharpCompilation BaseCompilation = CreateBaseCompilation();
private static CSharpCompilation CreateBaseCompilation()
{
var prefix = Preamble;
var full = prefix + (userSource ?? "") + "\n }\n}\n";
var tree = CSharpSyntaxTree.ParseText(full);
var compilation = CSharpCompilation.Create("ScriptAnalysis", new[] { tree }, Sandbox.References, CompileOptions);
return (tree, compilation, compilation.GetSemanticModel(tree), prefix.Length);
var compilation = CSharpCompilation.Create(
"ScriptAnalysis", new[] { WrapAndParse("", CancellationToken.None) }, Sandbox.References, CompileOptions);
_ = compilation.GetSpecialType(SpecialType.System_Object);
return compilation;
}
// This scope's current compilation (one per scoped service). Each analysis swaps its tree in with
// ReplaceSyntaxTree; a lost race between two concurrent calls on one scope is harmless because every
// compilation in the lineage is a complete, valid single-tree compilation.
private CSharpCompilation _compilation = BaseCompilation;
// userSource is appended after Preamble, then the method/class are closed.
private (SyntaxTree Tree, CSharpCompilation Compilation, SemanticModel Model, int PreambleLength) Analyze(
string userSource, CancellationToken ct)
{
var tree = WrapAndParse(userSource, ct);
var current = _compilation;
var compilation = current.ReplaceSyntaxTree(current.SyntaxTrees.Single(), tree);
_compilation = compilation;
return (tree, compilation, compilation.GetSemanticModel(tree), Preamble.Length);
}
/// <summary>Test seam: the compilation one analysis of <paramref name="userSource"/> runs against.</summary>
internal CSharpCompilation CompilationFor(string userSource) => Analyze(Normalize(userSource), CancellationToken.None).Compilation;
// editor (line,col) in USER-source coords -> physical offset in the wrapped document
private static int OffsetInWrapped(string userSource, int line, int column, int preambleLength)
=> preambleLength + Math.Clamp(PositionToOffset(userSource ?? "", line, column), 0, (userSource ?? "").Length);
@@ -87,14 +117,16 @@ public sealed class ScriptAnalysisService
/// <summary>Compiles the script and returns compile-error diagnostics plus sandbox-policy violations
/// (forbidden types, disallowed dynamic tag paths).</summary>
/// <param name="req">The diagnose request carrying the script source.</param>
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
/// <returns>The list of diagnostic markers, empty when the script is clean or analysis fails.</returns>
public DiagnoseResponse Diagnose(DiagnoseRequest req)
public DiagnoseResponse Diagnose(DiagnoseRequest req, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
try
{
ct.ThrowIfCancellationRequested();
var code = Normalize(req.Code);
var (tree, compilation, _, preambleLength) = Analyze(code);
var (tree, compilation, _, preambleLength) = Analyze(code, ct);
var markers = new List<DiagnosticMarker>();
// (1) Roslyn — surface ONLY compile errors, the same severity that actually blocks a
@@ -107,7 +139,7 @@ public sealed class ScriptAnalysisService
// makes GetMappedLineSpan report user-source coords (GetLineSpan would return the
// physical wrapper line and be wrong); the preambleLength guard also drops phantom
// wrapper diagnostics like CS0161 on Run() when the user hasn't typed `return` yet.
foreach (var d in compilation.GetDiagnostics())
foreach (var d in compilation.GetDiagnostics(ct))
{
if (d.Severity != DiagnosticSeverity.Error || !d.Location.IsInSource) continue;
if (d.Location.SourceSpan.Start < preambleLength) continue;
@@ -115,14 +147,24 @@ public sealed class ScriptAnalysisService
markers.Add(ToMarker(span, Sev(d.Severity), d.GetMessage(), d.Id));
}
// (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; map via #line.
ct.ThrowIfCancellationRequested();
foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation))
markers.Add(ToMarker(tree.GetMappedLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN"));
ct.ThrowIfCancellationRequested();
// (3) DependencyExtractor — spans are offsets in the normalized user source; map directly.
foreach (var r in DependencyExtractor.Extract(code).Rejections)
markers.Add(ToUserMarker(code, r.Span, r.Message, "OTSCRIPT_DYNPATH"));
// (4) ctx.SetVirtualTag(...) is inert at runtime — a WARNING (severity 4), never an error: publish
// accepts the script, so the editor must too; the marker just puts the hover's caveat in the Problems list.
foreach (var inv in SetVirtualTagCalls(tree.GetRoot(ct), preambleLength))
markers.Add(ToMarker(tree.GetMappedLineSpan(inv.Span), 4, SetVirtualTagInertMessage, "OTSCRIPT_SETVTAG_INERT"));
return new DiagnoseResponse(markers.Distinct().ToList());
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Script diagnostics failed; returning no markers.");
@@ -144,10 +186,11 @@ public sealed class ScriptAnalysisService
/// (<c>DraftValidator.ValidateEquipReferenceResolution</c>) rejects, so the editor accepts ⇔ publish
/// accepts. Inert (base markers only) on the shared ScriptEdit page where <c>EquipmentId</c> is null.</summary>
/// <param name="req">The diagnose request (its <c>EquipmentId</c> carries the owning-equipment context).</param>
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
/// <returns>The base diagnostics plus any unresolved-reference markers.</returns>
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req)
public async Task<DiagnoseResponse> DiagnoseAsync(DiagnoseRequest req, CancellationToken ct = default)
{
var baseResp = Diagnose(req);
var baseResp = Diagnose(req, ct);
if (_catalog is null || string.IsNullOrEmpty(req.EquipmentId) || string.IsNullOrEmpty(req.Code))
return baseResp;
try
@@ -156,7 +199,7 @@ public sealed class ScriptAnalysisService
if (!code.Contains(EquipmentScriptPaths.EquipTokenPrefix, StringComparison.Ordinal))
return baseResp;
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, CancellationToken.None);
var names = await _catalog.GetEquipmentReferenceNamesAsync(req.EquipmentId, null, ct);
var resolvable = new HashSet<string>(names, StringComparer.Ordinal);
var markers = new List<DiagnosticMarker>(baseResp.Markers);
@@ -175,6 +218,10 @@ public sealed class ScriptAnalysisService
}
return new DiagnoseResponse(markers.Distinct().ToList());
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Script equip-ref diagnostics failed; returning base markers.");
@@ -182,6 +229,23 @@ public sealed class ScriptAnalysisService
}
}
private const string SetVirtualTagInertMessage =
"ctx.SetVirtualTag(...) has no effect at runtime: cross-tag writes are dropped in single-tag mode (the " +
"cascade engine is not wired into the host). The script compiles and publishes, but this write will not " +
"take effect.";
// Every ctx.SetVirtualTag(...) invocation in the user source — the same `ctx`-anchored receiver guard as the
// tag-path completion (TryGetTagPathLiteral), so comments, string literals and other receivers are ignored.
private static IEnumerable<InvocationExpressionSyntax> SetVirtualTagCalls(SyntaxNode root, int preambleLength)
=> root.DescendantNodes()
.OfType<InvocationExpressionSyntax>()
.Where(inv => inv.SpanStart >= preambleLength
&& inv.Expression is MemberAccessExpressionSyntax
{
Expression: IdentifierNameSyntax { Identifier.ValueText: "ctx" },
Name.Identifier.ValueText: nameof(ScriptContext.SetVirtualTag),
});
private static int Sev(DiagnosticSeverity s) => s switch
{
DiagnosticSeverity.Error => 8,
@@ -210,16 +274,18 @@ public sealed class ScriptAnalysisService
}
/// <summary>Computes completion items (tag-path, dot-member, or in-scope symbol completions) at the caret position.</summary>
/// <param name="req">The completions request carrying the script source and caret position.</param>
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
/// <returns>The resolved list of completion items, empty when none apply.</returns>
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
public async Task<CompletionsResponse> CompleteAsync(CompletionsRequest req, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.Empty<CompletionItem>());
try
{
ct.ThrowIfCancellationRequested();
var code = Normalize(req.CodeText);
var (tree, _, model, preambleLength) = Analyze(code);
var (tree, _, model, preambleLength) = Analyze(code, ct);
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
var root = await tree.GetRootAsync();
var root = await tree.GetRootAsync(ct);
// position is the offset just AFTER the caret; -1 finds the token the caret sits at/just-after (e.g. the dot in "ctx.").
var token = root.FindToken(Math.Max(0, position - 1));
@@ -233,26 +299,31 @@ public sealed class ScriptAnalysisService
if (string.IsNullOrEmpty(req.EquipmentId))
return new CompletionsResponse(Array.Empty<CompletionItem>());
var names = await _catalog.GetEquipmentReferenceNamesAsync(
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), CancellationToken.None);
req.EquipmentId, pathPrefix.Substring(equipPrefix.Length), ct);
return new CompletionsResponse(names
.Select(n => new CompletionItem(equipPrefix + n, equipPrefix + n, "tag path", "Field"))
.ToList());
}
var paths = await _catalog.GetPathsAsync(pathPrefix, CancellationToken.None);
var paths = await _catalog.GetPathsAsync(pathPrefix, ct);
return new CompletionsResponse(
paths.Select(p => new CompletionItem(p, p, "tag path", "Field")).ToList());
}
var dot = TryGetDotMembers(token, model);
var dot = TryGetDotMembers(token, model, ct);
if (dot != null) return new CompletionsResponse(dot);
ct.ThrowIfCancellationRequested();
var scoped = model.LookupSymbols(position)
.Where(s => !s.IsImplicitlyDeclared && !string.IsNullOrEmpty(s.Name))
.GroupBy(s => s.Name).Select(g => g.First())
.Select(ToCompletionItem).Take(200).ToList();
return new CompletionsResponse(scoped);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger?.LogWarning(ex, "Script completion failed; returning none.");
@@ -260,12 +331,12 @@ public sealed class ScriptAnalysisService
}
}
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model)
private static List<CompletionItem>? TryGetDotMembers(SyntaxToken token, SemanticModel model, CancellationToken ct)
{
var memberAccess = token.Parent as MemberAccessExpressionSyntax
?? token.GetPreviousToken().Parent as MemberAccessExpressionSyntax;
if (memberAccess == null) return null;
var typeInfo = model.GetTypeInfo(memberAccess.Expression);
var typeInfo = model.GetTypeInfo(memberAccess.Expression, ct);
var type = typeInfo.Type ?? typeInfo.ConvertedType;
if (type == null) return null;
// The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose
@@ -322,16 +393,18 @@ public sealed class ScriptAnalysisService
}
/// <summary>Computes hover information (tag-path info or C# symbol info) for the position under the caret.</summary>
/// <param name="req">The hover request carrying the script source and caret position.</param>
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
/// <returns>The resolved hover markdown, or a response with a null value when nothing resolves.</returns>
public async Task<HoverResponse> Hover(HoverRequest req)
public async Task<HoverResponse> Hover(HoverRequest req, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(req.CodeText)) return new HoverResponse(null);
try
{
ct.ThrowIfCancellationRequested();
var code = Normalize(req.CodeText);
var (tree, _, model, preambleLength) = Analyze(code);
var (tree, _, model, preambleLength) = Analyze(code, ct);
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
var token = tree.GetRoot().FindToken(Math.Max(0, position - 1));
var token = tree.GetRoot(ct).FindToken(Math.Max(0, position - 1));
// Tag-path hover takes priority over C# symbol resolution: when the caret sits on a
// ctx.GetTag("…")/ctx.SetVirtualTag("…") path literal, show the resolved tag's info
@@ -350,7 +423,7 @@ public sealed class ScriptAnalysisService
"(by effective name) to the backing raw tag's path when the VirtualTag is deployed.");
}
var info = await _catalog.GetTagInfoAsync(tagPath, CancellationToken.None);
var info = await _catalog.GetTagInfoAsync(tagPath, ct);
var tagMd = info is null
? $"**Tag path** `{Code(tagPath)}`\n\n⚠ Not a known configured tag path."
: $"**Tag path** `{Code(info.Path)}`\n\n{info.Kind} · Type **{info.DataType}**"
@@ -368,21 +441,22 @@ public sealed class ScriptAnalysisService
// Resolve the token's node; if that yields nothing, climb to the enclosing
// member-access (e.g. hovering the `GetTag` identifier — the IdentifierName itself
// carries no symbol info, but its parent member-access binds to the method).
var symbol = ResolveSymbol(model, node);
var symbol = ResolveSymbol(model, node, ct);
if (symbol is null) return new HoverResponse(null);
var display = symbol.ToDisplayString(SymbolDisplayFormat.MinimallyQualifiedFormat);
var summary = TryGetXmlSummary(symbol);
var md = "```csharp\n" + display + "\n```" + (summary is null ? "" : "\n\n" + summary);
return new HoverResponse(md);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex) { _logger?.LogWarning(ex, "Script hover failed."); return new HoverResponse(null); }
}
private static ISymbol? ResolveSymbol(SemanticModel model, SyntaxNode node)
private static ISymbol? ResolveSymbol(SemanticModel model, SyntaxNode node, CancellationToken ct)
{
foreach (var candidate in ResolutionCandidates(node))
{
var info = model.GetSymbolInfo(candidate);
var info = model.GetSymbolInfo(candidate, ct);
var symbol = info.Symbol ?? info.CandidateSymbols.FirstOrDefault();
if (symbol is not null) return symbol;
}
@@ -420,24 +494,26 @@ public sealed class ScriptAnalysisService
/// <summary>Computes signature help for the method invocation at the caret position.</summary>
/// <param name="req">The signature-help request carrying the script source and caret position.</param>
/// <param name="ct">The request's cancellation token; a cancelled request throws <see cref="OperationCanceledException"/>.</param>
/// <returns>The resolved signature and active-parameter index, or an empty response when none applies.</returns>
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req)
public SignatureHelpResponse SignatureHelp(SignatureHelpRequest req, CancellationToken ct = default)
{
var empty = new SignatureHelpResponse(null, null, 0);
if (string.IsNullOrWhiteSpace(req.CodeText)) return empty;
try
{
ct.ThrowIfCancellationRequested();
var code = Normalize(req.CodeText);
var (tree, _, model, preambleLength) = Analyze(code);
var (tree, _, model, preambleLength) = Analyze(code, ct);
var position = OffsetInWrapped(code, req.Line, req.Column, preambleLength);
var token = tree.GetRoot().FindToken(Math.Max(0, position - 1));
var token = tree.GetRoot(ct).FindToken(Math.Max(0, position - 1));
InvocationExpressionSyntax? inv = null;
for (var n = token.Parent; n is not null; n = n.Parent)
if (n is InvocationExpressionSyntax found) { inv = found; break; }
if (inv is null) return empty;
var info = model.GetSymbolInfo(inv);
var info = model.GetSymbolInfo(inv, ct);
var method = (info.Symbol ?? info.CandidateSymbols.FirstOrDefault()) as IMethodSymbol;
if (method is null) return empty;
@@ -453,6 +529,7 @@ public sealed class ScriptAnalysisService
return new SignatureHelpResponse(label, ps, active);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex) { _logger?.LogWarning(ex, "Script signature help failed."); return empty; }
}
/// <summary>Formats a script's source code using Roslyn's syntax-tree whitespace normalization.</summary>