feat(adminui): script diagnostics (Roslyn + forbidden-type + dynamic-path)
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using Microsoft.CodeAnalysis;
|
||||
using Microsoft.CodeAnalysis.CSharp;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
|
||||
|
||||
@@ -27,6 +28,9 @@ public sealed class ScriptAnalysisService
|
||||
OutputKind.DynamicallyLinkedLibrary, optimizationLevel: OptimizationLevel.Release,
|
||||
allowUnsafe: false, warningLevel: 4, nullableContextOptions: NullableContextOptions.Enable);
|
||||
|
||||
private readonly ILogger<ScriptAnalysisService>? _logger;
|
||||
public ScriptAnalysisService(ILogger<ScriptAnalysisService>? logger = null) => _logger = logger;
|
||||
|
||||
// Mirrors ScriptEvaluator's wrapper EXACTLY (usings from the sandbox + the
|
||||
// Run(ScriptGlobals<VirtualTagContext>) shape), except the analysis return type is
|
||||
// `object` so a shared script stays valid regardless of any virtual tag's DataType.
|
||||
@@ -67,7 +71,76 @@ public sealed class ScriptAnalysisService
|
||||
return code.Length;
|
||||
}
|
||||
|
||||
public DiagnoseResponse Diagnose(DiagnoseRequest req) => new(Array.Empty<DiagnosticMarker>()); // Task 3
|
||||
private static string Normalize(string? s) => (s ?? "").Replace("\r\n", "\n").Replace("\r", "\n");
|
||||
|
||||
public DiagnoseResponse Diagnose(DiagnoseRequest req)
|
||||
{
|
||||
if (string.IsNullOrEmpty(req.Code)) return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
||||
try
|
||||
{
|
||||
var code = Normalize(req.Code);
|
||||
var (tree, compilation, _, preambleLength) = Analyze(code);
|
||||
var markers = new List<DiagnosticMarker>();
|
||||
|
||||
// (1) Roslyn — surface ONLY compile errors, the same severity that actually blocks a
|
||||
// publish in ScriptEvaluator.Compile (it filters GetDiagnostics() to
|
||||
// DiagnosticSeverity.Error). Mirroring that here keeps editor markers in lockstep with
|
||||
// what fails publish: a nullable warning like CS8605 on the canonical passthrough shape
|
||||
// `return ctx.GetTag("X").Value;` compiles + publishes fine under the evaluator's
|
||||
// nullable-enabled options, so flagging it in the editor would be misleading noise.
|
||||
// Restricted to diagnostics physically inside the user source — #line 1 in the preamble
|
||||
// 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())
|
||||
{
|
||||
if (d.Severity != DiagnosticSeverity.Error || !d.Location.IsInSource) continue;
|
||||
if (d.Location.SourceSpan.Start < preambleLength) continue;
|
||||
var span = d.Location.GetMappedLineSpan(); // honors #line 1 → user coords
|
||||
markers.Add(ToMarker(span, Sev(d.Severity), d.GetMessage(), d.Id));
|
||||
}
|
||||
// (2) ForbiddenTypeAnalyzer — spans are in the wrapped tree; map via #line.
|
||||
foreach (var r in ForbiddenTypeAnalyzer.Analyze(compilation))
|
||||
markers.Add(ToMarker(tree.GetMappedLineSpan(r.Span), 8, r.Message, "OTSCRIPT_FORBIDDEN"));
|
||||
// (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"));
|
||||
|
||||
return new DiagnoseResponse(markers.Distinct().ToList());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger?.LogWarning(ex, "Script diagnostics failed; returning no markers.");
|
||||
return new DiagnoseResponse(Array.Empty<DiagnosticMarker>());
|
||||
}
|
||||
}
|
||||
|
||||
private static int Sev(DiagnosticSeverity s) => s switch
|
||||
{
|
||||
DiagnosticSeverity.Error => 8,
|
||||
DiagnosticSeverity.Warning => 4,
|
||||
DiagnosticSeverity.Info => 2,
|
||||
_ => 1,
|
||||
};
|
||||
|
||||
private static DiagnosticMarker ToMarker(FileLinePositionSpan span, int sev, string msg, string code) => new(
|
||||
sev, span.StartLinePosition.Line + 1, span.StartLinePosition.Character + 1,
|
||||
span.EndLinePosition.Line + 1, span.EndLinePosition.Character + 1, msg, code);
|
||||
|
||||
private static DiagnosticMarker ToUserMarker(string userSource, Microsoft.CodeAnalysis.Text.TextSpan span, string msg, string code)
|
||||
{
|
||||
var (sl, sc) = LineCol(userSource, span.Start);
|
||||
var (el, ec) = LineCol(userSource, span.End);
|
||||
return new DiagnosticMarker(8, sl, sc, el, ec, msg, code);
|
||||
}
|
||||
|
||||
private static (int Line, int Col) LineCol(string code, int offset)
|
||||
{
|
||||
int line = 1, col = 1;
|
||||
for (int i = 0; i < offset && i < code.Length; i++)
|
||||
if (code[i] == '\n') { line++; col = 1; } else col++;
|
||||
return (line, col);
|
||||
}
|
||||
public Task<CompletionsResponse> CompleteAsync(CompletionsRequest req)
|
||||
=> Task.FromResult(new CompletionsResponse(Array.Empty<CompletionItem>())); // Tasks 4,6
|
||||
public HoverResponse Hover(HoverRequest req) => new((string?)null); // Task 7
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.AdminUI.ScriptAnalysis;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.ScriptAnalysis;
|
||||
|
||||
public sealed class DiagnoseTests
|
||||
{
|
||||
private static readonly ScriptAnalysisService Svc = new();
|
||||
private static IReadOnlyList<DiagnosticMarker> Diag(string code) => Svc.Diagnose(new DiagnoseRequest(code)).Markers;
|
||||
|
||||
[Fact] public void Clean_script_has_no_markers()
|
||||
=> Diag("""return (double)ctx.GetTag("A").Value * 2.0;""").ShouldBeEmpty();
|
||||
|
||||
[Fact] public void Roslyn_error_is_reported_on_user_line_1()
|
||||
{
|
||||
var m = Diag("return ctx.NoSuchMember();");
|
||||
m.ShouldNotBeEmpty();
|
||||
m.ShouldContain(x => x.Severity == 8);
|
||||
m.First(x => x.Severity == 8).StartLineNumber.ShouldBe(1); // #line 1 → user coords (GetMappedLineSpan)
|
||||
}
|
||||
|
||||
[Fact] public void Roslyn_error_maps_to_correct_user_line()
|
||||
{
|
||||
// error on the THIRD user line — proves mapping isn't off by the preamble height
|
||||
var m = Diag("var a = 1;\nvar b = 2;\nreturn ctx.Nope();");
|
||||
m.ShouldContain(x => x.Severity == 8 && x.StartLineNumber == 3);
|
||||
}
|
||||
|
||||
[Fact] public void Forbidden_or_unresolved_dangerous_type_is_flagged()
|
||||
=> Diag("""var c = new System.Net.Http.HttpClient(); return 0;""")
|
||||
.ShouldContain(x => x.Severity == 8);
|
||||
|
||||
[Fact] public void Dynamic_tag_path_is_flagged()
|
||||
=> Diag("""var p = "A"; return ctx.GetTag(p).Value;""")
|
||||
.ShouldContain(x => x.Message.Contains("literal"));
|
||||
|
||||
[Fact] public void Missing_return_does_not_produce_a_phantom_preamble_marker()
|
||||
=> Diag("var x = 1;").ShouldNotContain(m => m.Code == "CS0161");
|
||||
|
||||
[Fact] public void Empty_code_has_no_markers()
|
||||
=> Diag("").ShouldBeEmpty();
|
||||
|
||||
[Fact] public void Crlf_source_maps_dynamic_path_to_the_correct_line()
|
||||
{
|
||||
// \r\n line endings; the dynamic-path rejection must land on user line 3, not be column-shifted.
|
||||
var m = Diag("var a = 1;\r\nvar b = 2;\r\nreturn ctx.GetTag(b > 0 ? \"x\" : \"y\").Value;");
|
||||
m.ShouldContain(x => x.Message.Contains("literal") && x.StartLineNumber == 3);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user