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 b55ff712..15da96f1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -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? _logger; + public ScriptAnalysisService(ILogger? logger = null) => _logger = logger; + // Mirrors ScriptEvaluator's wrapper EXACTLY (usings from the sandbox + the // Run(ScriptGlobals) 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()); // 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()); + try + { + var code = Normalize(req.Code); + var (tree, compilation, _, preambleLength) = Analyze(code); + var markers = new List(); + + // (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()); + } + } + + 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 CompleteAsync(CompletionsRequest req) => Task.FromResult(new CompletionsResponse(Array.Empty())); // Tasks 4,6 public HoverResponse Hover(HoverRequest req) => new((string?)null); // Task 7 diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs new file mode 100644 index 00000000..21460637 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs @@ -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 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); + } +}