diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs index 48ee0706..8b8db824 100644 --- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs +++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ValidationService.cs @@ -530,19 +530,27 @@ public class ValidationService /// A human-readable error message if the expression is invalid; null if well-formed. internal static string? CheckExpressionSyntax(string expression) { - // Authoritative forbidden-API verdict first. Report ALL violations, not just - // the first, so an operator fixing one forbidden API isn't surprised by a - // second on the next deploy attempt (mirrors ScriptCompiler.TryCompile). - var violations = ScriptTrustValidator.FindViolations(expression); - if (violations.Count > 0) - return $"uses forbidden API: {string.Join("; ", violations)}"; + // Memoise the verdict under the TRIGGER surface key (see ScriptCompileVerdictCache): + // the verdict is a pure function of expression + policy + TriggerCompileSurface, and + // it is the exact hot-path cost N1 flagged — every staleness sweep / import probe of + // an Expression-triggered config re-ran a full trust compilation + script compile. + var (_, error) = ScriptCompileVerdictCache.GetOrAdd(nameof(TriggerCompileSurface), expression, () => + { + // Authoritative forbidden-API verdict first. Report ALL violations, not + // just the first, so an operator fixing one forbidden API isn't surprised + // by a second on the next deploy attempt (mirrors ScriptCompiler.TryCompile). + var violations = ScriptTrustValidator.FindViolations(expression); + if (violations.Count > 0) + return (false, $"uses forbidden API: {string.Join("; ", violations)}"); - // Real compile of the bare boolean expression against the trigger globals. - var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface)); - if (errors.Count > 0) - return $"is not a valid expression: {string.Join("; ", errors)}"; + // Real compile of the bare boolean expression against the trigger globals. + var errors = RoslynScriptCompiler.Compile(expression, typeof(TriggerCompileSurface)); + if (errors.Count > 0) + return (false, $"is not a valid expression: {string.Join("; ", errors)}"); - return null; + return (true, (string?)null); + }); + return error; } /// diff --git a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs index 1f4977c4..1c0309ab 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs @@ -149,6 +149,29 @@ public class ScriptCompilerTests Assert.Contains("forbidden", error, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void CheckExpressionSyntax_SameExpressionTwice_SecondCallIsCacheHit() + { + ScriptCompileVerdictCache.Clear(); + ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null"); + var hitsBefore = ScriptCompileVerdictCache.Hits; + var error = ValidationService.CheckExpressionSyntax("Attributes[\"Temp\"] != null"); + Assert.Null(error); + Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits); // FAILS today: no cache use + } + + [Fact] + public void CheckExpressionSyntax_ScriptSurfaceVerdict_NotReusedForTriggerSurface() + { + // "Notify != null" resolves on ScriptCompileSurface (Notify is a script global, + // ScriptCompileSurface.cs:53) but NOT on TriggerCompileSurface — a shared + // code-only cache entry would wrongly report the trigger expression clean. + ScriptCompileVerdictCache.Clear(); + Assert.True(new ScriptCompiler().TryCompile("Notify != null", "S").IsSuccess); // warms the SCRIPT-surface entry + var error = ValidationService.CheckExpressionSyntax("Notify != null"); + Assert.NotNull(error); // green today (no cache), and MUST STAY green after T3 — the T2 key makes it structural + } + [Fact] public void CheckExpressionSyntax_MultipleForbiddenApis_ReportsAll() {