fix(core-scripting): resolve Low code-review findings (Core.Scripting-005,006,008,009,011)

- Core.Scripting-005: DependencyExtractor.HandleTagCall now recognises
  raw-string literal paths by checking the StringLiteralExpression node
  kind instead of the legacy StringLiteralToken kind.
- Core.Scripting-006: scope CompiledScriptCache failed-compile eviction
  with TryRemove(KeyValuePair) so a racing retry entry is not evicted.
- Core.Scripting-008: document the per-publish assembly accretion as an
  accepted limitation in docs/VirtualTags.md.
- Core.Scripting-009: enumerate the authoritative deny-list (namespace
  prefixes + type-granular denies) in the Phase 7 decision-#6 entry to
  match ForbiddenTypeAnalyzer.
- Core.Scripting-011: pin ScriptSandbox.Build, ScriptContext.Deadband
  boundary semantics, and end-to-end factory + companion-sink
  integration.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-23 07:23:42 -04:00
parent 99354bfaf2
commit 0a20de728d
10 changed files with 300 additions and 16 deletions

View File

@@ -148,4 +148,77 @@ public sealed class CompiledScriptCacheTests
var cache = new CompiledScriptCache<FakeScriptContext, int>();
Should.Throw<ArgumentNullException>(() => cache.GetOrCompile(null!));
}
[Fact]
public void Failed_compile_eviction_does_not_remove_a_concurrent_retry_entry()
{
// Regression for Core.Scripting-006: when a faulted Lazy is observed by a thread,
// the eviction must scope to that specific Lazy instance, not the key. If a
// concurrent retry has already inserted a fresh Lazy under the same key between
// the throw and the catch-block removal, the buggy TryRemove(key, out _) overload
// evicts the retry entry. The fixed TryRemove(KeyValuePair<,>) overload compares
// value identity, so only the faulted Lazy is removed.
//
// Deterministic setup: pre-populate the cache's internal dictionary with a
// faulted Lazy whose factory itself swaps the entry to a fresh Lazy as a side
// effect during the throw. By the time GetOrCompile reaches its catch block, the
// dictionary holds the fresh entry under the same key — exactly the race window
// the finding describes. The fix must leave the fresh entry in place.
var cache = new CompiledScriptCache<FakeScriptContext, int>();
// Reach the private _cache + HashSource via reflection — they're private, so
// InternalsVisibleTo doesn't help.
var cacheField = typeof(CompiledScriptCache<FakeScriptContext, int>)
.GetField("_cache", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
cacheField.ShouldNotBeNull();
var dict = (System.Collections.Concurrent.ConcurrentDictionary<
string, Lazy<ScriptEvaluator<FakeScriptContext, int>>>)cacheField!.GetValue(cache)!;
const string source = """return 7;""";
var hashSourceMethod = typeof(CompiledScriptCache<FakeScriptContext, int>)
.GetMethod("HashSource", System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic);
hashSourceMethod.ShouldNotBeNull();
var key = (string)hashSourceMethod!.Invoke(null, [source])!;
// The fresh Lazy is what a concurrent retry would have inserted between the
// faulted-throw and the catch's removal. Materialise it eagerly so we have a
// stable reference to assert identity against.
var fresh = new Lazy<ScriptEvaluator<FakeScriptContext, int>>(
() => ScriptEvaluator<FakeScriptContext, int>.Compile(source),
LazyThreadSafetyMode.ExecutionAndPublication);
// The faulted Lazy throws — but only after swapping its own dictionary entry
// for the fresh Lazy, modelling the race window between the throw and the
// catch-block eviction.
var faulted = new Lazy<ScriptEvaluator<FakeScriptContext, int>>(
() =>
{
dict[key] = fresh;
throw new InvalidOperationException("bad compile");
},
LazyThreadSafetyMode.ExecutionAndPublication);
dict[key] = faulted;
// Drive GetOrCompile through the public API. It observes the faulted Lazy
// currently under `key`, invokes .Value (which swaps in the fresh Lazy then
// throws), and runs the catch block's eviction. The fix removes only the
// specific faulted Lazy instance; the fresh entry survives.
Should.Throw<InvalidOperationException>(() => cache.GetOrCompile(source));
dict.ContainsKey(key).ShouldBeTrue(
"the fresh retry entry that won the race must survive the faulted Lazy eviction (Core.Scripting-006)");
ReferenceEquals(dict[key], fresh).ShouldBeTrue(
"the entry under the key must still be the fresh Lazy — an unconditional TryRemove(key) would have evicted it");
}
[Fact]
public void Failed_compile_path_still_evicts_its_own_faulted_entry()
{
// Companion to the race test above: confirm the fix's value-scoped eviction
// still removes the actual faulted Lazy (so retries with corrected source can
// succeed — the original Core.Scripting test that locked the contract).
var cache = new CompiledScriptCache<FakeScriptContext, int>();
Should.Throw<Exception>(() => cache.GetOrCompile("""return unknownIdentifier + 1;"""));
cache.Count.ShouldBe(0, "faulted Lazy must still be evicted after compile failure");
}
}

