refactor(scripting): extract script-callable types into Roslyn-free Core.Scripting.Abstractions (A0)

This commit is contained in:
Joseph Doherty
2026-06-07 15:10:00 -04:00
parent df772dd09a
commit 56cac39216
9 changed files with 26 additions and 4 deletions
@@ -0,0 +1,68 @@
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms;
/// <summary>
/// <see cref="ScriptContext"/> subclass for alarm predicate evaluation. Reads from
/// the engine's shared tag cache (driver + virtual tags), writes are rejected —
/// predicates must be side-effect free so their output doesn't depend on evaluation
/// order or drive cascade behavior.
/// </summary>
/// <remarks>
/// Per Phase 7 plan Shape A decision, alarm scripts are one-script-per-alarm
/// returning <c>bool</c>. They read any tag they want but should not write
/// anything (the owning alarm's state is tracked by the engine, not the script).
/// </remarks>
public sealed class AlarmPredicateContext : ScriptContext
{
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
private readonly Func<DateTime> _clock;
/// <summary>Initializes a new instance of the <see cref="AlarmPredicateContext"/> class.</summary>
/// <param name="readCache">The cached read values from tags.</param>
/// <param name="logger">The logger for diagnostics.</param>
/// <param name="clock">Optional custom clock for testing.</param>
public AlarmPredicateContext(
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
ILogger logger,
Func<DateTime>? clock = null)
{
_readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? (() => DateTime.UtcNow);
}
/// <summary>Gets a tag value from the read cache.</summary>
/// <param name="path">The tag path to retrieve.</param>
/// <inheritdoc />
public override DataValueSnapshot GetTag(string path)
{
if (string.IsNullOrWhiteSpace(path))
return new DataValueSnapshot(null, 0x80340000u, null, _clock());
return _readCache.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u, null, _clock());
}
/// <summary>Rejects virtual tag writes for pure predicate semantics.</summary>
/// <param name="path">The virtual tag path.</param>
/// <param name="value">The value to write.</param>
/// <inheritdoc />
public override void SetVirtualTag(string path, object? value)
{
// Predicates must be pure — writing from an alarm script couples alarm state to
// virtual-tag state in a way that's near-impossible to reason about. Rejected
// at runtime with a clear message; operators see it in the scripts-*.log.
throw new InvalidOperationException(
"Alarm predicate scripts cannot write to virtual tags. Move the write logic " +
"into a virtual tag whose value the alarm predicate then reads.");
}
/// <inheritdoc />
public override DateTime Now => _clock();
/// <inheritdoc />
public override ILogger Logger { get; }
}
@@ -0,0 +1,86 @@
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// The API user scripts see as the global <c>ctx</c>. Abstract — concrete subclasses
/// (e.g. <c>VirtualTagScriptContext</c>, <c>AlarmScriptContext</c>) plug in the
/// actual tag-backend + logger + virtual-tag writer for each evaluation. Phase 7 plan
/// decision #6: scripts can read any tag, write only to virtual tags, and have no
/// other .NET reach — no HttpClient, no File, no Process, no reflection.
/// </summary>
/// <remarks>
/// <para>
/// Every member on this type MUST be serializable in the narrow sense that
/// <c>DependencyExtractor</c> can recognize tag-access call sites from the
/// script AST. Method names used from scripts are locked — renaming
/// <see cref="GetTag"/> or <see cref="SetVirtualTag"/> is a breaking change for every
/// authored script and the dependency extractor must update in lockstep.
/// </para>
/// <para>
/// New helpers (<see cref="Now"/>, <see cref="Deadband"/>) are additive: adding a
/// method doesn't invalidate existing scripts. Do not remove or rename without a
/// plan-level decision + migration for authored scripts.
/// </para>
/// </remarks>
public abstract class ScriptContext
{
/// <summary>
/// Read a tag's current value + quality + source timestamp. Path syntax is
/// <c>Enterprise/Site/Area/Line/Equipment/TagName</c> (forward-slash delimited,
/// matching the Equipment-namespace browse tree). Returns a
/// <see cref="DataValueSnapshot"/> so scripts branch on quality without a second
/// call.
/// </summary>
/// <remarks>
/// <paramref name="path"/> MUST be a string literal in the script source — dynamic
/// paths (variables, concatenation, method-returned strings) are rejected at
/// publish by <c>DependencyExtractor</c>. This is intentional: the static
/// dependency set is required for the change-driven scheduler to subscribe to the
/// right upstream tags at load time.
/// </remarks>
/// <param name="path">The literal tag path to read.</param>
public abstract DataValueSnapshot GetTag(string path);
/// <summary>
/// Write a value to a virtual tag. Operator scripts cannot write to driver-sourced
/// tags — the OPC UA dispatch in <c>DriverNodeManager</c> rejects that separately
/// per ADR-002 with <c>BadUserAccessDenied</c>. This method is the only write path
/// virtual tags have.
/// </summary>
/// <remarks>
/// Path rules identical to <see cref="GetTag"/> — literal only, dependency
/// extractor tracks the write targets so the engine knows what downstream
/// subscribers to notify.
/// </remarks>
/// <param name="path">The literal tag path to write to.</param>
/// <param name="value">The value to write to the virtual tag.</param>
public abstract void SetVirtualTag(string path, object? value);
/// <summary>
/// Current UTC timestamp. Prefer this over <see cref="DateTime.UtcNow"/> in
/// scripts so the harness can supply a deterministic clock for tests.
/// </summary>
public abstract DateTime Now { get; }
/// <summary>
/// Per-script Serilog logger. Output lands in the dedicated <c>scripts-*.log</c>
/// sink with structured property <c>ScriptName</c> = the script's configured name.
/// Use at error level to surface problems; main <c>opcua-*.log</c> receives a
/// companion WARN entry so operators see script errors in the primary log.
/// </summary>
public abstract ILogger Logger { get; }
/// <summary>
/// Deadband helper — returns <c>true</c> when <paramref name="current"/> differs
/// from <paramref name="previous"/> by more than <paramref name="tolerance"/>.
/// Useful for alarm predicates that shouldn't flicker on small noise. Pure
/// function; no side effects.
/// </summary>
/// <param name="current">The current value to check.</param>
/// <param name="previous">The previous value to compare against.</param>
/// <param name="tolerance">The minimum difference threshold for a change to be detected.</param>
public static bool Deadband(double current, double previous, double tolerance)
=> Math.Abs(current - previous) > tolerance;
}
@@ -0,0 +1,20 @@
namespace ZB.MOM.WW.OtOpcUa.Core.Scripting;
/// <summary>
/// Wraps a <see cref="ScriptContext"/> as a named field so user scripts see
/// <c>ctx.GetTag(...)</c> instead of the bare <c>GetTag(...)</c> that Roslyn's
/// globalsType convention would produce. Keeps the script ergonomics operators
/// author against consistent with the dependency extractor (which looks for the
/// <c>ctx.</c> prefix) and with the Admin UI hand-written type stub.
/// </summary>
/// <remarks>
/// Generic on <typeparamref name="TContext"/> so alarm predicates can use a richer
/// context (e.g. with an <c>Alarm</c> property carrying the owning condition's
/// metadata) without affecting virtual-tag contexts.
/// </remarks>
public class ScriptGlobals<TContext>
where TContext : ScriptContext
{
/// <summary>Gets or sets the script context available to scripts.</summary>
public TContext ctx { get; set; } = default!;
}
@@ -0,0 +1,74 @@
using Serilog;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Scripting;
namespace ZB.MOM.WW.OtOpcUa.Core.VirtualTags;
/// <summary>
/// Per-evaluation <see cref="ScriptContext"/> for a virtual-tag script. Reads come
/// out of the engine's last-known-value cache (driver tags updated via the
/// <c>ITagUpstreamSource</c> subscription, virtual tags updated by prior
/// evaluations). Writes route through <c>VirtualTagEngine</c>'s
/// <c>OnScriptSetVirtualTag</c> callback so cross-tag write side effects still
/// participate in change-trigger cascades (via the engine's <c>CascadeAsync</c>).
/// </summary>
/// <remarks>
/// <para>
/// Context instances are evaluation-scoped, not tag-scoped. The engine
/// constructs a fresh context for every run — cheap because the constructor
/// just captures references — so scripts can't cache mutable state across runs
/// via <c>ctx</c>. Mutable state across runs is a future decision (e.g. a
/// dedicated <c>ctx.Memory</c> dictionary); not in scope for Phase 7.
/// </para>
/// <para>
/// The <see cref="Now"/> clock is injectable so tests can pin time
/// deterministically. Production wires to <see cref="DateTime.UtcNow"/>.
/// </para>
/// </remarks>
public sealed class VirtualTagContext : ScriptContext
{
private readonly IReadOnlyDictionary<string, DataValueSnapshot> _readCache;
private readonly Action<string, object?> _setVirtualTag;
private readonly Func<DateTime> _clock;
/// <summary>Initializes a new instance of the VirtualTagContext class.</summary>
/// <param name="readCache">The cache of tag values available for reading during script execution.</param>
/// <param name="setVirtualTag">Callback to invoke when the script writes a virtual tag value.</param>
/// <param name="logger">The Serilog logger instance.</param>
/// <param name="clock">Optional function to get the current UTC time; defaults to DateTime.UtcNow if not provided.</param>
public VirtualTagContext(
IReadOnlyDictionary<string, DataValueSnapshot> readCache,
Action<string, object?> setVirtualTag,
ILogger logger,
Func<DateTime>? clock = null)
{
_readCache = readCache ?? throw new ArgumentNullException(nameof(readCache));
_setVirtualTag = setVirtualTag ?? throw new ArgumentNullException(nameof(setVirtualTag));
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
_clock = clock ?? (() => DateTime.UtcNow);
}
/// <inheritdoc />
public override DataValueSnapshot GetTag(string path)
{
if (string.IsNullOrWhiteSpace(path))
return new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
return _readCache.TryGetValue(path, out var v)
? v
: new DataValueSnapshot(null, 0x80340000u /* BadNodeIdUnknown */, null, _clock());
}
/// <inheritdoc />
public override void SetVirtualTag(string path, object? value)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Virtual tag path required.", nameof(path));
_setVirtualTag(path, value);
}
/// <inheritdoc />
public override DateTime Now => _clock();
/// <inheritdoc />
public override ILogger Logger { get; }
}
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Core.Abstractions\ZB.MOM.WW.OtOpcUa.Core.Abstractions.csproj"/>
</ItemGroup>
</Project>