refactor: rename ScadaLink → ZB.MOM.WW.ScadaBridge (code + projects + namespaces)

Solution + 23 src projects + 26 test projects renamed; folders, csproj,
namespaces, and ScadaLinkDbContext/ScadaBridgeDbContext class updated.
ActorSystem "scadalink" → "scadabridge", Akka seed-node URLs migrated.
SQL roles/logins, LDAP domains, CLI command name, and CLI config dir
(~/.scadalink → ~/.scadabridge) also renamed.

Build green; 5 Host.Tests fail awaiting SQL login rename in next commit.
Pre-existing StaleTagMonitor timing flakes unchanged.

Rename script committed at tools/rename-to-scadabridge.sh.
This commit is contained in:
Joseph Doherty
2026-05-28 09:37:45 -04:00
parent 6d87ee3c3b
commit 7b0b9c7365
1531 changed files with 11180 additions and 11054 deletions
@@ -0,0 +1,416 @@
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// <summary>
/// String/comment-aware scanner for the balanced-delimiter ("does it look like
/// valid C#") checks used by <see cref="ScriptCompiler"/> and
/// <c>SharedScriptService.ValidateSyntax</c>.
///
/// <para>
/// This is <b>not</b> a compiler. It is an interim structural check that walks
/// the source once and tracks <c>{}</c>, <c>[]</c> and <c>()</c> depth while
/// correctly skipping over the C# lexical constructs in which a delimiter is
/// inert: line/block comments, regular string literals (with <c>\</c> escapes),
/// verbatim strings (<c>@"..."</c>, where <c>""</c> escapes a quote and <c>\</c>
/// is literal), interpolated strings (<c>$"..."</c> / <c>$@"..."</c> — the holes
/// <c>{...}</c> are code and <c>{{</c>/<c>}}</c> are escaped braces), raw string
/// literals (<c>"""..."""</c>), and char literals (<c>'}'</c>).
/// </para>
///
/// <para>
/// It is intentionally conservative: when the real Roslyn-based compiler is
/// wired in (see <see cref="ScriptCompiler"/>) this hand-rolled scan should be
/// replaced by <c>CSharpSyntaxTree.ParseText</c> diagnostics. Until then this
/// scanner removes the false positives that a naive character count produced
/// for valid scripts containing a delimiter inside a string or comment.
/// </para>
/// </summary>
internal static class CSharpDelimiterScanner
{
/// <summary>The kind of delimiter mismatch found, if any.</summary>
internal enum Mismatch
{
None,
UnexpectedCloseBrace,
UnexpectedCloseBracket,
UnexpectedCloseParen,
UnclosedBrace,
UnclosedBracket,
UnclosedParen,
UnclosedBlockComment,
UnterminatedString,
UnterminatedChar,
}
/// <summary>
/// Returns true when <paramref name="pattern"/> occurs in a <b>code</b>
/// region of <paramref name="code"/> — i.e. not wholly inside a string
/// literal, char literal, or comment. Used by the interim forbidden-API
/// scan so that the inert text <c>System.IO.</c> in a comment or string
/// literal is not flagged as a forbidden API call (TemplateEngine-006).
///
/// <para>
/// This removes the false-positive half of the substring scan. It does
/// <b>not</b> close the bypass half: namespace aliases, <c>using static</c>,
/// and <c>global::</c>-qualified references still evade a pure text match.
/// Authoritative forbidden-API enforcement requires Roslyn semantic symbol
/// analysis and is deferred to the real script compiler / Site Runtime
/// sandbox; this check is advisory only.
/// </para>
/// </summary>
/// <param name="code">The C# source code to scan.</param>
/// <param name="pattern">The substring to search for in code regions only.</param>
internal static bool ContainsInCode(string code, string pattern)
{
if (string.IsNullOrEmpty(pattern))
return false;
// Blank out every string/char-literal/comment span, then do an ordinary
// substring search over what remains (the code regions).
var codeOnly = BlankNonCodeSpans(code);
return codeOnly.Contains(pattern, StringComparison.Ordinal);
}
/// <summary>
/// Replaces the content of every comment, string literal, and char literal
/// with spaces (newlines preserved), leaving only code regions intact.
/// Delimiter characters themselves are also blanked so a pattern cannot
/// straddle a literal boundary.
/// </summary>
private static string BlankNonCodeSpans(string code)
{
var buffer = code.ToCharArray();
int n = code.Length;
int i = 0;
void Blank(int from, int to)
{
for (int k = from; k < to && k < n; k++)
if (buffer[k] != '\n' && buffer[k] != '\r')
buffer[k] = ' ';
}
while (i < n)
{
char c = code[i];
char next = i + 1 < n ? code[i + 1] : '\0';
int start = i;
if (c == '/' && next == '/')
{
i += 2;
while (i < n && code[i] != '\n') i++;
Blank(start, i);
continue;
}
if (c == '/' && next == '*')
{
i += 2;
while (i < n && !(code[i] == '*' && i + 1 < n && code[i + 1] == '/')) i++;
if (i < n) i += 2;
Blank(start, i);
continue;
}
if (c == '"' && next == '"' && i + 2 < n && code[i + 2] == '"')
{
SkipRawString(code, ref i);
Blank(start, i);
continue;
}
if (c == '$')
{
int j = i + 1;
bool verbatim = false;
if (j < n && code[j] == '@') { verbatim = true; j++; }
if (j < n && code[j] == '"')
{
i = j;
SkipInterpolatedString(code, ref i, verbatim);
Blank(start, i);
continue;
}
}
if (c == '@' && next == '"')
{
i++;
SkipVerbatimString(code, ref i);
Blank(start, i);
continue;
}
if (c == '"')
{
SkipRegularString(code, ref i);
Blank(start, i);
continue;
}
if (c == '\'')
{
SkipCharLiteral(code, ref i);
Blank(start, i);
continue;
}
i++;
}
return new string(buffer);
}
/// <summary>
/// Walks <paramref name="code"/> once and reports the first structural
/// delimiter problem, or <see cref="Mismatch.None"/> when the source is
/// balanced. Delimiters inside comments, strings, and char literals are
/// ignored.
/// </summary>
/// <param name="code">The C# source code to scan for delimiter balance.</param>
internal static Mismatch Scan(string code)
{
int brace = 0, bracket = 0, paren = 0;
int i = 0;
int n = code.Length;
while (i < n)
{
char c = code[i];
char next = i + 1 < n ? code[i + 1] : '\0';
// Line comment.
if (c == '/' && next == '/')
{
i += 2;
while (i < n && code[i] != '\n') i++;
continue;
}
// Block comment.
if (c == '/' && next == '*')
{
i += 2;
bool closed = false;
while (i < n)
{
if (code[i] == '*' && i + 1 < n && code[i + 1] == '/')
{
i += 2;
closed = true;
break;
}
i++;
}
if (!closed) return Mismatch.UnclosedBlockComment;
continue;
}
// Raw string literal: three or more consecutive quotes open it; the
// same number of quotes closes it. Detected before $/@-prefixed and
// plain strings.
if (c == '"' && next == '"' && i + 2 < n && code[i + 2] == '"')
{
if (!SkipRawString(code, ref i)) return Mismatch.UnterminatedString;
continue;
}
// Interpolated string ($"..." or $@"..." / @$"...").
if (c == '$')
{
int j = i + 1;
bool verbatim = false;
if (j < n && code[j] == '@') { verbatim = true; j++; }
if (j < n && code[j] == '"')
{
i = j;
if (!SkipInterpolatedString(code, ref i, verbatim)) return Mismatch.UnterminatedString;
continue;
}
}
// Verbatim string (@"...").
if (c == '@' && next == '"')
{
i++; // now on the opening quote
if (!SkipVerbatimString(code, ref i)) return Mismatch.UnterminatedString;
continue;
}
// Regular string literal.
if (c == '"')
{
if (!SkipRegularString(code, ref i)) return Mismatch.UnterminatedString;
continue;
}
// Char literal.
if (c == '\'')
{
if (!SkipCharLiteral(code, ref i)) return Mismatch.UnterminatedChar;
continue;
}
switch (c)
{
case '{': brace++; break;
case '}':
brace--;
if (brace < 0) return Mismatch.UnexpectedCloseBrace;
break;
case '[': bracket++; break;
case ']':
bracket--;
if (bracket < 0) return Mismatch.UnexpectedCloseBracket;
break;
case '(': paren++; break;
case ')':
paren--;
if (paren < 0) return Mismatch.UnexpectedCloseParen;
break;
}
i++;
}
if (brace != 0) return Mismatch.UnclosedBrace;
if (bracket != 0) return Mismatch.UnclosedBracket;
if (paren != 0) return Mismatch.UnclosedParen;
return Mismatch.None;
}
/// <summary>
/// Advances <paramref name="i"/> past a regular <c>"..."</c> string literal.
/// On entry <c>code[i] == '"'</c>. Returns false if the string is unterminated.
/// </summary>
private static bool SkipRegularString(string code, ref int i)
{
int n = code.Length;
i++; // past opening quote
while (i < n)
{
char c = code[i];
if (c == '\\') { i += 2; continue; } // escape — skip next char
if (c == '\n') return false; // unterminated (no multi-line)
if (c == '"') { i++; return true; }
i++;
}
return false;
}
/// <summary>
/// Advances past a verbatim <c>@"..."</c> string. On entry <c>code[i] == '"'</c>.
/// Inside, <c>\</c> is literal and <c>""</c> is an escaped quote.
/// </summary>
private static bool SkipVerbatimString(string code, ref int i)
{
int n = code.Length;
i++; // past opening quote
while (i < n)
{
if (code[i] == '"')
{
if (i + 1 < n && code[i + 1] == '"') { i += 2; continue; } // escaped quote
i++;
return true;
}
i++;
}
return false;
}
/// <summary>
/// Advances past an interpolated string. <paramref name="verbatim"/> selects
/// the <c>$@"..."</c> escaping rules. Interpolation holes <c>{...}</c> are
/// skipped over (their braces are code, not literal text); <c>{{</c>/<c>}}</c>
/// are escaped braces. On entry <c>code[i] == '"'</c>.
/// </summary>
private static bool SkipInterpolatedString(string code, ref int i, bool verbatim)
{
int n = code.Length;
i++; // past opening quote
while (i < n)
{
char c = code[i];
if (!verbatim && c == '\\') { i += 2; continue; }
if (c == '"')
{
if (verbatim && i + 1 < n && code[i + 1] == '"') { i += 2; continue; }
i++;
return true;
}
if (c == '{')
{
if (i + 1 < n && code[i + 1] == '{') { i += 2; continue; } // escaped brace
// Interpolation hole — skip to the matching '}', tracking nested
// braces so a hole containing an object initializer is handled.
i++;
int depth = 1;
while (i < n && depth > 0)
{
char h = code[i];
if (h == '{') depth++;
else if (h == '}') depth--;
else if (h == '"')
{
// A nested string inside the hole.
if (!SkipRegularString(code, ref i)) return false;
continue;
}
i++;
}
continue;
}
if (c == '}' && i + 1 < n && code[i + 1] == '}') { i += 2; continue; } // escaped brace
i++;
}
return false;
}
/// <summary>
/// Advances past a raw string literal <c>"""..."""</c> (C# 11). On entry
/// <c>code[i]</c> is the first of three or more opening quotes.
/// </summary>
private static bool SkipRawString(string code, ref int i)
{
int n = code.Length;
int openCount = 0;
while (i < n && code[i] == '"') { openCount++; i++; }
// Look for a run of the same number of quotes.
while (i < n)
{
if (code[i] == '"')
{
int closeCount = 0;
int start = i;
while (i < n && code[i] == '"') { closeCount++; i++; }
if (closeCount >= openCount) return true;
// Fewer quotes than the opener — they are literal content; keep scanning.
if (closeCount == 0) i = start + 1;
}
else
{
i++;
}
}
return false;
}
/// <summary>
/// Advances past a <c>'x'</c> char literal. On entry <c>code[i] == '\''</c>.
/// </summary>
private static bool SkipCharLiteral(string code, ref int i)
{
int n = code.Length;
i++; // past opening quote
while (i < n)
{
char c = code[i];
if (c == '\\') { i += 2; continue; }
if (c == '\n') return false;
if (c == '\'') { i++; return true; }
i++;
}
return false;
}
}
@@ -0,0 +1,105 @@
using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// <summary>
/// Validates script code by attempting to compile it using Roslyn.
/// In production, this would compile C# scripts against a stub ScriptApi assembly
/// that provides the allowed API surface (attribute read/write, CallScript, CallShared, etc.)
/// and enforces the forbidden API list (System.IO, Process, Threading, Reflection, raw network).
///
/// For now, this implementation performs basic syntax validation.
///
/// <para>
/// <b>SECURITY LIMITATION (TemplateEngine-006):</b> the forbidden-API check below
/// is an interim, <i>advisory</i> text scan — it is NOT an authoritative trust-model
/// boundary. <see cref="CSharpDelimiterScanner.ContainsInCode"/> removes the
/// false-positive half (forbidden text inside a string/comment is ignored), but a
/// determined script can still bypass the literal patterns via namespace aliases,
/// <c>using static</c>, or <c>global::</c>-qualified references. Authoritative
/// enforcement requires Roslyn semantic symbol analysis of the referenced
/// types/namespaces and is the responsibility of the real script compiler and the
/// Site Runtime sandbox. Do not rely on this class as the sole trust-model gate.
/// </para>
/// </summary>
public class ScriptCompiler
{
/// <summary>
/// Forbidden namespace patterns — scripts (and trigger expressions, via
/// <see cref="ValidationService"/>) must not use these. Trigger expressions run
/// under the same trust model as scripts, so the list is shared from here rather
/// than duplicated.
///
/// <para>
/// Matched with <see cref="CSharpDelimiterScanner.ContainsInCode"/> against code
/// regions only. This is advisory — see the class summary's SECURITY LIMITATION
/// note; the substring patterns are bypassable and the authoritative check is
/// deferred to Roslyn semantic analysis.
/// </para>
/// </summary>
internal static readonly string[] ForbiddenPatterns =
[
"System.IO.",
"System.Diagnostics.Process",
"System.Threading.",
"System.Reflection.",
"System.Net.Sockets.",
"System.Net.Http.",
];
/// <summary>
/// Attempts to compile a script and returns success or a compilation error.
/// </summary>
/// <param name="code">The C# script code.</param>
/// <param name="scriptName">The canonical name of the script (for error messages).</param>
/// <returns>Success if the script compiles, or Failure with the error message.</returns>
public Result<bool> TryCompile(string code, string scriptName)
{
if (string.IsNullOrWhiteSpace(code))
return Result<bool>.Failure($"Script '{scriptName}' has empty code.");
// Check for forbidden APIs. Advisory only (see class summary): the scan is
// code-region-aware so forbidden text inside a string/comment is ignored,
// but it remains a substring match and is not an authoritative boundary.
foreach (var pattern in ForbiddenPatterns)
{
if (CSharpDelimiterScanner.ContainsInCode(code, pattern))
{
return Result<bool>.Failure(
$"Script '{scriptName}' uses forbidden API: '{pattern.TrimEnd('.')}'. " +
"Scripts cannot use System.IO, Process, Threading, Reflection, or raw network APIs.");
}
}
// Basic structural validation: balanced braces/brackets/parens. The scan
// is string- and comment-aware (see CSharpDelimiterScanner) so a delimiter
// inside a regular/verbatim/interpolated/raw string, a char literal, or a
// comment does not produce a false mismatch. This remains an interim check
// until the Roslyn-based compiler is wired in.
var mismatch = CSharpDelimiterScanner.Scan(code);
return mismatch switch
{
CSharpDelimiterScanner.Mismatch.None =>
Result<bool>.Success(true),
CSharpDelimiterScanner.Mismatch.UnexpectedCloseBrace =>
Result<bool>.Failure($"Script '{scriptName}' has mismatched braces (unexpected closing brace)."),
CSharpDelimiterScanner.Mismatch.UnclosedBrace =>
Result<bool>.Failure($"Script '{scriptName}' has mismatched braces (unclosed opening brace)."),
CSharpDelimiterScanner.Mismatch.UnexpectedCloseBracket =>
Result<bool>.Failure($"Script '{scriptName}' has mismatched brackets (unexpected closing bracket)."),
CSharpDelimiterScanner.Mismatch.UnclosedBracket =>
Result<bool>.Failure($"Script '{scriptName}' has mismatched brackets (unclosed opening bracket)."),
CSharpDelimiterScanner.Mismatch.UnexpectedCloseParen =>
Result<bool>.Failure($"Script '{scriptName}' has mismatched parentheses (unexpected closing parenthesis)."),
CSharpDelimiterScanner.Mismatch.UnclosedParen =>
Result<bool>.Failure($"Script '{scriptName}' has mismatched parentheses (unclosed opening parenthesis)."),
CSharpDelimiterScanner.Mismatch.UnclosedBlockComment =>
Result<bool>.Failure($"Script '{scriptName}' has an unclosed block comment."),
CSharpDelimiterScanner.Mismatch.UnterminatedString =>
Result<bool>.Failure($"Script '{scriptName}' has an unterminated string literal."),
CSharpDelimiterScanner.Mismatch.UnterminatedChar =>
Result<bool>.Failure($"Script '{scriptName}' has an unterminated character literal."),
_ => Result<bool>.Success(true),
};
}
}
@@ -0,0 +1,404 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// <summary>
/// Semantic validation rules for a FlattenedConfiguration:
/// - CallScript/CallShared targets must reference existing scripts
/// - Parameter count and types must match
/// - Return type compatibility
/// - Trigger operand types: RangeViolation requires numeric attribute
/// - On-trigger script must exist
/// - Instance scripts cannot call alarm on-trigger scripts
/// </summary>
public class SemanticValidator
{
// Known numeric data types for RangeViolation trigger type validation
private static readonly HashSet<string> NumericDataTypes = new(StringComparer.OrdinalIgnoreCase)
{
"Int32", "Float", "Double"
};
/// <summary>
/// Runs all semantic validation rules.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <param name="sharedScripts">Shared scripts available for CallShared references.</param>
public ValidationResult Validate(
FlattenedConfiguration configuration,
IReadOnlyList<ResolvedScript>? sharedScripts = null)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
var scriptNames = new HashSet<string>(
configuration.Scripts.Select(s => s.CanonicalName), StringComparer.Ordinal);
var sharedScriptNames = new HashSet<string>(
(sharedScripts ?? []).Select(s => s.CanonicalName), StringComparer.Ordinal);
var attributeMap = new Dictionary<string, ResolvedAttribute>(StringComparer.Ordinal);
foreach (var a in configuration.Attributes)
{
// Skip duplicates — naming collisions are reported separately
attributeMap.TryAdd(a.CanonicalName, a);
}
// Collect alarm on-trigger script names for cross-call violation checks
var alarmOnTriggerScripts = new HashSet<string>(StringComparer.Ordinal);
foreach (var alarm in configuration.Alarms)
{
if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName))
alarmOnTriggerScripts.Add(alarm.OnTriggerScriptCanonicalName);
}
// Build parameter maps for call target validation
var scriptParamMap = BuildParameterMap(configuration.Scripts);
var sharedParamMap = BuildParameterMap(sharedScripts ?? []);
var scriptReturnMap = BuildReturnMap(configuration.Scripts);
var sharedReturnMap = BuildReturnMap(sharedScripts ?? []);
foreach (var script in configuration.Scripts)
{
var callTargets = ExtractCallTargets(script.Code);
foreach (var call in callTargets)
{
if (call.IsShared)
{
// CallShared targets must reference existing shared scripts
if (!sharedScriptNames.Contains(call.TargetName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
$"Script '{script.CanonicalName}' calls shared script '{call.TargetName}' which does not exist.",
script.CanonicalName));
}
else
{
ValidateCallParameters(script.CanonicalName, call, sharedParamMap, errors);
}
}
else
{
// CallScript targets must reference existing instance scripts
if (!scriptNames.Contains(call.TargetName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.CallTargetNotFound,
$"Script '{script.CanonicalName}' calls script '{call.TargetName}' which does not exist.",
script.CanonicalName));
}
else
{
ValidateCallParameters(script.CanonicalName, call, scriptParamMap, errors);
// Instance scripts cannot call alarm on-trigger scripts
if (alarmOnTriggerScripts.Contains(call.TargetName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.CrossCallViolation,
$"Script '{script.CanonicalName}' calls alarm on-trigger script '{call.TargetName}' which is not allowed.",
script.CanonicalName));
}
}
}
}
}
// Validate Call-type scripts have parameter definitions
foreach (var script in configuration.Scripts)
{
if (string.Equals(script.TriggerType, "Call", StringComparison.OrdinalIgnoreCase))
{
if (string.IsNullOrWhiteSpace(script.ParameterDefinitions))
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.MissingMetadata,
$"Call-type script '{script.CanonicalName}' has no parameter definitions.",
script.CanonicalName));
}
}
}
// Validate alarm trigger operand types
foreach (var alarm in configuration.Alarms)
{
// RangeViolation requires numeric attribute
if (alarm.TriggerType == "RangeViolation" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
{
var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
{
if (!NumericDataTypes.Contains(attr.DataType))
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' uses RangeViolation trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
alarm.CanonicalName));
}
}
}
// HiLo requires numeric attribute + ordered setpoints
if (alarm.TriggerType == "HiLo" && !string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
{
var attrName = ValidationService.ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
if (attrName != null && attributeMap.TryGetValue(attrName, out var attr))
{
if (!NumericDataTypes.Contains(attr.DataType))
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' uses HiLo trigger on attribute '{attrName}' which has non-numeric type '{attr.DataType}'.",
alarm.CanonicalName));
}
}
var setpoints = ValidationService.ExtractHiLoSetpoints(alarm.TriggerConfiguration);
// At least one setpoint must be configured — otherwise the alarm
// can never fire.
if (!setpoints.LoLo.HasValue && !setpoints.Lo.HasValue
&& !setpoints.Hi.HasValue && !setpoints.HiHi.HasValue)
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' is HiLo but no setpoints (LoLo/Lo/Hi/HiHi) are configured — it will never fire.",
alarm.CanonicalName));
}
// Ordering: LoLo ≤ Lo, Hi ≤ HiHi, and the highest Lo-side band
// must sit strictly below the lowest Hi-side band — otherwise the
// bands overlap and the evaluator's behavior is ambiguous.
if (setpoints.LoLo is { } loLo && setpoints.Lo is { } lo && loLo > lo)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: LoLo ({loLo}) must be ≤ Lo ({lo}).",
alarm.CanonicalName));
}
if (setpoints.Hi is { } hi && setpoints.HiHi is { } hiHi && hi > hiHi)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' HiLo setpoints out of order: Hi ({hi}) must be ≤ HiHi ({hiHi}).",
alarm.CanonicalName));
}
var highestLowSide = setpoints.Lo ?? setpoints.LoLo;
var lowestHighSide = setpoints.Hi ?? setpoints.HiHi;
if (highestLowSide is { } lowSide && lowestHighSide is { } highSide
&& lowSide >= highSide)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' HiLo bands overlap: low-side setpoint ({lowSide}) must be strictly less than high-side setpoint ({highSide}).",
alarm.CanonicalName));
}
// Deadbands must be non-negative — negative deadband would invert
// the hysteresis (alarm could escape faster than it entered).
foreach (var (name, value) in new (string, double?)[] {
("LoLo deadband", setpoints.LoLoDeadband),
("Lo deadband", setpoints.LoDeadband),
("Hi deadband", setpoints.HiDeadband),
("HiHi deadband", setpoints.HiHiDeadband)
})
{
if (value is { } d && d < 0)
{
errors.Add(ValidationEntry.Error(ValidationCategory.TriggerOperandType,
$"Alarm '{alarm.CanonicalName}' {name} ({d}) must be non-negative.",
alarm.CanonicalName));
}
}
}
// On-trigger script must exist
if (!string.IsNullOrWhiteSpace(alarm.OnTriggerScriptCanonicalName) &&
!scriptNames.Contains(alarm.OnTriggerScriptCanonicalName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.OnTriggerScriptNotFound,
$"Alarm '{alarm.CanonicalName}' references on-trigger script '{alarm.OnTriggerScriptCanonicalName}' which does not exist.",
alarm.CanonicalName));
}
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
private static void ValidateCallParameters(
string callerName,
CallTarget call,
Dictionary<string, List<string>> paramMap,
List<ValidationEntry> errors)
{
if (!paramMap.TryGetValue(call.TargetName, out var expectedParams))
return;
if (call.ArgumentCount != expectedParams.Count)
{
errors.Add(ValidationEntry.Error(ValidationCategory.ParameterMismatch,
$"Script '{callerName}' calls '{call.TargetName}' with {call.ArgumentCount} arguments but {expectedParams.Count} are expected.",
callerName));
}
}
private static Dictionary<string, List<string>> BuildParameterMap(IReadOnlyList<ResolvedScript> scripts)
{
var result = new Dictionary<string, List<string>>(StringComparer.Ordinal);
foreach (var script in scripts)
{
var parameters = ParseParameterDefinitions(script.ParameterDefinitions);
result[script.CanonicalName] = parameters;
}
return result;
}
private static Dictionary<string, string?> BuildReturnMap(IReadOnlyList<ResolvedScript> scripts)
{
var result = new Dictionary<string, string?>(StringComparer.Ordinal);
foreach (var script in scripts)
{
result[script.CanonicalName] = script.ReturnDefinition;
}
return result;
}
/// <summary>
/// Parses a parameter definitions JSON string (JSON Schema or legacy flat array) and returns the declared parameter names.
/// </summary>
/// <param name="parameterDefinitionsJson">JSON Schema or legacy flat-array string; null/empty returns an empty list.</param>
internal static List<string> ParseParameterDefinitions(string? parameterDefinitionsJson)
{
if (string.IsNullOrWhiteSpace(parameterDefinitionsJson))
return [];
try
{
using var doc = JsonDocument.Parse(parameterDefinitionsJson);
// JSON Schema: { type:"object", properties:{ name:{...}, ... }, required:[...] }
if (doc.RootElement.ValueKind == JsonValueKind.Object)
{
if (doc.RootElement.TryGetProperty("properties", out var props)
&& props.ValueKind == JsonValueKind.Object)
{
return props.EnumerateObject().Select(p => p.Name).ToList();
}
}
// Legacy flat form: [{ name, type, required? }]
else if (doc.RootElement.ValueKind == JsonValueKind.Array)
{
return doc.RootElement.EnumerateArray()
.Select(e => e.TryGetProperty("type", out var t) ? t.GetString() ?? "unknown" : "unknown")
.ToList();
}
}
catch (JsonException)
{
}
return [];
}
/// <summary>
/// Extracts call targets from script code by simple pattern matching.
/// Looks for CallScript("name", ...) and CallShared("name", ...) patterns.
/// </summary>
/// <param name="code">The script source code to scan.</param>
internal static List<CallTarget> ExtractCallTargets(string code)
{
var results = new List<CallTarget>();
ExtractCallsOfType(code, "CallScript", false, results);
ExtractCallsOfType(code, "CallShared", true, results);
return results;
}
private static void ExtractCallsOfType(string code, string methodName, bool isShared, List<CallTarget> results)
{
var searchPattern = methodName + "(";
int pos = 0;
while (pos < code.Length)
{
var idx = code.IndexOf(searchPattern, pos, StringComparison.Ordinal);
if (idx < 0) break;
var argsStart = idx + searchPattern.Length;
var target = ExtractStringArgument(code, argsStart);
if (target != null)
{
var argCount = CountArguments(code, argsStart);
results.Add(new CallTarget
{
TargetName = target,
IsShared = isShared,
ArgumentCount = Math.Max(0, argCount - 1) // First arg is the name, rest are parameters
});
}
pos = argsStart;
}
}
private static string? ExtractStringArgument(string code, int startPos)
{
// Skip whitespace
var pos = startPos;
while (pos < code.Length && char.IsWhiteSpace(code[pos])) pos++;
if (pos >= code.Length) return null;
// Expect a quote
var quote = code[pos];
if (quote != '"' && quote != '\'') return null;
pos++;
var nameStart = pos;
while (pos < code.Length && code[pos] != quote) pos++;
if (pos >= code.Length) return null;
return code[nameStart..pos];
}
private static int CountArguments(string code, int startPos)
{
var depth = 1;
var count = 1; // At least one argument (the name)
var pos = startPos;
while (pos < code.Length && depth > 0)
{
switch (code[pos])
{
case '(':
depth++;
break;
case ')':
depth--;
break;
case ',' when depth == 1:
count++;
break;
case '"':
case '\'':
// Skip string literals
var quote = code[pos];
pos++;
while (pos < code.Length && code[pos] != quote)
{
if (code[pos] == '\\') pos++; // Skip escaped chars
pos++;
}
break;
}
pos++;
}
return count;
}
internal record CallTarget
{
/// <summary>Name of the script being called.</summary>
public string TargetName { get; init; } = string.Empty;
/// <summary>True when the call is to a shared script via <c>CallShared</c>.</summary>
public bool IsShared { get; init; }
/// <summary>Number of non-name arguments passed to the call.</summary>
public int ArgumentCount { get; init; }
}
}
@@ -0,0 +1,600 @@
using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
namespace ZB.MOM.WW.ScadaBridge.TemplateEngine.Validation;
/// <summary>
/// Pre-deployment validation pipeline. Validates a FlattenedConfiguration for correctness
/// before deployment. Also available on-demand (same logic, no deployment trigger).
///
/// Validation checks:
/// 1. Flattening success (no empty configuration)
/// 2. No naming collisions
/// 3. Script compilation (via ScriptCompiler)
/// 4. Alarm trigger references exist (referenced attributes must be in the flattened config)
/// 5. Script trigger references exist (referenced attributes must be in the flattened config)
/// 6. Expression triggers — blank check, syntax check, and attribute-reference scan
/// 7. Connection binding completeness (all data-sourced attributes must have a binding)
/// 8. Does NOT verify tag path resolution on devices
/// </summary>
public class ValidationService
{
private readonly SemanticValidator _semanticValidator;
private readonly ScriptCompiler _scriptCompiler;
/// <summary>
/// Initializes a new instance of the ValidationService with the specified dependencies.
/// </summary>
/// <param name="semanticValidator">The semantic validator for configuration validation.</param>
/// <param name="scriptCompiler">The script compiler for validating script code.</param>
public ValidationService(SemanticValidator semanticValidator, ScriptCompiler scriptCompiler)
{
_semanticValidator = semanticValidator;
_scriptCompiler = scriptCompiler;
}
/// <summary>
/// Convenience constructor that creates default dependencies.
/// </summary>
public ValidationService() : this(new SemanticValidator(), new ScriptCompiler())
{
}
/// <summary>
/// Runs the full validation pipeline on a flattened configuration.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
/// <param name="sharedScripts">Optional list of shared scripts for validation context.</param>
public ValidationResult Validate(FlattenedConfiguration configuration, IReadOnlyList<ResolvedScript>? sharedScripts = null)
{
ArgumentNullException.ThrowIfNull(configuration);
var results = new List<ValidationResult>
{
ValidateFlatteningSuccess(configuration),
ValidateNamingCollisions(configuration),
ValidateScriptCompilation(configuration),
ValidateAlarmTriggerReferences(configuration),
ValidateScriptTriggerReferences(configuration),
ValidateExpressionTriggers(configuration),
ValidateConnectionBindingCompleteness(configuration),
_semanticValidator.Validate(configuration, sharedScripts)
};
return ValidationResult.Merge(results.ToArray());
}
/// <summary>
/// Validates that flattening produced a non-empty configuration.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
public static ValidationResult ValidateFlatteningSuccess(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
if (string.IsNullOrWhiteSpace(configuration.InstanceUniqueName))
errors.Add(ValidationEntry.Error(ValidationCategory.FlatteningFailure,
"Instance unique name is missing."));
if (configuration.Attributes.Count == 0 &&
configuration.Alarms.Count == 0 &&
configuration.Scripts.Count == 0)
{
return new ValidationResult
{
Warnings = [ValidationEntry.Warning(ValidationCategory.FlatteningFailure,
"Flattened configuration contains no attributes, alarms, or scripts.")]
};
}
return errors.Count > 0
? new ValidationResult { Errors = errors }
: ValidationResult.Success();
}
/// <summary>
/// Validates that there are no naming collisions across entity types.
/// Canonical names must be unique within their entity type (attributes, alarms, scripts).
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
public static ValidationResult ValidateNamingCollisions(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
CheckDuplicates(configuration.Attributes, a => a.CanonicalName, "Attribute", errors);
CheckDuplicates(configuration.Alarms, a => a.CanonicalName, "Alarm", errors);
CheckDuplicates(configuration.Scripts, s => s.CanonicalName, "Script", errors);
return errors.Count > 0
? new ValidationResult { Errors = errors }
: ValidationResult.Success();
}
/// <summary>
/// Validates that all scripts compile successfully using the ScriptCompiler.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
public ValidationResult ValidateScriptCompilation(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
foreach (var script in configuration.Scripts)
{
var result = _scriptCompiler.TryCompile(script.Code, script.CanonicalName);
if (!result.IsSuccess)
{
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptCompilation,
$"Script '{script.CanonicalName}' failed compilation: {result.Error}",
script.CanonicalName));
}
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
/// <summary>
/// Validates that alarm trigger configurations reference existing attributes.
/// Alarm trigger configs are JSON with an "attributeName" field referencing a canonical attribute name.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
public static ValidationResult ValidateAlarmTriggerReferences(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var attributeNames = new HashSet<string>(
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
foreach (var alarm in configuration.Alarms)
{
if (string.IsNullOrWhiteSpace(alarm.TriggerConfiguration))
continue;
var attrName = ExtractAttributeNameFromTriggerConfig(alarm.TriggerConfiguration);
if (attrName != null && !attributeNames.Contains(attrName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.AlarmTriggerReference,
$"Alarm '{alarm.CanonicalName}' references attribute '{attrName}' which does not exist in the flattened configuration.",
alarm.CanonicalName));
}
}
return errors.Count > 0
? new ValidationResult { Errors = errors }
: ValidationResult.Success();
}
/// <summary>
/// Validates that script trigger configurations reference existing attributes.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
public static ValidationResult ValidateScriptTriggerReferences(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var attributeNames = new HashSet<string>(
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
foreach (var script in configuration.Scripts)
{
if (string.IsNullOrWhiteSpace(script.TriggerConfiguration))
continue;
var attrName = ExtractAttributeNameFromTriggerConfig(script.TriggerConfiguration);
if (attrName != null && !attributeNames.Contains(attrName))
{
errors.Add(ValidationEntry.Error(ValidationCategory.ScriptTriggerReference,
$"Script '{script.CanonicalName}' trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
script.CanonicalName));
}
}
return errors.Count > 0
? new ValidationResult { Errors = errors }
: ValidationResult.Success();
}
/// <summary>
/// Validates Expression-trigger scripts and alarms before deployment.
///
/// For every script/alarm whose trigger type is "Expression" this performs three
/// checks against the <c>{ "expression": "..." }</c> trigger configuration:
/// <list type="bullet">
/// <item>Blank expression → warning (the trigger will never fire).</item>
/// <item>Syntax check → error if the expression uses a forbidden API or has
/// unbalanced brackets/quotes. The TemplateEngine project does not reference a
/// Roslyn compiler (see <see cref="ScriptCompiler"/>), so this mirrors that
/// string-based syntax check rather than a full compile.</item>
/// <item>Attribute-reference scan → error for any <c>Attributes["X"]</c> literal
/// whose key is absent from the flattened configuration, mirroring
/// <see cref="ValidateScriptTriggerReferences"/> for the structured triggers.</item>
/// </list>
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
public static ValidationResult ValidateExpressionTriggers(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
var attributeNames = new HashSet<string>(
configuration.Attributes.Select(a => a.CanonicalName), StringComparer.Ordinal);
foreach (var script in configuration.Scripts)
{
if (!IsExpressionTrigger(script.TriggerType))
continue;
CheckExpressionTrigger(
ValidationCategory.ScriptTriggerReference, "script",
script.CanonicalName, script.TriggerConfiguration,
attributeNames, errors, warnings);
}
foreach (var alarm in configuration.Alarms)
{
if (!IsExpressionTrigger(alarm.TriggerType))
continue;
CheckExpressionTrigger(
ValidationCategory.AlarmTriggerReference, "alarm",
alarm.CanonicalName, alarm.TriggerConfiguration,
attributeNames, errors, warnings);
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
private static bool IsExpressionTrigger(string? triggerType) =>
string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Runs the blank / syntax / attribute-reference checks for a single
/// Expression-trigger entity and appends any findings to the shared lists.
/// </summary>
/// <param name="category">
/// The <see cref="ValidationCategory"/> to file every finding under
/// (<see cref="ValidationCategory.ScriptTriggerReference"/> for scripts,
/// <see cref="ValidationCategory.AlarmTriggerReference"/> for alarms). The same
/// category is used for blank, syntax, and attribute-reference findings so an
/// alarm's syntax error is not miscategorised as script compilation.
/// </param>
/// <param name="entityLabel">
/// Human-readable entity-type label (<c>"script"</c>/<c>"alarm"</c>) used in
/// message text only.
/// </param>
private static void CheckExpressionTrigger(
ValidationCategory category,
string entityLabel,
string entityName,
string? triggerConfigJson,
HashSet<string> attributeNames,
List<ValidationEntry> errors,
List<ValidationEntry> warnings)
{
var expression = ExtractExpressionFromTriggerConfig(triggerConfigJson);
if (string.IsNullOrWhiteSpace(expression))
{
warnings.Add(ValidationEntry.Warning(category,
$"The {entityLabel} '{entityName}' has an expression trigger with no expression; it will never fire.",
entityName));
return;
}
var syntaxError = CheckExpressionSyntax(expression);
if (syntaxError != null)
{
errors.Add(ValidationEntry.Error(category,
$"The {entityLabel} '{entityName}' expression trigger failed validation: {syntaxError}",
entityName));
}
foreach (var attrName in ExtractAttributeReferences(expression))
{
if (!attributeNames.Contains(attrName))
{
errors.Add(ValidationEntry.Error(category,
$"The {entityLabel} '{entityName}' expression trigger references attribute '{attrName}' which does not exist in the flattened configuration.",
entityName));
}
}
}
/// <summary>
/// Reads the "expression" string from a <c>{ "expression": "..." }</c> trigger
/// configuration. Returns <c>null</c> on malformed JSON or a missing key.
/// </summary>
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
internal static string? ExtractExpressionFromTriggerConfig(string? triggerConfigJson)
{
if (string.IsNullOrWhiteSpace(triggerConfigJson))
return null;
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
if (doc.RootElement.TryGetProperty("expression", out var prop)
&& prop.ValueKind == JsonValueKind.String)
{
return prop.GetString();
}
}
catch (JsonException)
{
// Not valid JSON — treated as a blank expression by the caller.
}
return null;
}
/// <summary>
/// Lightweight string-based syntax check for a trigger expression. Mirrors the
/// approach in <see cref="ScriptCompiler"/> (the TemplateEngine project has no
/// Roslyn compiler reference): rejects forbidden APIs and unbalanced
/// brackets/quotes. Returns an error message, or <c>null</c> when the expression
/// looks well-formed.
/// </summary>
/// <param name="expression">The expression to check for syntax errors.</param>
internal static string? CheckExpressionSyntax(string expression)
{
// Advisory forbidden-API scan (TemplateEngine-006): code-region-aware so
// the inert text inside a string/comment is not flagged, but still a
// substring match — not an authoritative boundary. See ScriptCompiler.
foreach (var pattern in ScriptCompiler.ForbiddenPatterns)
{
if (CSharpDelimiterScanner.ContainsInCode(expression, pattern))
{
return $"uses forbidden API '{pattern.TrimEnd('.')}'. " +
"Trigger expressions cannot use System.IO, Process, Threading, Reflection, or raw network APIs.";
}
}
var parenDepth = 0;
var bracketDepth = 0;
var braceDepth = 0;
var inString = false;
var inChar = false;
var inLineComment = false;
var inBlockComment = false;
for (int i = 0; i < expression.Length; i++)
{
var c = expression[i];
var next = i + 1 < expression.Length ? expression[i + 1] : '\0';
if (inLineComment)
{
if (c == '\n') inLineComment = false;
continue;
}
if (inBlockComment)
{
if (c == '*' && next == '/')
{
inBlockComment = false;
i++;
}
continue;
}
if (inString)
{
if (c == '\\') { i++; continue; }
if (c == '"') inString = false;
continue;
}
if (inChar)
{
if (c == '\\') { i++; continue; }
if (c == '\'') inChar = false;
continue;
}
if (c == '/' && next == '/')
{
inLineComment = true;
i++;
continue;
}
if (c == '/' && next == '*')
{
inBlockComment = true;
i++;
continue;
}
switch (c)
{
case '"': inString = true; break;
case '\'': inChar = true; break;
case '(': parenDepth++; break;
case ')':
parenDepth--;
if (parenDepth < 0) return "mismatched parentheses (unexpected ')').";
break;
case '[': bracketDepth++; break;
case ']':
bracketDepth--;
if (bracketDepth < 0) return "mismatched brackets (unexpected ']').";
break;
case '{': braceDepth++; break;
case '}':
braceDepth--;
if (braceDepth < 0) return "mismatched braces (unexpected '}').";
break;
}
}
if (inBlockComment) return "unterminated block comment.";
if (inString) return "unterminated string literal.";
if (inChar) return "unterminated character literal.";
if (parenDepth != 0) return $"mismatched parentheses ({parenDepth} unclosed).";
if (bracketDepth != 0) return $"mismatched brackets ({bracketDepth} unclosed).";
if (braceDepth != 0) return $"mismatched braces ({braceDepth} unclosed).";
return null;
}
/// <summary>
/// Scans an expression for <c>Attributes["..."]</c> string-literal accessor keys.
/// Best-effort: only matches double-quoted literals (the form the editor emits)
/// and skips keys built dynamically.
/// </summary>
/// <param name="expression">The expression to scan for attribute references.</param>
internal static IEnumerable<string> ExtractAttributeReferences(string expression)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
const string marker = "Attributes[";
var index = 0;
while ((index = expression.IndexOf(marker, index, StringComparison.Ordinal)) >= 0)
{
// Only treat this as a self-attribute reference when it is not a member
// access. A bare `Attributes["X"]` resolves against the flattened
// configuration; `Children["Pump"].Attributes["X"]` and
// `Parent.Attributes["X"]` are member accesses (preceded by '.') whose
// dotted/composed canonical names cannot be checked against the flat
// self-attribute set — skip them rather than emit a false positive.
if (index > 0 && expression[index - 1] == '.')
{
index += marker.Length;
continue;
}
var cursor = index + marker.Length;
// Skip whitespace between '[' and the literal.
while (cursor < expression.Length && char.IsWhiteSpace(expression[cursor]))
cursor++;
if (cursor < expression.Length && expression[cursor] == '"')
{
var keyStart = cursor + 1;
var keyEnd = keyStart;
while (keyEnd < expression.Length && expression[keyEnd] != '"')
{
if (expression[keyEnd] == '\\') keyEnd++; // skip escaped char
keyEnd++;
}
if (keyEnd < expression.Length)
{
var key = expression.Substring(keyStart, keyEnd - keyStart);
if (key.Length > 0 && seen.Add(key))
yield return key;
}
}
index += marker.Length;
}
}
/// <summary>
/// Validates that all data-sourced attributes have connection bindings.
/// </summary>
/// <param name="configuration">The flattened configuration to validate.</param>
public static ValidationResult ValidateConnectionBindingCompleteness(FlattenedConfiguration configuration)
{
var errors = new List<ValidationEntry>();
var warnings = new List<ValidationEntry>();
foreach (var attr in configuration.Attributes)
{
if (attr.DataSourceReference != null && attr.BoundDataConnectionId == null)
{
warnings.Add(ValidationEntry.Warning(ValidationCategory.ConnectionBinding,
$"Attribute '{attr.CanonicalName}' has a data source reference but no connection binding.",
attr.CanonicalName));
}
}
return new ValidationResult { Errors = errors, Warnings = warnings };
}
private static void CheckDuplicates<T>(
IReadOnlyList<T> items,
Func<T, string> getName,
string entityType,
List<ValidationEntry> errors)
{
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in items)
{
var name = getName(item);
if (!seen.Add(name))
{
errors.Add(ValidationEntry.Error(ValidationCategory.NamingCollision,
$"{entityType} naming collision: '{name}' appears more than once.",
name));
}
}
}
/// <summary>
/// Extracts the attribute name from a trigger configuration JSON.
/// </summary>
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
internal static string? ExtractAttributeNameFromTriggerConfig(string triggerConfigJson)
{
// Accept both keys to stay consistent with FlatteningService.PrefixTriggerAttribute,
// AlarmActor.ParseEvalConfig and AlarmTriggerConfigCodec. Old data may still use
// "attribute"; the UI codec writes the canonical "attributeName".
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
if (doc.RootElement.TryGetProperty("attributeName", out var prop))
return prop.GetString();
if (doc.RootElement.TryGetProperty("attribute", out var legacyProp))
return legacyProp.GetString();
}
catch (JsonException)
{
// Not valid JSON, ignore
}
return null;
}
/// <summary>
/// Extracts the four HiLo setpoints from a trigger configuration JSON.
/// Any unset (or non-numeric) setpoint comes back as <c>null</c>. Returns
/// all-nulls on malformed JSON — callers should treat that as "nothing to
/// validate" and let other checks surface the deeper problem.
/// </summary>
/// <param name="triggerConfigJson">The trigger configuration JSON to parse.</param>
internal static HiLoSetpoints ExtractHiLoSetpoints(string triggerConfigJson)
{
try
{
using var doc = JsonDocument.Parse(triggerConfigJson);
var root = doc.RootElement;
return new HiLoSetpoints(
LoLo: ReadDouble(root, "loLo"),
Lo: ReadDouble(root, "lo"),
Hi: ReadDouble(root, "hi"),
HiHi: ReadDouble(root, "hiHi"),
LoLoDeadband: ReadDouble(root, "loLoDeadband"),
LoDeadband: ReadDouble(root, "loDeadband"),
HiDeadband: ReadDouble(root, "hiDeadband"),
HiHiDeadband: ReadDouble(root, "hiHiDeadband"));
}
catch (JsonException)
{
return new HiLoSetpoints(null, null, null, null, null, null, null, null);
}
}
private static double? ReadDouble(JsonElement el, string name)
{
if (!el.TryGetProperty(name, out var p)) return null;
return p.ValueKind switch
{
JsonValueKind.Number => p.GetDouble(),
JsonValueKind.String when double.TryParse(p.GetString(),
System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var v) => v,
_ => null
};
}
}
internal readonly record struct HiLoSetpoints(
double? LoLo, double? Lo, double? Hi, double? HiHi,
double? LoLoDeadband = null, double? LoDeadband = null,
double? HiDeadband = null, double? HiHiDeadband = null);