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()
{