diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs
index f4b652e2..18370bc3 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs
@@ -105,31 +105,20 @@ public sealed class ScriptTagCatalog(
///
public async Task> GetPathsAsync(string? filter, CancellationToken ct)
{
- var entries = await cache.GetOrBuildAsync(BuildEntriesAsync, ct);
-
- var paths = new HashSet(entries.Select(e => e.Path), StringComparer.OrdinalIgnoreCase);
-
- IEnumerable 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);
}
///
public async Task 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);
}
///
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs
index 6783f56f..ad598c1f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisEndpoints.cs
@@ -8,20 +8,38 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
public static class ScriptAnalysisEndpoints
{
+ /// 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.
+ private const int ClientClosedRequest = 499;
+
/// Maps the /api/script-analysis endpoint group (diagnostics, completions, hover, signature help, format), gated by the ConfigEditor policy (Administrator or Designer).
/// The route builder to map the endpoints onto.
/// The same route builder, for chaining.
+ /// Every analysis handler binds the request's (HttpContext.RequestAborted)
+ /// and passes it to the analysis, so a request the editor abandoned stops compiling instead of running to completion.
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 Run(Func> analyse, CancellationToken ct)
+ {
+ try
+ {
+ return Results.Ok(await analyse());
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ return Results.StatusCode(ClientClosedRequest);
+ }
+ }
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
index 23d610b7..9a98267d 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
@@ -18,10 +18,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
/// context.
///
///
-/// 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.
+/// All capabilities are fully implemented: Diagnose, Complete, Hover, SignatureHelp, and Format
+/// are wired and backed by the real Roslyn document/analysis pipeline.
+/// Cost model. Every analysis derives from one process-wide
+/// via , so the sandbox reference set is bound once rather
+/// than per keystroke, and every entry point takes the request's
+/// (HttpContext.RequestAborted) so an abandoned request stops instead of running to completion.
///
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);
+
+ ///
+ /// The process-wide compilation every analysis derives from. Its sandbox reference set is bound ONCE
+ /// (forced here by resolving System.Object); 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.
+ ///
+ 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);
+ }
+
+ /// Test seam: the compilation one analysis of runs against.
+ 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
/// Compiles the script and returns compile-error diagnostics plus sandbox-policy violations
/// (forbidden types, disallowed dynamic tag paths).
/// The diagnose request carrying the script source.
+ /// The request's cancellation token; a cancelled request throws .
/// The list of diagnostic markers, empty when the script is clean or analysis fails.
- public DiagnoseResponse Diagnose(DiagnoseRequest req)
+ public DiagnoseResponse Diagnose(DiagnoseRequest req, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty());
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();
// (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
/// (DraftValidator.ValidateEquipReferenceResolution) rejects, so the editor accepts ⇔ publish
/// accepts. Inert (base markers only) on the shared ScriptEdit page where EquipmentId is null.
/// The diagnose request (its EquipmentId carries the owning-equipment context).
+ /// The request's cancellation token; a cancelled request throws .
/// The base diagnostics plus any unresolved-reference markers.
- public async Task DiagnoseAsync(DiagnoseRequest req)
+ public async Task 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(names, StringComparer.Ordinal);
var markers = new List(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 SetVirtualTagCalls(SyntaxNode root, int preambleLength)
+ => root.DescendantNodes()
+ .OfType()
+ .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
}
/// Computes completion items (tag-path, dot-member, or in-scope symbol completions) at the caret position.
/// The completions request carrying the script source and caret position.
+ /// The request's cancellation token; a cancelled request throws .
/// The resolved list of completion items, empty when none apply.
- public async Task CompleteAsync(CompletionsRequest req)
+ public async Task CompleteAsync(CompletionsRequest req, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(req.CodeText)) return new CompletionsResponse(Array.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 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());
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? TryGetDotMembers(SyntaxToken token, SemanticModel model)
+ private static List? 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
}
/// Computes hover information (tag-path info or C# symbol info) for the position under the caret.
/// The hover request carrying the script source and caret position.
+ /// The request's cancellation token; a cancelled request throws .
/// The resolved hover markdown, or a response with a null value when nothing resolves.
- public async Task Hover(HoverRequest req)
+ public async Task 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
/// Computes signature help for the method invocation at the caret position.
/// The signature-help request carrying the script source and caret position.
+ /// The request's cancellation token; a cancelled request throws .
/// The resolved signature and active-parameter index, or an empty response when none applies.
- 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; }
}
/// Formats a script's source code using Roslyn's syntax-tree whitespace normalization.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogIndex.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogIndex.cs
new file mode 100644
index 00000000..2f4016ed
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogIndex.cs
@@ -0,0 +1,82 @@
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
+
+///
+/// The query-ready form of one snapshot: the raw entries, a sorted
+/// de-duplicated path array for prefix completion, and an Ordinal path→info dictionary for hover.
+///
+///
+/// Built ONCE per snapshot (at most every 5 s) instead of per keystroke. Completion used to build a
+/// 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 StartsWith, sorted ,
+/// 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.
+///
+public sealed class ScriptTagCatalogIndex
+{
+ private static readonly StringComparer SortComparer = StringComparer.OrdinalIgnoreCase;
+
+ private readonly Dictionary _byPath;
+ private readonly string[] _sortedPaths;
+
+ private ScriptTagCatalogIndex(IReadOnlyList entries, string[] sortedPaths, Dictionary byPath)
+ {
+ Entries = entries;
+ _sortedPaths = sortedPaths;
+ _byPath = byPath;
+ }
+
+ /// The projection the index was built from.
+ public IReadOnlyList Entries { get; }
+
+ /// Distinct (case-insensitively) paths, sorted .
+ public IReadOnlyList SortedPaths => _sortedPaths;
+
+ /// Builds the index over a catalog projection.
+ /// The catalog entries, in projection order (first occurrence wins on duplicates).
+ /// The built index.
+ public static ScriptTagCatalogIndex Build(IReadOnlyList 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(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(entries.Count, StringComparer.Ordinal);
+ foreach (var e in entries) byPath.TryAdd(e.Path, e);
+
+ return new ScriptTagCatalogIndex(entries, sorted.ToArray(), byPath);
+ }
+
+ /// Paths starting (case-insensitively) with , in sorted order.
+ /// The literal prefix; null/whitespace returns the first paths.
+ /// Upper bound on the returned count.
+ /// At most matching paths.
+ public IReadOnlyList 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(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;
+ }
+
+ /// Exact (Ordinal) lookup of a path.
+ /// The path to look up.
+ /// The first entry for that path, or .
+ public ScriptTagInfo? TryGet(string path) => _byPath.GetValueOrDefault(path);
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs
index c6597fae..1369c52e 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptTagCatalogSnapshotCache.cs
@@ -55,24 +55,37 @@ public sealed class ScriptTagCatalogSnapshotCache(TimeProvider timeProvider)
public async Task> GetOrBuildAsync(
Func>> build,
CancellationToken ct)
+ => (await GetOrBuildIndexAsync(build, ct)).Entries;
+
+ ///
+ /// As , but returns the snapshot's query-ready
+ /// (sorted paths + path dictionary), which is built once per rebuild
+ /// rather than once per completion keystroke.
+ ///
+ /// Builds a fresh projection; invoked at most once per TTL window.
+ /// Cancellation token.
+ /// The index over a fresh snapshot, or over one just built.
+ public async Task GetOrBuildIndexAsync(
+ Func>> 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 Entries, DateTimeOffset TakenAt);
+ private sealed record Snapshot(ScriptTagCatalogIndex Index, DateTimeOffset TakenAt);
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js
index e90c63bc..5900b6a2 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/wwwroot/js/monaco-init.js
@@ -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}}/
// 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: [{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCancellationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCancellationTests.cs
new file mode 100644
index 00000000..2253d8f6
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCancellationTests.cs
@@ -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;
+
+///
+/// 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 HttpContext.RequestAborted.
+///
+public sealed class AnalysisCancellationTests
+{
+ internal sealed class CapturingCatalog : IScriptTagCatalog
+ {
+ public readonly List Seen = new();
+
+ public Task> GetPathsAsync(string? filter, CancellationToken ct)
+ {
+ Seen.Add(ct);
+ return Task.FromResult>(new[] { "Plant/Dev/Speed" });
+ }
+
+ public Task GetTagInfoAsync(string path, CancellationToken ct)
+ {
+ Seen.Add(ct);
+ return Task.FromResult(new ScriptTagInfo(path, "Tag", "Float", "DRV-1"));
+ }
+
+ public Task> GetEquipmentReferenceNamesAsync(string equipmentId, string? filter, CancellationToken ct)
+ {
+ Seen.Add(ct);
+ return Task.FromResult>(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(
+ 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(svc.CompleteAsync(new CompletionsRequest(code, 1, 20), Cancelled()));
+ await Should.ThrowAsync(
+ svc.Hover(new HoverRequest("return ctx.GetTag(\"Plant/Dev/Speed\");", 1, 22), Cancelled()));
+ Should.Throw(() => 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(catalog);
+ builder.Services.AddScoped(sp => new ScriptAnalysisService(sp.GetRequiredService()));
+ builder.Services.AddAuthorization();
+ var app = builder.Build();
+ app.MapScriptAnalysisEndpoints();
+ return (app, catalog);
+ }
+
+ internal static IReadOnlyList Endpoints(WebApplication app)
+ => ((IEndpointRouteBuilder)app).DataSources.SelectMany(d => d.Endpoints).OfType().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(new HasBody());
+
+ await endpoint.RequestDelegate!(ctx);
+
+ ctx.Response.StatusCode.ShouldBe(200);
+ catalog.Seen.ShouldHaveSingleItem().ShouldBe(aborted.Token);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCompilationReuseTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCompilationReuseTests.cs
new file mode 100644
index 00000000..d6eaca6a
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/AnalysisCompilationReuseTests.cs
@@ -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;
+
+///
+/// R04:P2 — the editor used to build a brand-new CSharpCompilation (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 ReplaceSyntaxTree, so the referenced-assembly
+/// binding is done once and shared.
+///
+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);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogIndexTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogIndexTests.cs
new file mode 100644
index 00000000..84ec3070
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/ScriptTagCatalogIndexTests.cs
@@ -0,0 +1,79 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
+
+namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
+
+///
+/// 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 string[] (prefix-searched
+/// with a binary search) and an Ordinal path→info dictionary, built once per snapshot.
+///
+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 Naive(IReadOnlyList entries, string? filter)
+ {
+ IEnumerable q = new HashSet(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 { 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);
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/SetVirtualTagInertDiagnosticTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/SetVirtualTagInertDiagnosticTests.cs
new file mode 100644
index 00000000..69cd2b57
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/SetVirtualTagInertDiagnosticTests.cs
@@ -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;
+
+///
+/// R04:S1 — ctx.SetVirtualTag(...) compiles and publishes but is dropped at runtime (single-tag mode).
+/// Only a hover used to say so; Diagnose now emits an OTSCRIPT_SETVTAG_INERT WARNING marker so the
+/// Problems list shows it. A warning, never an error: publish accepts the script, so the editor must too.
+///
+public sealed class SetVirtualTagInertDiagnosticTests
+{
+ private const string InertCode = "OTSCRIPT_SETVTAG_INERT";
+ private static readonly ScriptAnalysisService Svc = new();
+ private static IReadOnlyList 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.Compile(source);
+ evaluator.ShouldNotBeNull();
+ }
+}