View File

@@ -209,4 +209,33 @@ public sealed class DependencyExtractorTests
result.IsValid.ShouldBeTrue();
result.Reads.Count.ShouldBe(2);
}
[Fact]
public void Accepts_single_line_raw_string_literal_path()
{
// A single-line raw string literal ("""Line1/Speed""") tokenizes as
// SingleLineRawStringLiteralToken, not StringLiteralToken — the old check
// would mis-reject it as a "dynamic path". Confirm static raw-string paths are
// harvested. (Core.Scripting-005.)
var src = "return ctx.GetTag(\"\"\"Line1/Speed\"\"\").Value;";
var result = DependencyExtractor.Extract(src);
result.IsValid.ShouldBeTrue();
result.Reads.ShouldContain("Line1/Speed");
result.Rejections.ShouldBeEmpty();
}
[Fact]
public void Accepts_multi_line_raw_string_literal_path()
{
// A multi-line raw string literal tokenizes as MultiLineRawStringLiteralToken.
// Even though it is unusual for a tag path, it is still a static string and
// must not be mis-rejected. (Core.Scripting-005.)
// Note: the multi-line raw string strips the common leading indent and the
// surrounding newlines, leaving exactly the body text.
var src = "return ctx.GetTag(\"\"\"\nLine1/Speed\n\"\"\").Value;";
var result = DependencyExtractor.Extract(src);
result.IsValid.ShouldBeTrue();
result.Reads.ShouldContain("Line1/Speed");
result.Rejections.ShouldBeEmpty();
}
}

View File

@@ -0,0 +1,70 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// Locks the boundary semantics of <see cref="ScriptContext.Deadband"/>. The helper
/// is the canonical "ignore small noise" predicate alarm authors compose into bigger
/// expressions; subtle sign / boundary changes here would silently move the
/// active-state edge of every consuming alarm. (Core.Scripting-011.)
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptContextTests
{
[Fact]
public void Deadband_returns_false_when_difference_equals_tolerance()
{
// Strict greater-than comparison: a delta exactly equal to tolerance is
// considered "within deadband" and must NOT trip.
ScriptContext.Deadband(current: 10.5, previous: 10.0, tolerance: 0.5).ShouldBeFalse();
}
[Fact]
public void Deadband_returns_true_when_difference_just_exceeds_tolerance()
{
// Any delta strictly greater than tolerance trips the deadband.
ScriptContext.Deadband(current: 10.6, previous: 10.0, tolerance: 0.5).ShouldBeTrue();
}
[Fact]
public void Deadband_returns_false_when_difference_just_below_tolerance()
{
ScriptContext.Deadband(current: 10.4, previous: 10.0, tolerance: 0.5).ShouldBeFalse();
}
[Fact]
public void Deadband_is_symmetric_in_direction_of_change()
{
// Math.Abs ensures the helper trips identically whether the value rose or fell.
ScriptContext.Deadband(current: 9.0, previous: 10.0, tolerance: 0.5).ShouldBeTrue();
ScriptContext.Deadband(current: 11.0, previous: 10.0, tolerance: 0.5).ShouldBeTrue();
}
[Fact]
public void Deadband_returns_false_when_values_are_equal()
{
ScriptContext.Deadband(current: 10.0, previous: 10.0, tolerance: 0.001).ShouldBeFalse();
}
[Fact]
public void Deadband_with_zero_tolerance_returns_true_for_any_difference()
{
// Zero-tolerance is the "trip on any non-equal change" mode. Equality still
// returns false (delta 0 is not strictly greater than 0).
ScriptContext.Deadband(current: 10.0, previous: 10.0, tolerance: 0).ShouldBeFalse();
ScriptContext.Deadband(current: 10.0, previous: 10.000001, tolerance: 0).ShouldBeTrue();
}
[Fact]
public void Deadband_with_negative_tolerance_always_trips_for_unequal_values()
{
// A negative tolerance is nonsensical input but the helper does not guard
// against it — Math.Abs(delta) is always >= 0, so the comparison is
// effectively "any non-equal change". Lock this so a refactor that adds
// (or removes) input validation requires an explicit test update.
ScriptContext.Deadband(current: 10.0, previous: 10.5, tolerance: -1.0).ShouldBeTrue();
ScriptContext.Deadband(current: 10.0, previous: 10.0, tolerance: -1.0).ShouldBeTrue();
}
}

