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
@@ -105,31 +105,20 @@ public sealed class ScriptTagCatalog(
/// <inheritdoc />
public async Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
{
var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct);
var paths = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
IEnumerable<string> query = paths;
if (!string.IsNullOrWhiteSpace(filter))
{
query = query.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
}
return query
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.Take(MaxResults)
.ToList();
// The sorted, de-duplicated path array is built once per snapshot; a keystroke is a binary search.
var index = await cache.GetOrBuildIndexAsync(BuildEntriesAsync, ct);
return index.FindByPrefix(filter, MaxResults);
}
/// <inheritdoc />
public async Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
{
var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct);
var index = await cache.GetOrBuildIndexAsync(BuildEntriesAsync, ct);
// Exact, case-SENSITIVE (Ordinal) match: the live runtime resolves a ctx.GetTag("X") literal
// via DependencyMuxActor keyed with StringComparer.Ordinal, so a case-mismatched path would
// not resolve at runtime — hover must report it as "not a known configured tag path".
return entries.FirstOrDefault(e => string.Equals(e.Path, path, StringComparison.Ordinal));
return index.TryGet(path);
}
/// <inheritdoc />
@@ -8,20 +8,38 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public static class ScriptAnalysisEndpoints
{
/// <summary>Status returned when the editor abandoned the request (Monaco cancelled it or the fetch was
/// aborted). Nobody reads the body; it only keeps a routine cancellation out of the error logs.</summary>
private const int ClientClosedRequest = 499;
/// <summary>Maps the <c>/api/script-analysis</c> endpoint group (diagnostics, completions, hover, signature help, format), gated by the ConfigEditor policy (Administrator or Designer).</summary>
/// <param name="endpoints">The route builder to map the endpoints onto.</param>
/// <returns>The same route builder, for chaining.</returns>
/// <remarks>Every analysis handler binds the request's <see cref="CancellationToken"/> (<c>HttpContext.RequestAborted</c>)
/// and passes it to the analysis, so a request the editor abandoned stops compiling instead of running to completion.</remarks>
public static IEndpointRouteBuilder MapScriptAnalysisEndpoints(this IEndpointRouteBuilder endpoints)
{
// ConfigEditor policy (Administrator or Designer) — matches the Script page gate and the /deployments gate.
var group = endpoints.MapGroup("/api/script-analysis")
.RequireAuthorization(AdminUiPolicies.ConfigEditor);
group.MapPost("/diagnostics", async (DiagnoseRequest r, ScriptAnalysisService s) => Results.Ok(await s.DiagnoseAsync(r)));
group.MapPost("/completions", async (CompletionsRequest r, ScriptAnalysisService s) => Results.Ok(await s.CompleteAsync(r)));
group.MapPost("/hover", async (HoverRequest r, ScriptAnalysisService s) => Results.Ok(await s.Hover(r)));
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s) => Results.Ok(s.SignatureHelp(r)));
group.MapPost("/diagnostics", (DiagnoseRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.DiagnoseAsync(r, ct), ct));
group.MapPost("/completions", (CompletionsRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.CompleteAsync(r, ct), ct));
group.MapPost("/hover", (HoverRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => s.Hover(r, ct), ct));
group.MapPost("/signature-help", (SignatureHelpRequest r, ScriptAnalysisService s, CancellationToken ct) => Run(() => Task.FromResult(s.SignatureHelp(r, ct)), ct));
group.MapPost("/format", (FormatRequest r, ScriptAnalysisService s) => Results.Ok(s.Format(r)));
group.MapPost("/inlay-hints", (InlayHintsRequest r, ScriptAnalysisService s) => Results.Ok(s.InlayHints(r)));
return endpoints;
}
private static async Task<IResult> Run<T>(Func<Task<T>> analyse, CancellationToken ct)
{
try
{
return Results.Ok(await analyse());
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
return Results.StatusCode(ClientClosedRequest);
}
}
}
@@ -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>
@@ -0,0 +1,82 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// <summary>
/// The query-ready form of one <see cref="ScriptTagCatalogSnapshotCache"/> snapshot: the raw entries, a sorted
/// de-duplicated path array for prefix completion, and an Ordinal path→info dictionary for hover.
/// </summary>
/// <remarks>
/// Built ONCE per snapshot (at most every 5 s) instead of per keystroke. Completion used to build a
/// <see cref="HashSet{T}"/> of every fleet path and sort it on each request, and hover scanned the entries
/// linearly; on a 50k-tag fleet that was tens of MB of garbage per typed character. The semantics are
/// unchanged: completion is case-insensitive <c>StartsWith</c>, sorted <see cref="StringComparer.OrdinalIgnoreCase"/>,
/// with case-only duplicates collapsed to the first-seen casing; hover is an exact Ordinal match (the runtime
/// dependency mux is Ordinal) returning the first entry for a path.
/// </remarks>
public sealed class ScriptTagCatalogIndex
{
private static readonly StringComparer SortComparer = StringComparer.OrdinalIgnoreCase;
private readonly Dictionary<string, ScriptTagInfo> _byPath;
private readonly string[] _sortedPaths;
private ScriptTagCatalogIndex(IReadOnlyList<ScriptTagInfo> entries, string[] sortedPaths, Dictionary<string, ScriptTagInfo> byPath)
{
Entries = entries;
_sortedPaths = sortedPaths;
_byPath = byPath;
}
/// <summary>The projection the index was built from.</summary>
public IReadOnlyList<ScriptTagInfo> Entries { get; }
/// <summary>Distinct (case-insensitively) paths, sorted <see cref="StringComparer.OrdinalIgnoreCase"/>.</summary>
public IReadOnlyList<string> SortedPaths => _sortedPaths;
/// <summary>Builds the index over a catalog projection.</summary>
/// <param name="entries">The catalog entries, in projection order (first occurrence wins on duplicates).</param>
/// <returns>The built index.</returns>
public static ScriptTagCatalogIndex Build(IReadOnlyList<ScriptTagInfo> entries)
{
// A stable sort keeps projection order among case-only duplicates, so keeping the first of each run
// reproduces the first-added casing a HashSet(OrdinalIgnoreCase) kept.
var sorted = new List<string>(entries.Count);
foreach (var path in entries.Select(e => e.Path).OrderBy(p => p, SortComparer))
{
if (sorted.Count > 0 && SortComparer.Equals(sorted[^1], path)) continue;
sorted.Add(path);
}
var byPath = new Dictionary<string, ScriptTagInfo>(entries.Count, StringComparer.Ordinal);
foreach (var e in entries) byPath.TryAdd(e.Path, e);
return new ScriptTagCatalogIndex(entries, sorted.ToArray(), byPath);
}
/// <summary>Paths starting (case-insensitively) with <paramref name="prefix"/>, in sorted order.</summary>
/// <param name="prefix">The literal prefix; null/whitespace returns the first <paramref name="max"/> paths.</param>
/// <param name="max">Upper bound on the returned count.</param>
/// <returns>At most <paramref name="max"/> matching paths.</returns>
public IReadOnlyList<string> FindByPrefix(string? prefix, int max)
{
var paths = _sortedPaths;
if (string.IsNullOrWhiteSpace(prefix)) return paths.Take(max).ToArray();
// Under an ordinal-style (char-by-char) comparer every string with prefix P sorts at or after P and the
// matches form one contiguous run, so a lower-bound binary search plus a forward scan is exact.
var start = Array.BinarySearch(paths, prefix, SortComparer);
if (start < 0) start = ~start;
var result = new List<string>(Math.Min(max, 32));
for (var i = start; i < paths.Length && result.Count < max; i++)
{
if (!paths[i].StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) break;
result.Add(paths[i]);
}
return result;
}
/// <summary>Exact (Ordinal) lookup of a path.</summary>
/// <param name="path">The path to look up.</param>
/// <returns>The first entry for that path, or <see langword="null"/>.</returns>
public ScriptTagInfo? TryGet(string path) => _byPath.GetValueOrDefault(path);
}
@@ -55,24 +55,37 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider)
public async Task<IReadOnlyList<ScriptTagInfo>> GetOrBuildAsync(
Func<CancellationToken, Task<IReadOnlyList<ScriptTagInfo>>> build,
CancellationToken ct)
=> (await GetOrBuildIndexAsync(build, ct)).Entries;
/// <summary>
/// As <see cref="GetOrBuildAsync"/>, but returns the snapshot's query-ready
/// <see cref="ScriptTagCatalogIndex"/> (sorted paths + path dictionary), which is built once per rebuild
/// rather than once per completion keystroke.
/// </summary>
/// <param name="build">Builds a fresh projection; invoked at most once per TTL window.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The index over a fresh snapshot, or over one just built.</returns>
public async Task<ScriptTagCatalogIndex> GetOrBuildIndexAsync(
Func<CancellationToken, Task<IReadOnlyList<ScriptTagInfo>>> build,
CancellationToken ct)
{
var current = _snapshot;
if (current is not null && !IsStale(current)) return current.Entries;
if (current is not null && !IsStale(current)) return current.Index;
await _rebuildGate.WaitAsync(ct);
try
{
// Double-check: while we queued, the caller ahead of us may have rebuilt already.
current = _snapshot;
if (current is not null && !IsStale(current)) return current.Entries;
if (current is not null && !IsStale(current)) return current.Index;
// A throw here leaves _snapshot untouched (the previous one, if any, keeps serving) and
// propagates to this caller only; the finally still releases the gate.
var entries = await build(ct);
var index = ScriptTagCatalogIndex.Build(await build(ct));
// Stamped AFTER the build so a slow scan does not hand back an already-expired snapshot.
_snapshot = new Snapshot(entries, timeProvider.GetUtcNow());
return entries;
_snapshot = new Snapshot(index, timeProvider.GetUtcNow());
return index;
}
finally
{
@@ -82,5 +95,5 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider)
private bool IsStale(Snapshot snapshot) => timeProvider.GetUtcNow() - snapshot.TakenAt >= Ttl;
private sealed record Snapshot(IReadOnlyList<ScriptTagInfo> Entries, DateTimeOffset TakenAt);
private sealed record Snapshot(ScriptTagCatalogIndex Index, DateTimeOffset TakenAt);
}
@@ -23,6 +23,30 @@
const KIND_MAP = { Method: 0, Field: 3, Property: 9, Event: 10, Class: 5, Module: 8, Variable: 4, Text: 18 };
// POSTs to a script-analysis endpoint, honouring Monaco's CancellationToken: when Monaco abandons a request
// (the caret moved, the user kept typing) the fetch is aborted, which cancels HttpContext.RequestAborted
// server-side so the Roslyn analysis stops instead of running to completion. Resolves to null when the
// request was cancelled or failed, so a provider never applies a superseded answer.
async function postAnalysis(path, body, token) {
if (token && token.isCancellationRequested) return null;
const ac = new AbortController();
const sub = token && token.onCancellationRequested ? token.onCancellationRequested(function () { ac.abort(); }) : null;
try {
const resp = await fetch("/api/script-analysis/" + path, {
method: "POST", credentials: "same-origin", signal: ac.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
if (!resp.ok) return null;
const data = await resp.json();
return (token && token.isCancellationRequested) ? null : data;
} catch (e) {
return null;
} finally {
if (sub && sub.dispose) sub.dispose();
}
}
// The owning-equipment id is stamped onto each editor's model at createEditor time so the globally
// registered csharp providers (which only receive the model) can thread it into the {{equip}}/<RefName>
// completion + diagnostics. Null on the shared ScriptEdit page (equip-ref features inert there).
@@ -31,15 +55,12 @@
function registerCSharpProviders() {
monaco.languages.registerCompletionItemProvider("csharp", {
triggerCharacters: [".", "(", "\""],
provideCompletionItems: async function (model, position) {
provideCompletionItems: async function (model, position, context, token) {
try {
const resp = await fetch("/api/script-analysis/completions", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
});
if (!resp.ok) return { suggestions: [] };
const data = await resp.json();
const data = await postAnalysis("completions",
{ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) },
token);
if (!data) return { suggestions: [] };
const word = model.getWordUntilPosition(position);
const wordRange = {
startLineNumber: position.lineNumber, endLineNumber: position.lineNumber,
@@ -72,31 +93,21 @@
}
});
monaco.languages.registerHoverProvider("csharp", {
provideHover: async function (model, position) {
provideHover: async function (model, position, token) {
try {
const resp = await fetch("/api/script-analysis/hover", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) })
});
if (!resp.ok) return null;
const data = await resp.json();
if (!data.markdown) return null;
const data = await postAnalysis("hover",
{ codeText: model.getValue(), line: position.lineNumber, column: position.column, equipmentId: equipmentIdOf(model) },
token);
if (!data || !data.markdown) return null;
return { contents: [{ value: data.markdown }] };
} catch (e) { return null; }
}
});
monaco.languages.registerDocumentFormattingEditProvider("csharp", {
provideDocumentFormattingEdits: async function (model) {
provideDocumentFormattingEdits: async function (model, options, token) {
try {
const resp = await fetch("/api/script-analysis/format", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code: model.getValue() })
});
if (!resp.ok) return [];
const data = await resp.json();
if (typeof data.code !== "string" || data.code === model.getValue()) return [];
const data = await postAnalysis("format", { code: model.getValue() }, token);
if (!data || typeof data.code !== "string" || data.code === model.getValue()) return [];
return [{ range: model.getFullModelRange(), text: data.code }];
} catch (e) { return []; }
}
@@ -123,16 +134,12 @@
monaco.languages.registerSignatureHelpProvider("csharp", {
signatureHelpTriggerCharacters: ["(", ","],
signatureHelpRetriggerCharacters: [","],
provideSignatureHelp: async function (model, position) {
provideSignatureHelp: async function (model, position, token) {
try {
const resp = await fetch("/api/script-analysis/signature-help", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ codeText: model.getValue(), line: position.lineNumber, column: position.column })
});
if (!resp.ok) return null;
const data = await resp.json();
if (!data.label) return null;
const data = await postAnalysis("signature-help",
{ codeText: model.getValue(), line: position.lineNumber, column: position.column },
token);
if (!data || !data.label) return null;
return {
value: {
signatures: [{
@@ -0,0 +1,134 @@
using System.Text;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:P2 — an abandoned editor request (Monaco cancelled it, or the browser aborted the fetch) used to run
/// its full compile to completion. The request's token now reaches the service, the Roslyn calls and the
/// catalog, and the endpoints bind <c>HttpContext.RequestAborted</c>.
/// </summary>
public sealed class AnalysisCancellationTests
{
internal sealed class CapturingCatalog : IScriptTagCatalog
{
public readonly List<CancellationToken> Seen = new();
public Task<IReadOnlyList<string>> GetPathsAsync(string? filter, CancellationToken ct)
{
Seen.Add(ct);
return Task.FromResult<IReadOnlyList<string>>(new[] { "Plant/Dev/Speed" });
}
public Task<ScriptTagInfo?> GetTagInfoAsync(string path, CancellationToken ct)
{
Seen.Add(ct);
return Task.FromResult<ScriptTagInfo?>(new ScriptTagInfo(path, "Tag", "Float", "DRV-1"));
}
public Task<IReadOnlyList<string>> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
{
Seen.Add(ct);
return Task.FromResult<IReadOnlyList<string>>(new[] { "Speed" });
}
}
private static CancellationToken Cancelled() => new(canceled: true);
[Fact]
public async Task Diagnose_with_a_cancelled_token_throws_instead_of_returning_empty_markers()
{
var svc = new ScriptAnalysisService();
// Positive control: the same request with a live token produces the error marker.
(await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), CancellationToken.None))
.Markers.ShouldContain(m => m.Severity == 8);
await Should.ThrowAsync<OperationCanceledException>(
svc.DiagnoseAsync(new DiagnoseRequest("return ctx.Nope();"), Cancelled()));
}
[Fact]
public async Task Completions_hover_and_signature_help_honour_a_cancelled_token()
{
var svc = new ScriptAnalysisService(new CapturingCatalog());
const string code = "return ctx.GetTag(\"\");";
// Positive controls.
(await svc.CompleteAsync(new CompletionsRequest(code, 1, 20), CancellationToken.None)).Items.ShouldNotBeEmpty();
(await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), CancellationToken.None))
.Markdown.ShouldNotBeNull();
svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), CancellationToken.None).Label.ShouldNotBeNull();
await Should.ThrowAsync<OperationCanceledException>(svc.CompleteAsync(new CompletionsRequest(code, 1, 20), Cancelled()));
await Should.ThrowAsync<OperationCanceledException>(
svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), Cancelled()));
Should.Throw<OperationCanceledException>(() => svc.SignatureHelp(new SignatureHelpRequest("ctx.GetTag(", 1, 12), Cancelled()));
}
[Fact]
public async Task The_request_token_reaches_the_catalog()
{
var catalog = new CapturingCatalog();
var svc = new ScriptAnalysisService(catalog);
using var cts = new CancellationTokenSource();
await svc.CompleteAsync(new CompletionsRequest("return ctx.GetTag(\"\");", 1, 20), cts.Token);
await svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), cts.Token);
await svc.DiagnoseAsync(new DiagnoseRequest("return ctx.GetTag(\"{{equip}}/Nope\").Value;", "EQ-1"), cts.Token);
catalog.Seen.Count.ShouldBe(3);
catalog.Seen.ShouldAllBe(t => t == cts.Token);
}
// ---------------------------------------------------------------- endpoints
// A bare DefaultHttpContext reports "no body"; Kestrel sets this feature for a real POST.
private sealed class HasBody : Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature
{
public bool CanHaveBody => true;
}
internal static (WebApplication App, CapturingCatalog Catalog) BuildApp()
{
var builder = WebApplication.CreateBuilder();
var catalog = new CapturingCatalog();
builder.Services.AddSingleton<IScriptTagCatalog>(catalog);
builder.Services.AddScoped(sp => new ScriptAnalysisService(sp.GetRequiredService<IScriptTagCatalog>()));
builder.Services.AddAuthorization();
var app = builder.Build();
app.MapScriptAnalysisEndpoints();
return (app, catalog);
}
internal static IReadOnlyList<RouteEndpoint> Endpoints(WebApplication app)
=> ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType<RouteEndpoint>().ToList();
[Fact]
public async Task The_endpoint_binds_RequestAborted_through_to_the_catalog()
{
var (app, catalog) = BuildApp();
var endpoint = Endpoints(app).Single(e => e.RoutePattern.RawText!.EndsWith("/completions"));
using var scope = app.Services.CreateScope();
using var aborted = new CancellationTokenSource();
var ctx = new DefaultHttpContext { RequestServices = scope.ServiceProvider, RequestAborted = aborted.Token };
ctx.Request.Method = "POST";
ctx.Request.ContentType = "application/json";
var body = Encoding.UTF8.GetBytes("""{"codeText":"return ctx.GetTag(\"\");","line":1,"column":20}""");
ctx.Request.Body = new MemoryStream(body);
ctx.Request.ContentLength = body.Length;
ctx.Response.Body = new MemoryStream();
ctx.Features.Set<Microsoft.AspNetCore.Http.Features.IHttpRequestBodyDetectionFeature>(new HasBody());
await endpoint.RequestDelegate!(ctx);
ctx.Response.StatusCode.ShouldBe(200);
catalog.Seen.ShouldHaveSingleItem().ShouldBe(aborted.Token);
}
}
@@ -0,0 +1,58 @@
using Microsoft.CodeAnalysis;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:P2 — the editor used to build a brand-new <c>CSharpCompilation</c> (and re-bind the whole sandbox
/// reference set) on every completion / hover / signature-help / diagnostics request. Each analysis now
/// derives from ONE process-wide base compilation via <c>ReplaceSyntaxTree</c>, so the referenced-assembly
/// binding is done once and shared.
/// </summary>
public sealed class AnalysisCompilationReuseTests
{
[Fact]
public void Two_services_share_the_bound_reference_set()
{
// Two separate scopes (two HTTP requests) analysing different sources.
var c1 = new ScriptAnalysisService().CompilationFor("return 1;");
var c2 = new ScriptAnalysisService().CompilationFor("return 2;");
// Positive control: the two compilations really carry different user sources.
c1.SyntaxTrees.Single().ToString().ShouldContain("return 1;");
c2.SyntaxTrees.Single().ToString().ShouldContain("return 2;");
c1.ShouldNotBeSameAs(c2);
// Derived from one bound base: the corlib symbol is the SAME instance, not a re-bound copy.
c1.GetSpecialType(SpecialType.System_Object)
.ShouldBeSameAs(c2.GetSpecialType(SpecialType.System_Object));
c1.GetSpecialType(SpecialType.System_Object)
.ShouldBeSameAs(ScriptAnalysisService.BaseCompilation.GetSpecialType(SpecialType.System_Object));
}
[Fact]
public void Repeated_analysis_in_one_scope_replaces_the_tree_rather_than_adding_one()
{
var svc = new ScriptAnalysisService();
svc.CompilationFor("return 1;");
var second = svc.CompilationFor("return 2;");
second.SyntaxTrees.Count().ShouldBe(1);
second.SyntaxTrees.Single().ToString().ShouldContain("return 2;");
second.SyntaxTrees.Single().ToString().ShouldNotContain("return 1;");
}
[Fact]
public void Diagnostics_are_unchanged_by_reuse()
{
// A clean script stays clean and an error stays an error across interleaved analyses on one service
// — a stale tree leaking from the previous call would surface here.
var svc = new ScriptAnalysisService();
svc.Diagnose(new DiagnoseRequest("return ctx.Nope();")).Markers.ShouldContain(m => m.Severity == 8);
svc.Diagnose(new DiagnoseRequest("""return (double)ctx.GetTag("A").Value * 2.0;""")).Markers.ShouldBeEmpty();
svc.Diagnose(new DiagnoseRequest("var a = 1;\nreturn ctx.Nope();")).Markers
.ShouldContain(m => m.Severity == 8 && m.StartLineNumber == 2);
}
}
@@ -0,0 +1,79 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:P3 — the catalog used to build a HashSet of every fleet path and sort it on every completion keystroke,
/// and hover did a linear scan. The snapshot now carries a sorted, de-duplicated <c>string[]</c> (prefix-searched
/// with a binary search) and an Ordinal path→info dictionary, built once per snapshot.
/// </summary>
public sealed class ScriptTagCatalogIndexTests
{
private const int Max = 200;
// The previous per-keystroke implementation, kept here as the oracle the index must match exactly.
private static IReadOnlyList<string> Naive(IReadOnlyList<ScriptTagInfo> entries, string? filter)
{
IEnumerable<string> q = new HashSet<string>(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
if (!string.IsNullOrWhiteSpace(filter))
q = q.Where(p => p.StartsWith(filter, StringComparison.OrdinalIgnoreCase));
return q.OrderBy(p => p, StringComparer.OrdinalIgnoreCase).Take(Max).ToList();
}
private static ScriptTagInfo Tag(string path) => new(path, "Tag", "Float", "DRV-1");
[Fact]
public void Prefix_search_is_case_insensitive_sorted_and_bounded()
{
var index = ScriptTagCatalogIndex.Build(new[]
{
Tag("Plant/b/Speed"), Tag("plant/A/Temp"), Tag("Other/X"), Tag("Plant/a/Flow"),
});
index.FindByPrefix("PLANT/", Max).ShouldBe(new[] { "Plant/a/Flow", "plant/A/Temp", "Plant/b/Speed" });
index.FindByPrefix("other", Max).ShouldBe(new[] { "Other/X" });
index.FindByPrefix("zzz", Max).ShouldBeEmpty();
index.FindByPrefix(null, 2).Count.ShouldBe(2);
index.FindByPrefix(" ", Max).Count.ShouldBe(4); // whitespace filter = unfiltered, as before
}
[Fact]
public void Case_only_duplicates_collapse_to_the_first_seen_casing()
{
// HashSet(OrdinalIgnoreCase) kept the FIRST-added casing; the index must too.
var index = ScriptTagCatalogIndex.Build(new[] { Tag("A/speed"), Tag("A/Speed"), Tag("A/SPEED") });
index.SortedPaths.ShouldBe(new[] { "A/speed" });
}
[Fact]
public void Exact_lookup_is_ordinal_and_keeps_the_first_entry()
{
var first = new ScriptTagInfo("A/B", "Tag", "Float", "DRV-1");
var index = ScriptTagCatalogIndex.Build(new[] { first, new ScriptTagInfo("A/B", "Virtual tag", "Int32", null) });
index.TryGet("A/B").ShouldBeSameAs(first);
index.TryGet("a/b").ShouldBeNull(); // case-mismatched path does not resolve at runtime
index.TryGet("A/C").ShouldBeNull();
}
[Fact]
public void Matches_the_previous_implementation_on_a_generated_fleet()
{
// Deterministic seed — a mixed-case fleet with case-only duplicates and shared prefixes.
var rng = new Random(745);
string Seg() => new string(Enumerable.Range(0, rng.Next(1, 4)).Select(_ => "abAB_/1"[rng.Next(7)]).ToArray());
var entries = Enumerable.Range(0, 3000).Select(_ => Tag(Seg() + "/" + Seg() + "/" + Seg())).ToList();
var index = ScriptTagCatalogIndex.Build(entries);
var filters = new List<string?> { null, "", "a", "A", "ab", "aB/", "b/", "_", "1", "/", "zz" };
filters.AddRange(entries.Take(50).Select(e => e.Path[..Math.Min(e.Path.Length, rng.Next(1, 6))]));
foreach (var f in filters)
index.FindByPrefix(f, Max).ShouldBe(Naive(entries, f), $"filter '{f}'");
// Positive control: the generated fleet really exercises truncation and filtering.
Naive(entries, null).Count.ShouldBe(Max);
Naive(entries, "ab").Count.ShouldBeGreaterThan(0);
}
}
@@ -0,0 +1,58 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
/// <summary>
/// R04:S1 — <c>ctx.SetVirtualTag(...)</c> compiles and publishes but is dropped at runtime (single-tag mode).
/// Only a hover used to say so; <c>Diagnose</c> now emits an <c>OTSCRIPT_SETVTAG_INERT</c> WARNING marker so the
/// Problems list shows it. A warning, never an error: publish accepts the script, so the editor must too.
/// </summary>
public sealed class SetVirtualTagInertDiagnosticTests
{
private const string InertCode = "OTSCRIPT_SETVTAG_INERT";
private static readonly ScriptAnalysisService Svc = new();
private static IReadOnlyList<DiagnosticMarker> Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers;
[Fact]
public void SetVirtualTag_call_gets_a_warning_marker_on_its_line()
{
var markers = Diag("var a = 1;\nvar b = 2;\nctx.SetVirtualTag(\"Other\", a + b);\nreturn 0;");
var m = markers.ShouldHaveSingleItem();
m.Code.ShouldBe(InertCode);
m.Severity.ShouldBe(4); // Monaco warning
m.StartLineNumber.ShouldBe(3);
m.StartColumn.ShouldBe(1);
m.Message.ShouldContain("SetVirtualTag");
}
[Fact]
public void Every_SetVirtualTag_call_is_marked()
=> Diag("ctx.SetVirtualTag(\"A\", 1);\nctx.SetVirtualTag(\"B\", 2);\nreturn 0;")
.Count(m => m.Code == InertCode).ShouldBe(2);
[Theory]
[InlineData("return ctx.GetTag(\"A\").Value;")] // a different ctx member
[InlineData("return 0; // ctx.SetVirtualTag(\"A\", 1)")] // a comment
[InlineData("var s = \"ctx.SetVirtualTag(\\\"A\\\", 1)\"; return s;")] // a string literal
public void Non_calls_are_not_marked(string code)
{
var markers = Diag(code);
markers.ShouldNotContain(m => m.Code == InertCode);
markers.ShouldNotContain(m => m.Severity == 8); // the probe scripts themselves are valid
}
[Fact]
public void The_warning_does_not_block_and_publish_accepts_the_script()
{
// Editor accepts ⇔ publish accepts: no error-severity marker, and the real publish compiler accepts it.
const string source = "ctx.SetVirtualTag(\"Other\", 1);\nreturn 0;";
Diag(source).ShouldNotContain(m => m.Severity == 8);
using var evaluator = ScriptEvaluator<VirtualTagContext, object>.Compile(source);
evaluator.ShouldNotBeNull();
}
}