diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompileVerdictCache.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompileVerdictCache.cs
new file mode 100644
index 00000000..52cee842
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompileVerdictCache.cs
@@ -0,0 +1,74 @@
+using System.Collections.Concurrent;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
+
+///
+/// Process-wide cache of script compile verdicts, keyed on a SHA-256 hash of the
+/// script code. The verdict (clean/compiles, or the name-free error text) is a pure
+/// function of the code plus the compile-time-static ScriptTrustPolicy and
+/// globals surface, so it is sound to memoise it across the whole process: an
+/// unchanged script runs the forbidden-API analysis and the Roslyn compile exactly
+/// once, removing both the repeat CPU cost and the repeat compiled-assembly load.
+///
+///
+/// The stored error is deliberately name-free — the caller's script name is
+/// formatted into the returned message at read time, so two callers submitting the
+/// same bad code each see their own name in the failure text while sharing one verdict.
+///
+///
+///
+/// Bounded at entries; on overflow the cache is simply
+/// cleared (the verdict is cheap to recompute, so a coarse reset is safe and avoids
+/// eviction bookkeeping). //
+/// are exposed for tests and diagnostics.
+///
+///
+public static class ScriptCompileVerdictCache
+{
+ /// Upper bound on cached entries; the cache is cleared wholesale on overflow.
+ private const int MaxEntries = 4096;
+
+ private static readonly ConcurrentDictionary Cache = new();
+ private static long _hits;
+
+ /// Number of cache hits observed since the last .
+ public static long Hits => Interlocked.Read(ref _hits);
+
+ /// Current number of cached verdicts.
+ public static int Count => Cache.Count;
+
+ ///
+ /// Returns the cached verdict for , or computes it via
+ /// and caches it on a miss. A hit increments
+ /// . The verdict's error text must be name-free — the caller
+ /// formats the script name in on return.
+ ///
+ public static (bool Ok, string? Error) GetOrAdd(string code, Func<(bool Ok, string? Error)> factory)
+ {
+ var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(code)));
+
+ if (Cache.TryGetValue(key, out var verdict))
+ {
+ Interlocked.Increment(ref _hits);
+ return verdict;
+ }
+
+ verdict = factory();
+
+ // Coarse bound: on overflow drop everything rather than track evictions.
+ if (Cache.Count >= MaxEntries)
+ Cache.Clear();
+
+ Cache[key] = verdict;
+ return verdict;
+ }
+
+ /// Clears all cached verdicts and resets the hit counter.
+ public static void Clear()
+ {
+ Cache.Clear();
+ Interlocked.Exchange(ref _hits, 0);
+ }
+}
diff --git a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs
index 36da37fc..4e23e64c 100644
--- a/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs
+++ b/src/ZB.MOM.WW.ScadaBridge.TemplateEngine/Validation/ScriptCompiler.cs
@@ -35,17 +35,28 @@ public class ScriptCompiler
if (string.IsNullOrWhiteSpace(code))
return Result.Failure($"Script '{scriptName}' has empty code.");
- // Authoritative forbidden-API verdict first — a security violation must
- // gate the script regardless of whether it otherwise compiles.
- var violations = ScriptTrustValidator.FindViolations(code);
- if (violations.Count > 0)
- return Result.Failure($"Script '{scriptName}' uses forbidden API: {violations[0]}");
+ // The verdict is a pure function of the code (the forbidden-API policy and
+ // globals surface are compile-time static), so memoise it process-wide: an
+ // unchanged script compiles exactly once per process. The cached error is
+ // name-free — the script name is formatted in below at read time.
+ var (ok, error) = ScriptCompileVerdictCache.GetOrAdd(code, () =>
+ {
+ // Authoritative forbidden-API verdict first — a security violation must
+ // gate the script regardless of whether it otherwise compiles.
+ var violations = ScriptTrustValidator.FindViolations(code);
+ if (violations.Count > 0)
+ return (false, $"uses forbidden API: {violations[0]}");
- // Real CSharpScript compile against the runtime-mirroring globals surface.
- var errors = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
- if (errors.Count > 0)
- return Result.Failure($"Script '{scriptName}' failed to compile: {errors[0]}");
+ // Real CSharpScript compile against the runtime-mirroring globals surface.
+ var errors = RoslynScriptCompiler.Compile(code, typeof(ScriptCompileSurface));
+ if (errors.Count > 0)
+ return (false, $"failed to compile: {errors[0]}");
- return Result.Success(true);
+ return (true, (string?)null);
+ });
+
+ return ok
+ ? Result.Success(true)
+ : Result.Failure($"Script '{scriptName}' {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 2f37665a..1405fbd3 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.TemplateEngine.Tests/Validation/ScriptCompilerTests.cs
@@ -135,4 +135,35 @@ public class ScriptCompilerTests
Assert.NotNull(error);
Assert.Contains("forbidden", error, StringComparison.OrdinalIgnoreCase);
}
+
+ // --- Compile-verdict cache: unchanged code compiles once per process ---
+
+ [Fact]
+ public void TryCompile_SameCodeTwice_SecondCallIsCacheHit()
+ {
+ ScriptCompileVerdictCache.Clear();
+ var c = new ScriptCompiler();
+ c.TryCompile("return 1;", "A");
+ var hitsBefore = ScriptCompileVerdictCache.Hits;
+ var r = c.TryCompile("return 1;", "B"); // same code, different name
+ Assert.True(r.IsSuccess);
+ Assert.Equal(hitsBefore + 1, ScriptCompileVerdictCache.Hits);
+ }
+
+ [Fact]
+ public void TryCompile_CachedFailure_FormatsNameForEachCaller()
+ {
+ // The stored verdict is name-free; the script name is formatted in at read
+ // time, so two callers of the same bad code get their own name in the message.
+ ScriptCompileVerdictCache.Clear();
+ var c = new ScriptCompiler();
+ var first = c.TryCompile("var x = NoSuchThing.Foo();", "Alpha");
+ var second = c.TryCompile("var x = NoSuchThing.Foo();", "Beta"); // cache hit
+
+ Assert.True(first.IsFailure);
+ Assert.True(second.IsFailure);
+ Assert.Contains("'Alpha'", first.Error);
+ Assert.Contains("'Beta'", second.Error);
+ Assert.Contains("compile", second.Error, StringComparison.OrdinalIgnoreCase);
+ }
}