View File

@@ -152,4 +152,47 @@ public sealed class ScriptLogCompanionSinkTests
scriptLogger.ForContext(ScriptLoggerFactory.ScriptNameProperty, "X").Fatal("fatal");
mainSink.Events.Count.ShouldBe(1);
}
[Fact]
public void Factory_plus_companion_sink_integration_surfaces_script_error_in_both_logs()
{
// End-to-end Core.Scripting-011 coverage: an Error-level emission from a logger
// built by ScriptLoggerFactory must land in BOTH the per-script sink (acting as
// the scripts-*.log substitute here) AND the main-log companion at Warning level,
// tagged with the originating ScriptName property. This is the integration the
// server's logger pipeline relies on so operators see script failures in the
// primary log without monitoring the scripts file separately.
var scriptsSink = new CapturingSink();
var mainSink = new CapturingSink();
// Main pipeline — receives only Error+ events forwarded by the companion sink.
var mainLogger = new LoggerConfiguration()
.MinimumLevel.Verbose().WriteTo.Sink(mainSink).CreateLogger();
// Root script pipeline — fans out to the scripts sink (stand-in for the
// rolling scripts-*.log file) AND the companion sink that forwards Error+
// to the main logger.
var rootScriptLogger = new LoggerConfiguration()
.MinimumLevel.Verbose()
.WriteTo.Sink(scriptsSink)
.WriteTo.Sink(new ScriptLogCompanionSink(mainLogger))
.CreateLogger();
// Factory binds the per-script ScriptName property — this is the only way the
// mirror knows which script raised the event.
var factory = new ScriptLoggerFactory(rootScriptLogger);
var perScript = factory.Create("HighTemp");
perScript.Error("threshold exceeded");
// Scripts sink saw the Error at its original level with the ScriptName bound.
scriptsSink.Events.Count.ShouldBe(1);
scriptsSink.Events[0].Level.ShouldBe(LogEventLevel.Error);
((ScalarValue)scriptsSink.Events[0].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("HighTemp");
// Main sink saw a Warning-level companion entry tagged with the same ScriptName.
mainSink.Events.Count.ShouldBe(1);
mainSink.Events[0].Level.ShouldBe(LogEventLevel.Warning);
((ScalarValue)mainSink.Events[0].Properties[ScriptLoggerFactory.ScriptNameProperty]).Value.ShouldBe("HighTemp");
((ScalarValue)mainSink.Events[0].Properties["OriginalLevel"]).Value.ShouldBe(LogEventLevel.Error);
}
}

View File

@@ -0,0 +1,56 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests;
/// <summary>
/// Covers the <see cref="ScriptSandbox.Build"/> argument-validation guards. The
/// guards are the only call-site protection against a typo / mis-wired context
/// type silently producing a sandbox missing the concrete context's assembly
/// reference; without coverage, the guards could be deleted by a refactor without
/// any test failing. (Core.Scripting-011.)
/// </summary>
[Trait("Category", "Unit")]
public sealed class ScriptSandboxBuildTests
{
[Fact]
public void Null_context_type_throws_ArgumentNullException()
{
Should.Throw<ArgumentNullException>(() => ScriptSandbox.Build(null!));
}
[Fact]
public void Non_ScriptContext_type_throws_ArgumentException()
{
// ScriptSandbox must reject types that don't derive from ScriptContext —
// ScriptGlobals<TContext> is constrained where TContext : ScriptContext, so
// sneaking a non-derived type past Build would later blow up inside Roslyn.
Should.Throw<ArgumentException>(() => ScriptSandbox.Build(typeof(string)))
.ParamName.ShouldBe("contextType");
}
[Fact]
public void Abstract_ScriptContext_base_type_is_accepted()
{
// The base ScriptContext type satisfies the IsAssignableFrom check, so the
// factory must not reject it even though it cannot be instantiated directly.
// Callers wiring a base-typed sandbox up for diagnostics rely on this.
var options = ScriptSandbox.Build(typeof(ScriptContext));
options.ShouldNotBeNull();
}
[Fact]
public void Concrete_subclass_is_accepted_and_its_assembly_referenced()
{
// The concrete context type's assembly must end up in the allow-listed
// references — otherwise Roslyn cannot resolve ScriptGlobals<TContext> at
// compile. We can't easily inspect the ScriptOptions metadata references
// by-assembly cross-version, so we exercise the end-to-end path instead: a
// script compiled against FakeScriptContext must successfully reach a
// FakeScriptContext member.
var evaluator = ScriptEvaluator<FakeScriptContext, double>.Compile(
"""return (double)ctx.GetTag("X").Value;""");
evaluator.ShouldNotBeNull();
}
}